diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..58e7d29d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,136 @@ +# Copilot Instructions for EasyDiffraction + +## Project Context + +- Python library for crystallographic diffraction analysis, such as refinement + of the structural model against experimental data. +- Support for + - sample_form: powder and single crystal + - beam_mode: time-of-flight and constant wavelength + - radiation_probe: neutron and x-ray + - scattering_type: bragg and total scattering +- Calculations are done using external calculation libraries: + - `cryspy` for Bragg diffraction + - `crysfml` for Bragg diffraction + - `pdffit2` for Total scattering +- Follow CIF naming conventions where possible. In some places, we deviate for + better API design, but we try to keep the spirit of the CIF names. +- Reusing the concept of datablocks and categories from CIF. We have + `DatablockItem` (structure or experiment) and `DatablockCollection` + (collection of structures or experiments), as well as `CategoryItem` (single + categories in CIF) and `CategoryCollection` (loop categories in CIF). +- Metadata via frozen dataclasses: `TypeInfo`, `Compatibility`, + `CalculatorSupport`. +- The API is designed for scientists who use EasyDiffraction as a final product + in a user-friendly, intuitive way. The target users are not software + developers and may have little or no Python experience. The design is not + oriented toward developers building their own tooling on top of the library, + although experienced developers will find their own way. Prioritize + discoverability, clear error messages, and safe defaults so that + non-programmers are not stuck by standard API conventions. +- This project must be developed to be as error-free as possible, with the same + rigour applied to critical software (e.g. nuclear-plant control systems). + Every code path must be tested, edge cases must be handled explicitly, and + silent failures are not acceptable. + +## Code Style + +- Use snake_case for functions and variables, PascalCase for classes, and + UPPER_SNAKE_CASE for constants. +- Use `from __future__ import annotations` in every module. +- Type-annotate all public function signatures. +- Docstrings on all public classes and methods (Google style). +- Prefer flat over nested, explicit over clever. +- Write straightforward code; do not add defensive checks for unlikely edge + cases. +- Prefer composition over deep inheritance. +- One class per file when the class is substantial; group small related classes. +- Avoid `**kwargs`; use explicit keyword arguments for clarity, autocomplete, + and typo detection. +- Do not use string-based dispatch (e.g. `getattr(self, f'_{name}')`) to route + to attributes or methods. Instead, write explicit named methods (e.g. + `_set_sample_form`, `_set_beam_mode`). This keeps the code greppable, + autocomplete-friendly, and type-safe. +- Public parameters and descriptors are either **editable** (property with both + getter and setter) or **read-only** (property with getter only). If internal + code needs to mutate a read-only property, add a private `_set_` method + instead of exposing a public setter. + +## Architecture + +- Eager imports at the top of the module by default. Use lazy imports (inside a + method body) only when necessary to break circular dependencies or to keep + `core/` free of heavy utility imports on rarely-called paths (e.g. `help()`). +- No `pkgutil` / `importlib` auto-discovery patterns. +- No background/daemon threads. +- No monkey-patching or runtime class mutation. +- Do not use `__all__` in modules; instead, rely on explicit imports in + `__init__.py` to control the public API. +- Do not use redundant `import X as X` aliases in `__init__.py`. Use plain + `from module import X`. +- Concrete classes use `@Factory.register` decorators. To trigger registration, + each package's `__init__.py` must explicitly import every concrete class (e.g. + `from .chebyshev import ChebyshevPolynomialBackground`). When adding a new + concrete class, always add its import to the corresponding `__init__.py`. +- Switchable categories (those whose implementation can be swapped at runtime + via a factory) follow a fixed naming convention on the owner (experiment, + structure, or analysis): `` (read-only property), `_type` + (getter + setter), `show_supported__types()`, + `show_current__type()`. The owner class owns the type setter and the + show methods; the show methods delegate to `Factory.show_supported(...)` + passing context. Every factory-created category must have this full API, even + if only one implementation exists today. +- Categories are flat siblings within their owner (datablock or analysis). A + category must never be a child of another category of a different type. + Categories can reference each other via IDs, but not via parent-child nesting. +- Every finite, closed set of values (factory tags, experiment axes, category + descriptors with enumerated choices) must use a `(str, Enum)` class. Internal + code compares against enum members, never raw strings. +- Keep `core/` free of domain logic — only base classes and utilities. +- Don't introduce a new abstraction until there is a concrete second use case. +- Don't add dependencies without asking. + +## Changes + +- Before implementing any structural or design change (new categories, new + factories, switchable-category wiring, new datablocks, CIF serialisation + changes), read `docs/architecture/architecture.md` to understand the current + design choices and conventions. Follow the documented patterns (factory + registration, switchable-category naming, metadata classification, etc.) to + stay consistent with the rest of the codebase. For localised bug fixes or test + updates, the rules in this file are sufficient. +- The project is in beta; do not keep legacy code or add deprecation warnings. + Instead, update tests and tutorials to follow the current API. +- Minimal diffs: don't rewrite working code just to reformat it. +- Never remove or replace existing functionality as part of a new change without + explicit confirmation. If a refactor would drop features, options, or + configurations, highlight every removal and wait for approval. +- Fix only what's asked; flag adjacent issues as comments, don't fix them + silently. +- Don't add new features or refactor existing code unless explicitly asked. +- Do not remove TODOs or comments unless the change fully resolves them. +- When renaming, grep the entire project (code, tests, tutorials, docs). +- Every change should be atomic and self-contained, small enough to be described + by a single commit message. Make one change, suggest the commit message, then + stop and wait for confirmation before starting the next change. +- When in doubt, ask for clarification before making changes. + +## Workflow + +- All open issues, design questions, and planned improvements are tracked in + `docs/architecture/issues_open.md`, ordered by priority. When an issue is + fully implemented, move it from that file to + `docs/architecture/issues_closed.md`. When the resolution affects the + architecture, update the relevant sections of + `docs/architecture/architecture.md`. +- After changes, run linting and formatting fixes with `pixi run fix`. Do not + check what was auto-fixed, just accept the fixes and move on. +- After changes, run unit tests with `pixi run unit-tests`. +- After changes, run integration tests with `pixi run integration-tests`. +- After changes, run tutorial tests with `pixi run script-tests`. +- Suggest a concise commit message (as a code block) after each change (less + than 72 characters, imperative mood, without prefixing with the type of + change). E.g.: + - Add ChebyshevPolynomialBackground class + - Implement background_type setter on Experiment + - Standardize switchable-category naming convention diff --git a/docs/api-reference/datablocks/experiment.md b/docs/api-reference/datablocks/experiment.md new file mode 100644 index 00000000..b0d19a6a --- /dev/null +++ b/docs/api-reference/datablocks/experiment.md @@ -0,0 +1 @@ +::: easydiffraction.datablocks.experiment diff --git a/docs/api-reference/datablocks/structure.md b/docs/api-reference/datablocks/structure.md new file mode 100644 index 00000000..43f752ff --- /dev/null +++ b/docs/api-reference/datablocks/structure.md @@ -0,0 +1 @@ +::: easydiffraction.datablocks.structure diff --git a/docs/api-reference/experiments.md b/docs/api-reference/experiments.md deleted file mode 100644 index 2eb1bd91..00000000 --- a/docs/api-reference/experiments.md +++ /dev/null @@ -1 +0,0 @@ -::: easydiffraction.experiments diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index 25418279..6a6f8be4 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -13,12 +13,13 @@ available in EasyDiffraction: space groups, and symmetry operations. - [utils](utils.md) – Miscellaneous utility functions for formatting, decorators, and general helpers. +- datablocks + - [experiments](datablocks/experiment.md) – Manages experimental setups and + instrument parameters, as well as the associated diffraction data. + - [structures](datablocks/structure.md) – Defines structures, such as + crystallographic structures, and manages their properties. - [display](display.md) – Tools for plotting data and rendering tables. - [project](project.md) – Defines the project and manages its state. -- [sample_models](sample_models.md) – Defines sample models, such as - crystallographic structures, and manages their properties. -- [experiments](experiments.md) – Manages experimental setups and instrument - parameters, as well as the associated diffraction data. - [analysis](analysis.md) – Provides tools for analyzing diffraction data, including fitting and minimization. - [summary](summary.md) – Provides a summary of the project. diff --git a/docs/api-reference/sample_models.md b/docs/api-reference/sample_models.md deleted file mode 100644 index 43c54901..00000000 --- a/docs/api-reference/sample_models.md +++ /dev/null @@ -1 +0,0 @@ -::: easydiffraction.sample_models diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md new file mode 100644 index 00000000..3770f3ea --- /dev/null +++ b/docs/architecture/architecture.md @@ -0,0 +1,1034 @@ +# EasyDiffraction Architecture + +**Version:** 1.0 +**Date:** 2026-03-24 +**Status:** Living document — updated as the project evolves + +--- + +## 1. Overview + +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. + +### 1.1 Supported Experiment Dimensions + +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. + +### 1.2 Calculation Engines + +External libraries perform the heavy computation: + +| Engine | Scope | +| --------- | ----------------- | +| `cryspy` | Bragg diffraction | +| `crysfml` | Bragg diffraction | +| `pdffit2` | Total scattering | + +--- + +## 2. Core Abstractions + +All core types live in `core/` which contains **only** base classes and +utilities — no domain logic. + +### 2.1 Object Hierarchy + +```shell +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. + +### 2.2 GuardedBase — Controlled Attribute Access + +`GuardedBase` is the root ABC. It enforces that only **declared `@property` +attributes** are accessible publicly: + +- **`__getattr__`** rejects any attribute not declared as a `@property` on 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. +- **Parent linkage** — when a `GuardedBase` child is assigned to another, the + child's `_parent` is set automatically, forming an implicit ownership tree. +- **Identity** — every instance gets an `_identity: Identity` object for lazy + CIF-style name resolution (`datablock_entry_name`, `category_code`, + `category_entry_name`) by walking the `_parent` chain. + +**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. + +#### 2.2.1 Public Property Convention — Editable vs Read-Only + +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_` 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: + +```python +@property +def name(self) -> str: + """Human-readable name of the experiment.""" + return self._name + + +@name.setter +def name(self, new: str) -> None: + self._name = new +``` + +**Read-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_` method instead of exposing a public +setter: + +```python +@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 = value +``` + +**Why 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_` 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. + +### 2.3 CategoryItem and CategoryCollection + +| 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). + +### 2.4 DatablockItem and DatablockCollection + +| 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 `Parameter`s | +| 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. + +### 2.5 Variable System — Parameters and Descriptors + +```shell +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, uid +``` + +CIF-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`). + +### 2.6 Validation + +`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 | + +--- + +## 3. Experiment System + +### 3.1 Experiment Type + +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 `StringDescriptor`s validated by `MembershipValidator`s. 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. + +### 3.2 Experiment Hierarchy + +```shell +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 + └── TofScExperiment +``` + +Each concrete experiment class carries: + +- `type_info: TypeInfo` — tag and description for factory lookup +- `compatibility: Compatibility` — which enum axis values it supports + +### 3.3 Category Ownership + +Every experiment owns its categories as private attributes with public read-only +or read-write properties: + +```python +# 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. + +--- + +## 4. Structure System + +### 4.1 Structure Hierarchy + +```shell +DatablockItem +└── Structure # name, cell, space_group, atom_sites +``` + +A `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()`. + +--- + +## 5. Factory System + +### 5.1 FactoryBase + +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`. + +### 5.2 Default Rules + +`_default_rules` maps frozensets of `(axis_name, enum_value)` tuples to tag +strings (preferably enum values for type safety): + +```python +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. + +### 5.3 Metadata on Registered Classes + +Every `@Factory.register`-ed class carries three frozen dataclass attributes: + +```python +@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 | + +### 5.4 Registration Trigger + +Concrete classes use `@Factory.register` decorators. To trigger registration, +each package's `__init__.py` must **explicitly import** every concrete class: + +```python +# datablocks/experiment/categories/background/__init__.py +from .chebyshev import ChebyshevPolynomialBackground +from .line_segment import LineSegmentBackground +``` + +### 5.5 All Factories + +| 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:** `ExperimentFactory` and `StructureFactory` are _builder_ factories +> with `from_cif_path`, `from_cif_str`, `from_data_path`, and `from_scratch` +> classmethods. `ExperimentFactory` inherits `FactoryBase` and uses `@register` +> on all four concrete experiment classes; `_resolve_class` looks up the +> registered class via `default_tag()` + `_supported_map()`. `StructureFactory` +> is a plain class without `FactoryBase` inheritance (only one structure type +> exists today). + +### 5.6 Tag Naming Convention + +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. + +#### Standard Abbreviations + +| 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` | | + +#### Complete Tag Registry + +**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 the `FactoryBase` migration. See +> `issues_open.md` for details. + +### 5.7 Metadata Classification — Which Classes Get What + +#### The Rule + +> **If a concrete class is created by a factory, it gets `type_info`, +> `compatibility`, and `calculator_support`.** +> +> **If a `CategoryItem` only exists as a child row inside a +> `CategoryCollection`, it does NOT get these attributes — the collection +> does.** + +#### Rationale + +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. + +#### Singleton CategoryItems — factory-created (get all three) + +| 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` | + +#### CategoryCollections — factory-created (get all three) + +| 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` | + +#### CategoryItems that are ONLY children of collections (NO metadata) + +| 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` | + +#### Non-category classes — factory-created (get `type_info` only) + +| 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) | + +--- + +## 6. Analysis + +### 6.1 Calculator + +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 + setter +- `show_supported_calculator_types()` — filtered by data category support +- `show_current_calculator_type()` + +### 6.2 Minimiser + +The minimiser drives the optimisation loop. `MinimizerFactory` creates instances +by tag (e.g. `'lmfit'`, `'dfols'`). + +### 6.3 Fitter + +`Fitter` wraps a minimiser instance and orchestrates the fitting workflow: + +1. Collect `free_parameters` from structures + experiments. +2. Record start values. +3. Build an objective function that calls the calculator. +4. Delegate to `minimizer.fit()`. +5. Sync results (values + uncertainties) back to parameters. + +### 6.4 Analysis Object + +`Analysis` is bound to a `Project` and provides the high-level API: + +- Minimiser selection: `current_minimizer`, `show_available_minimizers()` +- Fit mode: `fit_mode` (`CategoryItem` with a `mode` descriptor validated by + `FitModeEnum`); `'single'` fits each experiment independently, `'joint'` fits + all simultaneously with weights from `joint_fit_experiments`. +- Joint-fit weights: `joint_fit_experiments` (`CategoryCollection` of + per-experiment weight entries); sibling of `fit_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`) + +--- + +## 7. Project — The Top-Level Façade + +`Project` is the single entry point for the user: + +```python +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 | + +### 7.1 Data Flow + +``` +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 +``` + +### 7.2 Persistence + +Projects are saved as a directory of CIF files: + +```shell +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 experiment +``` + +--- + +## 8. User-Facing API Patterns + +All examples below are drawn from the actual tutorials (`tutorials/`). + +### 8.1 Project Setup + +```python +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) +``` + +### 8.2 Define Structures + +```python +# 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() +``` + +### 8.3 Define Experiments + +```python +# 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) +``` + +### 8.4 Analysis and Fitting + +```python +# 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() +``` + +### 8.5 TOF Experiment (tutorial ed-7) + +```python +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.0 +``` + +### 8.6 Total Scattering / PDF (tutorial ed-12) + +```python +project.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 +``` + +--- + +## 9. Design Principles + +### 9.1 Naming and CIF Conventions + +- 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 CIF `data_` block, `DatablockCollection` = set of + blocks. +- `CategoryItem` = one CIF category, `CategoryCollection` = CIF loop. + +### 9.2 Immutability of Experiment Type + +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. + +### 9.3 Category Type Switching + +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: + +```python +# ✅ 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. + +### 9.4 Switchable-Category Convention + +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 | `` property (read-only) | `expt.background`, `expt.peak` | +| Active type tag | `_type` property (getter + setter) | `expt.background_type`, `expt.peak_profile_type` | +| Show supported | `show_supported__types()` | `expt.show_supported_background_types()` | +| Show current | `show_current__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 `_type` setter because switching replaces the + entire category object (`self._background = BackgroundFactory.create(...)`). +- The **experiment owns** the `show_*` methods because they are one-liners that + delegate to `Factory.show_supported(...)` and can pass experiment-specific + context (e.g. `scattering_type`, `beam_mode` for peak filtering). +- Concrete category subclasses provide a public `show()` method for displaying + the current content (not on the base `CategoryItem`/`CategoryCollection`). + +### 9.5 Discoverable Supported Options + +The user can always discover what is supported for the current experiment: + +```python +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. + +### 9.6 Enums for Finite Value Sets + +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.JOINT` finds 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 `MembershipValidator` with the enum members + instead of `RegexValidator` with 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: + +```python +# ✅ Correct — compare with enum +if self._fit_mode.mode.value == FitModeEnum.JOINT: + +# ❌ Wrong — compare with raw string +if self._fit_mode.mode.value == 'joint': +``` + +### 9.7 Flat Category Structure — No Nested Categories + +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: + +```python +# ✅ 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.7 +``` + +In 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 +``` + +--- + +## 10. Issues + +- **Open:** [`issues_open.md`](issues_open.md) — prioritised backlog. +- **Closed:** [`issues_closed.md`](issues_closed.md) — resolved items for + reference. + +When a resolution affects the architecture described above, the relevant +sections of this document are updated accordingly. diff --git a/docs/architecture/issues_closed.md b/docs/architecture/issues_closed.md new file mode 100644 index 00000000..bc8ea19b --- /dev/null +++ b/docs/architecture/issues_closed.md @@ -0,0 +1,60 @@ +# EasyDiffraction — Closed Issues + +Issues that have been fully resolved. Kept for historical reference. + +--- + +## Dirty-Flag Guard Was Disabled + +**Resolution:** added `_set_value_from_minimizer()` on `GenericDescriptorBase` +that writes `_value` directly (no validation) but sets the dirty flag on the +parent `DatablockItem`. Both `LmfitMinimizer` and `DfolsMinimizer` now use it. +The guard in `DatablockItem._update_categories()` is enabled and skips redundant +updates on the user-facing path (CIF export, plotting). During fitting the guard +is bypassed (`called_by_minimizer=True`) because experiment calculations depend +on structure parameters owned by a different `DatablockItem`. + +--- + +## Move Calculator from Global to Per-Experiment + +**Resolution:** removed the global calculator from `Analysis`. Each experiment +now owns its calculator, auto-resolved on first access from +`CalculatorFactory._default_rules` (maps `scattering_type` → default tag) and +filtered by the data category's `calculator_support` metadata (e.g. `PdCwlData` +→ `{CRYSPY}`, `TotalData` → `{PDFFIT}`). Calculator classes no longer carry +`compatibility` attributes — limitations are expressed on categories. The +experiment exposes the standard switchable-category API: `calculator` +(read-only, lazy), `calculator_type` (getter + setter), +`show_supported_calculator_types()`, `show_current_calculator_type()`. +Tutorials, tests, and docs updated. + +--- + +## Add Universal Factories for All Categories + +**Resolution:** converted every category to use the `FactoryBase` pattern. Each +former single-file category is now a package with `factory.py` (trivial +`FactoryBase` subclass), `default.py` (concrete class with `@register` + +`type_info`), and `__init__.py` (re-exports preserving import compatibility). + +Experiment categories: `Extinction` → `ShelxExtinction` / `ExtinctionFactory` +(tag `shelx`), `LinkedCrystal` / `LinkedCrystalFactory` (tag `default`), +`ExcludedRegions` / `ExcludedRegionsFactory`, `LinkedPhases` / +`LinkedPhasesFactory`, `ExperimentType` / `ExperimentTypeFactory`. + +Structure categories: `Cell` / `CellFactory`, `SpaceGroup` / +`SpaceGroupFactory`, `AtomSites` / `AtomSitesFactory`. + +Analysis categories: `Aliases` / `AliasesFactory`, `Constraints` / +`ConstraintsFactory`, `JointFitExperiments` / `JointFitExperimentsFactory`. + +`ShelxExtinction` and `LinkedCrystal` get the full switchable-category API on +`ScExperimentBase` (`extinction_type`, `linked_crystal_type` getter+setter, +`show_supported_*_types()`, `show_current_*_type()`). `ExcludedRegions` and +`LinkedPhases` get the same API on `PdExperimentBase`. `Cell`, `SpaceGroup`, and +`AtomSites` get it on `Structure`. `Aliases` and `Constraints` get it on +`Analysis`. Architecture §3.3, §5.5, §5.7, §9.4, §9.5 updated. Copilot +instructions updated with universal switchable-category scope and +architecture-first workflow rule. Unit tests extended with factory tests for +extinction and linked-crystal. diff --git a/docs/architecture/issues_open.md b/docs/architecture/issues_open.md new file mode 100644 index 00000000..d8bd37a2 --- /dev/null +++ b/docs/architecture/issues_open.md @@ -0,0 +1,339 @@ +# EasyDiffraction — Open Issues + +Prioritised list of issues, improvements, and design questions to address. Items +are ordered by a combination of user impact, blocking potential, and +implementation readiness. When an item is fully implemented, remove it from this +file and update `architecture.md` if needed. + +**Legend:** 🔴 High · 🟡 Medium · 🟢 Low + +--- + +## 1. 🔴 Implement `Project.load()` + +**Type:** Completeness + +`save()` serialises all components to CIF files but `load()` is a stub that +raises `NotImplementedError`. Users cannot round-trip a project. + +**Why first:** this is the highest-severity gap. Without it the save +functionality is only half useful — CIF files are written but cannot be read +back. Tutorials that demonstrate save/load are blocked. + +**Fix:** implement `load()` that reads CIF files from the project directory and +reconstructs structures, experiments, and analysis settings. + +**Depends on:** nothing (standalone). + +--- + +## 2. 🟡 Restore Minimiser Variant Support + +**Type:** Feature loss + Design limitation + +After the `FactoryBase` migration only `'lmfit'` and `'dfols'` remain as +registered tags. The ability to select a specific lmfit algorithm (e.g. +`'lmfit (leastsq)'`, `'lmfit (least_squares)'`) raises a `ValueError`. + +The root cause is that `FactoryBase` assumes one class ↔ one tag; registering +the same class twice with different constructor arguments is not supported. + +**Fix:** decide on an approach (thin subclasses, extended registry, or two-level +selection) and implement. Thin subclasses is the quickest. + +**Planned tags:** + +| Tag | Description | +| ----------------------- | ------------------------------------------------------------------------ | +| `lmfit` | LMFIT library using the default Levenberg-Marquardt least squares method | +| `lmfit (leastsq)` | LMFIT library with Levenberg-Marquardt least squares method | +| `lmfit (least_squares)` | LMFIT library with SciPy's trust region reflective algorithm | +| `dfols` | DFO-LS library for derivative-free least-squares optimization | + +**Trade-offs:** + +| Approach | Pros | Cons | +| -------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| **A. Thin subclasses** (one per variant) | Works today; each variant gets full metadata; no `FactoryBase` changes | Class proliferation; boilerplate | +| **B. Extend registry to store `(class, kwargs)` tuples** | No extra classes; factory handles variants natively | `_supported_map` changes shape; `TypeInfo` moves from class attribute to registration-time data | +| **C. Two-level selection** (`engine` + `algorithm`) | Clean separation; engine maps to class, algorithm is a constructor arg | More complex API (`current_minimizer = ('lmfit', 'least_squares')`); needs new `FactoryBase` protocol | + +**Depends on:** nothing (standalone, but should be decided before more factories +adopt variants). + +--- + +## 3. 🟡 Rebuild Joint-Fit Weights on Every Fit + +**Type:** Fragility + +`joint_fit_experiments` is created once when `fit_mode` becomes `'joint'`. If +experiments are added, removed, or renamed afterwards, the weight collection is +stale. Joint fitting can fail with missing keys or run with incorrect weights. + +**Fix:** rebuild or validate `joint_fit_experiments` at the start of every joint +fit. At minimum, `fit()` should assert that the weight keys exactly match +`project.experiments.names`. + +**Depends on:** nothing. + +--- + +## 4. 🔴 Refresh Constraint State Before Automatic Updates and Fitting + +**Type:** Correctness + +`ConstraintsHandler` is only synchronised from `analysis.aliases` and +`analysis.constraints` when the user explicitly calls +`project.analysis.apply_constraints()`. The normal fit / serialisation path +calls `constraints_handler.apply()` directly, so newly added or edited aliases +and constraints can be ignored until that manual sync step happens. + +**Why high:** this produces silently incorrect results. A user can define +constraints, run a fit, and believe they were applied when the active singleton +still contains stale state from a previous run or no state at all. + +**Fix:** before any automatic constraint application, always refresh the +singleton from the current `Aliases` and `Constraints` collections. The sync +should happen inside `Analysis._update_categories()` or inside the constraints +category itself, not only in a user-facing helper method. + +**Depends on:** nothing. + +--- + +## 5. 🟡 Make `Analysis` a `DatablockItem` + +**Type:** Consistency + +`Analysis` owns categories (`Aliases`, `Constraints`, `JointFitExperiments`) but +does not extend `DatablockItem`. Its ad-hoc `_update_categories()` iterates over +a hard-coded list and does not participate in standard category discovery, +parameter enumeration, or CIF serialisation. + +**Fix:** make `Analysis` extend `DatablockItem`, or extract a shared +`_update_categories()` protocol. + +**Depends on:** benefits from issue 1 (load/save) being designed first. + +--- + +## 6. 🔴 Restrict `data_type` Switching to Compatible Types and Preserve Data Safety + +**Type:** Correctness + Data safety + +`Experiment.data_type` currently validates against all registered data tags +rather than only those compatible with the experiment's `sample_form` / +`scattering_type` / `beam_mode`. This allows users to switch an experiment to an +incompatible data collection class. The setter also replaces the existing data +object with a fresh empty instance, discarding loaded data without warning. + +**Why high:** the current API can create internally inconsistent experiments and +silently lose measured data, which is especially dangerous for notebook and +tutorial workflows. + +**Fix:** filter supported data types through `DataFactory.supported_for(...)` +using the current experiment context, and warn or block when a switch would +discard existing data. If runtime data-type switching is not a real user need, +consider making `data` effectively fixed after experiment creation. + +**Depends on:** nothing. + +--- + +## 7. 🟡 Eliminate Dummy `Experiments` Wrapper in Single-Fit Mode + +**Type:** Fragility + +Single-fit mode creates a throw-away `Experiments` collection per experiment, +manually forces `_parent` via `object.__setattr__`, and passes it to `Fitter`. +This bypasses `GuardedBase` parent tracking and is fragile. + +**Fix:** make `Fitter.fit()` accept a list of experiment objects (or a single +experiment) instead of requiring an `Experiments` collection. Or add a +`fit_single(experiment)` method. + +**Depends on:** nothing, but simpler after issue 5 (Analysis refactor) clarifies +the fitting orchestration. + +--- + +## 8. 🟡 Add Explicit `create()` Signatures on Collections + +**Type:** API safety + +`CategoryCollection.create(**kwargs)` accepts arbitrary keyword arguments and +applies them via `setattr`. Typos are silently dropped (GuardedBase logs a +warning but does not raise), so items are created with incorrect defaults. + +**Fix:** concrete collection subclasses (e.g. `AtomSites`, `Background`) should +override `create()` with explicit parameters for IDE autocomplete and typo +detection. The base `create(**kwargs)` remains as an internal implementation +detail. + +**Depends on:** nothing. + +--- + +## 9. 🟢 Add Future Enum Extensions + +**Type:** Design improvement + +The four current experiment axes will be extended with at least two more: + +| New axis | Options | Enum (proposed) | +| ------------------- | ---------------------- | ------------------------ | +| Data dimensionality | 1D, 2D | `DataDimensionalityEnum` | +| Beam polarisation | unpolarised, polarised | `PolarisationEnum` | + +These should follow the same `str, Enum` pattern and integrate into +`Compatibility` (new `FrozenSet` fields), `_default_rules`, and `ExperimentType` +(new `StringDescriptor`s with `MembershipValidator`s). + +**Migration path:** existing `Compatibility` objects that don't specify the new +fields use `frozenset()` (empty = "any"), so all existing classes remain +compatible without changes. + +**Depends on:** nothing. + +--- + +## 10. 🟢 Unify Project-Level Update Orchestration + +**Type:** Maintainability + +`Project._update_categories(expt_name)` hard-codes the update order (structures +→ analysis → one experiment). The `_update_priority` system exists on categories +but is not used across datablocks. The `expt_name` parameter means only one +experiment is updated per call, inconsistent with joint-fit workflows. + +**Fix:** consider a project-level `_update_priority` on datablocks, or at +minimum document the required update order. For joint fitting, all experiments +should be updateable in a single call. + +**Depends on:** benefits from issue 5 (Analysis as DatablockItem) and issue 7 +(fitter refactor). + +--- + +## 11. 🟢 Document Category `_update` Contract + +**Type:** Maintainability + +`_update()` is an optional override with a no-op default. A clearer contract +would help contributors: + +- **Active categories** (those that compute something, e.g. `Background`, + `Data`) should have an explicit `_update()` implementation. +- **Passive categories** (those that only store parameters, e.g. `Cell`, + `SpaceGroup`) keep the no-op default. + +The distinction is already implicit in the code; making it explicit in +documentation (and possibly via a naming convention or flag) would reduce +confusion for new contributors. + +**Depends on:** nothing. + +--- + +## 12. 🟢 Add CIF Round-Trip Integration Test + +**Type:** Quality + +Ensuring every parameter survives a `save()` → `load()` cycle is critical for +reproducibility. A systematic integration test that creates a project, populates +all categories, saves, reloads, and compares all parameter values would +strengthen confidence in the serialisation layer. + +**Depends on:** issue 1 (`Project.load()` implementation). + +--- + +## 13. 🟢 Suppress Redundant Dirty-Flag Sets in Symmetry Constraints + +**Type:** Performance + +Symmetry constraint application (cell metric, atomic coordinates, ADPs) goes +through the public `value` setter for each parameter, setting the dirty flag +repeatedly during what is logically a single batch operation. + +No correctness issue — the dirty-flag guard handles this correctly. The +redundant sets are a minor inefficiency that only matters if profiling shows it +is a bottleneck. + +**Fix:** introduce a private `_set_value_no_notify()` method on +`GenericDescriptorBase` for internal batch operations, or a context manager / +flag on the owning datablock to suppress notifications during a batch. + +**Depends on:** nothing, but low priority. + +--- + +## 14. 🟢 Finer-Grained Parameter Change Tracking + +**Type:** Performance + +The current dirty-flag approach (`_need_categories_update` on `DatablockItem`) +triggers a full update of all categories when any parameter changes. This is +simple and correct. If performance becomes a concern with many categories, a +more granular approach could track which specific categories are dirty. Only +implement when profiling proves it is needed. + +**Depends on:** nothing, but low priority. + +--- + +## 15. 🟡 Validate Joint-Fit Weights Before Residual Normalisation + +**Type:** Correctness + +Joint-fit weights currently allow invalid numeric values such as negatives or an +all-zero set. The residual code then normalises by the total weight and applies +`sqrt(weight)`, which can produce division-by-zero or `nan` residuals. + +**Fix:** require weights to be strictly positive, or at minimum validate that +all weights are non-negative and their total is greater than zero before +normalisation. This should fail with a clear user-facing error instead of +letting invalid floating-point values propagate into the minimiser. + +**Depends on:** related to issue 3, but independent. + +--- + +## 16. 🟡 Persist Per-Experiment `calculator_type` + +**Type:** Completeness + +The current architecture moved calculator selection to the experiment level via +`calculator_type`, but this selection is not written to CIF during `save()` / +`show_as_cif()`. Reloading or exporting a project therefore loses explicit +calculator choices and falls back to auto-resolution. + +**Fix:** serialise `calculator_type` as part of the experiment or analysis +state, and make sure `load()` restores it. The saved project should represent +the exact active calculator configuration, not just a re-derivable default. + +**Depends on:** issue 1 (`Project.load()` implementation). + +--- + +## Summary + +| # | Issue | Severity | Type | +| --- | ------------------------------------------ | -------- | ----------------------- | +| 1 | Implement `Project.load()` | 🔴 High | Completeness | +| 2 | Restore minimiser variants | 🟡 Med | Feature loss | +| 3 | Rebuild joint-fit weights | 🟡 Med | Fragility | +| 4 | Refresh constraint state before auto-apply | 🔴 High | Correctness | +| 5 | `Analysis` as `DatablockItem` | 🟡 Med | Consistency | +| 6 | Restrict `data_type` switching | 🔴 High | Correctness/Data safety | +| 7 | Eliminate dummy `Experiments` | 🟡 Med | Fragility | +| 8 | Explicit `create()` signatures | 🟡 Med | API safety | +| 9 | Future enum extensions | 🟢 Low | Design | +| 10 | Unify update orchestration | 🟢 Low | Maintainability | +| 11 | Document `_update` contract | 🟢 Low | Maintainability | +| 12 | CIF round-trip integration test | 🟢 Low | Quality | +| 13 | Suppress redundant dirty-flag sets | 🟢 Low | Performance | +| 14 | Finer-grained change tracking | 🟢 Low | Performance | +| 15 | Validate joint-fit weights | 🟡 Med | Correctness | +| 16 | Persist per-experiment `calculator_type` | 🟡 Med | Completeness | diff --git a/docs/architecture/package-structure-full.md b/docs/architecture/package-structure-full.md index e24eadab..74662449 100644 --- a/docs/architecture/package-structure-full.md +++ b/docs/architecture/package-structure-full.md @@ -67,37 +67,176 @@ │ │ └── 🏷️ class GuardedBase │ ├── 📄 identity.py │ │ └── 🏷️ class Identity -│ ├── 📄 parameters.py -│ │ ├── 🏷️ class GenericDescriptorBase -│ │ ├── 🏷️ class GenericStringDescriptor -│ │ ├── 🏷️ class GenericNumericDescriptor -│ │ ├── 🏷️ class GenericParameter -│ │ ├── 🏷️ class StringDescriptor -│ │ ├── 🏷️ class NumericDescriptor -│ │ └── 🏷️ class Parameter -│ ├── 📄 singletons.py +│ ├── 📄 metadata.py +│ │ ├── 🏷️ class TypeInfo +│ │ ├── 🏷️ class Compatibility +│ │ └── 🏷️ class CalculatorSupport +│ ├── 📄 singleton.py │ │ ├── 🏷️ class SingletonBase │ │ ├── 🏷️ class UidMapHandler │ │ └── 🏷️ class ConstraintsHandler -│ └── 📄 validation.py -│ ├── 🏷️ class DataTypes -│ ├── 🏷️ class ValidationStage -│ ├── 🏷️ class ValidatorBase -│ ├── 🏷️ class TypeValidator -│ ├── 🏷️ class RangeValidator -│ ├── 🏷️ class MembershipValidator -│ ├── 🏷️ class RegexValidator -│ └── 🏷️ class AttributeSpec +│ ├── 📄 validation.py +│ │ ├── 🏷️ class DataTypeHints +│ │ ├── 🏷️ class DataTypes +│ │ ├── 🏷️ class ValidationStage +│ │ ├── 🏷️ class ValidatorBase +│ │ ├── 🏷️ class TypeValidator +│ │ ├── 🏷️ class RangeValidator +│ │ ├── 🏷️ class MembershipValidator +│ │ ├── 🏷️ class RegexValidator +│ │ └── 🏷️ class AttributeSpec +│ └── 📄 variable.py +│ ├── 🏷️ class GenericDescriptorBase +│ ├── 🏷️ class GenericStringDescriptor +│ ├── 🏷️ class GenericNumericDescriptor +│ ├── 🏷️ class GenericParameter +│ ├── 🏷️ class StringDescriptor +│ ├── 🏷️ class NumericDescriptor +│ └── 🏷️ class Parameter ├── 📁 crystallography │ ├── 📄 __init__.py │ ├── 📄 crystallography.py │ └── 📄 space_groups.py +├── 📁 datablocks +│ ├── 📁 experiment +│ │ ├── 📁 categories +│ │ │ ├── 📁 background +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ │ └── 🏷️ class BackgroundBase +│ │ │ │ ├── 📄 chebyshev.py +│ │ │ │ │ ├── 🏷️ class PolynomialTerm +│ │ │ │ │ └── 🏷️ class ChebyshevPolynomialBackground +│ │ │ │ ├── 📄 enums.py +│ │ │ │ │ └── 🏷️ class BackgroundTypeEnum +│ │ │ │ ├── 📄 factory.py +│ │ │ │ │ └── 🏷️ class BackgroundFactory +│ │ │ │ └── 📄 line_segment.py +│ │ │ │ ├── 🏷️ class LineSegment +│ │ │ │ └── 🏷️ class LineSegmentBackground +│ │ │ ├── 📁 data +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 bragg_pd.py +│ │ │ │ │ ├── 🏷️ class PdDataPointBaseMixin +│ │ │ │ │ ├── 🏷️ class PdCwlDataPointMixin +│ │ │ │ │ ├── 🏷️ class PdTofDataPointMixin +│ │ │ │ │ ├── 🏷️ class PdCwlDataPoint +│ │ │ │ │ ├── 🏷️ class PdTofDataPoint +│ │ │ │ │ ├── 🏷️ class PdDataBase +│ │ │ │ │ ├── 🏷️ class PdCwlData +│ │ │ │ │ └── 🏷️ class PdTofData +│ │ │ │ ├── 📄 bragg_sc.py +│ │ │ │ │ ├── 🏷️ class Refln +│ │ │ │ │ └── 🏷️ class ReflnData +│ │ │ │ ├── 📄 factory.py +│ │ │ │ │ └── 🏷️ class DataFactory +│ │ │ │ └── 📄 total_pd.py +│ │ │ │ ├── 🏷️ class TotalDataPoint +│ │ │ │ ├── 🏷️ class TotalDataBase +│ │ │ │ └── 🏷️ class TotalData +│ │ │ ├── 📁 instrument +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ │ └── 🏷️ class InstrumentBase +│ │ │ │ ├── 📄 cwl.py +│ │ │ │ │ ├── 🏷️ class CwlInstrumentBase +│ │ │ │ │ ├── 🏷️ class CwlScInstrument +│ │ │ │ │ └── 🏷️ class CwlPdInstrument +│ │ │ │ ├── 📄 factory.py +│ │ │ │ │ └── 🏷️ class InstrumentFactory +│ │ │ │ └── 📄 tof.py +│ │ │ │ ├── 🏷️ class TofScInstrument +│ │ │ │ └── 🏷️ class TofPdInstrument +│ │ │ ├── 📁 peak +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ │ └── 🏷️ class PeakBase +│ │ │ │ ├── 📄 cwl.py +│ │ │ │ │ ├── 🏷️ class CwlPseudoVoigt +│ │ │ │ │ ├── 🏷️ class CwlSplitPseudoVoigt +│ │ │ │ │ └── 🏷️ class CwlThompsonCoxHastings +│ │ │ │ ├── 📄 cwl_mixins.py +│ │ │ │ │ ├── 🏷️ class CwlBroadeningMixin +│ │ │ │ │ ├── 🏷️ class EmpiricalAsymmetryMixin +│ │ │ │ │ └── 🏷️ class FcjAsymmetryMixin +│ │ │ │ ├── 📄 factory.py +│ │ │ │ │ └── 🏷️ class PeakFactory +│ │ │ │ ├── 📄 tof.py +│ │ │ │ │ ├── 🏷️ class TofPseudoVoigt +│ │ │ │ │ ├── 🏷️ class TofPseudoVoigtIkedaCarpenter +│ │ │ │ │ └── 🏷️ class TofPseudoVoigtBackToBack +│ │ │ │ ├── 📄 tof_mixins.py +│ │ │ │ │ ├── 🏷️ class TofBroadeningMixin +│ │ │ │ │ └── 🏷️ class IkedaCarpenterAsymmetryMixin +│ │ │ │ ├── 📄 total.py +│ │ │ │ │ └── 🏷️ class TotalGaussianDampedSinc +│ │ │ │ └── 📄 total_mixins.py +│ │ │ │ └── 🏷️ class TotalBroadeningMixin +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 excluded_regions.py +│ │ │ │ ├── 🏷️ class ExcludedRegion +│ │ │ │ └── 🏷️ class ExcludedRegions +│ │ │ ├── 📄 experiment_type.py +│ │ │ │ └── 🏷️ class ExperimentType +│ │ │ ├── 📄 extinction.py +│ │ │ │ └── 🏷️ class Extinction +│ │ │ ├── 📄 linked_crystal.py +│ │ │ │ └── 🏷️ class LinkedCrystal +│ │ │ └── 📄 linked_phases.py +│ │ │ ├── 🏷️ class LinkedPhase +│ │ │ └── 🏷️ class LinkedPhases +│ │ ├── 📁 item +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ │ ├── 🏷️ class ExperimentBase +│ │ │ │ ├── 🏷️ class ScExperimentBase +│ │ │ │ └── 🏷️ class PdExperimentBase +│ │ │ ├── 📄 bragg_pd.py +│ │ │ │ └── 🏷️ class BraggPdExperiment +│ │ │ ├── 📄 bragg_sc.py +│ │ │ │ ├── 🏷️ class CwlScExperiment +│ │ │ │ └── 🏷️ class TofScExperiment +│ │ │ ├── 📄 enums.py +│ │ │ │ ├── 🏷️ class SampleFormEnum +│ │ │ │ ├── 🏷️ class ScatteringTypeEnum +│ │ │ │ ├── 🏷️ class RadiationProbeEnum +│ │ │ │ ├── 🏷️ class BeamModeEnum +│ │ │ │ ├── 🏷️ class CalculatorEnum +│ │ │ │ └── 🏷️ class PeakProfileTypeEnum +│ │ │ ├── 📄 factory.py +│ │ │ │ └── 🏷️ class ExperimentFactory +│ │ │ └── 📄 total_pd.py +│ │ │ └── 🏷️ class TotalPdExperiment +│ │ ├── 📄 __init__.py +│ │ └── 📄 collection.py +│ │ └── 🏷️ class Experiments +│ ├── 📁 structure +│ │ ├── 📁 categories +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 atom_sites.py +│ │ │ │ ├── 🏷️ class AtomSite +│ │ │ │ └── 🏷️ class AtomSites +│ │ │ ├── 📄 cell.py +│ │ │ │ └── 🏷️ class Cell +│ │ │ └── 📄 space_group.py +│ │ │ └── 🏷️ class SpaceGroup +│ │ ├── 📁 item +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ │ └── 🏷️ class Structure +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class StructureFactory +│ │ ├── 📄 __init__.py +│ │ └── 📄 collection.py +│ │ └── 🏷️ class Structures +│ └── 📄 __init__.py ├── 📁 display │ ├── 📁 plotters │ │ ├── 📄 __init__.py │ │ ├── 📄 ascii.py │ │ │ └── 🏷️ class AsciiPlotter │ │ ├── 📄 base.py +│ │ │ ├── 🏷️ class XAxisType │ │ │ └── 🏷️ class PlotterBase │ │ └── 📄 plotly.py │ │ └── 🏷️ class PlotlyPlotter @@ -123,108 +262,6 @@ │ │ └── 🏷️ class TableRendererFactory │ └── 📄 utils.py │ └── 🏷️ class JupyterScrollManager -├── 📁 experiments -│ ├── 📁 categories -│ │ ├── 📁 background -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 base.py -│ │ │ │ └── 🏷️ class BackgroundBase -│ │ │ ├── 📄 chebyshev.py -│ │ │ │ ├── 🏷️ class PolynomialTerm -│ │ │ │ └── 🏷️ class ChebyshevPolynomialBackground -│ │ │ ├── 📄 enums.py -│ │ │ │ └── 🏷️ class BackgroundTypeEnum -│ │ │ ├── 📄 factory.py -│ │ │ │ └── 🏷️ class BackgroundFactory -│ │ │ └── 📄 line_segment.py -│ │ │ ├── 🏷️ class LineSegment -│ │ │ └── 🏷️ class LineSegmentBackground -│ │ ├── 📁 data -│ │ │ ├── 📄 bragg_pd.py -│ │ │ │ ├── 🏷️ class PdDataPointBaseMixin -│ │ │ │ ├── 🏷️ class PdCwlDataPointMixin -│ │ │ │ ├── 🏷️ class PdTofDataPointMixin -│ │ │ │ ├── 🏷️ class PdCwlDataPoint -│ │ │ │ ├── 🏷️ class PdTofDataPoint -│ │ │ │ ├── 🏷️ class PdDataBase -│ │ │ │ ├── 🏷️ class PdCwlData -│ │ │ │ └── 🏷️ class PdTofData -│ │ │ ├── 📄 bragg_sc.py -│ │ │ │ └── 🏷️ class Refln -│ │ │ ├── 📄 factory.py -│ │ │ │ └── 🏷️ class DataFactory -│ │ │ └── 📄 total.py -│ │ │ ├── 🏷️ class TotalDataPoint -│ │ │ ├── 🏷️ class TotalDataBase -│ │ │ └── 🏷️ class TotalData -│ │ ├── 📁 instrument -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 base.py -│ │ │ │ └── 🏷️ class InstrumentBase -│ │ │ ├── 📄 cwl.py -│ │ │ │ └── 🏷️ class CwlInstrument -│ │ │ ├── 📄 factory.py -│ │ │ │ └── 🏷️ class InstrumentFactory -│ │ │ └── 📄 tof.py -│ │ │ └── 🏷️ class TofInstrument -│ │ ├── 📁 peak -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 base.py -│ │ │ │ └── 🏷️ class PeakBase -│ │ │ ├── 📄 cwl.py -│ │ │ │ ├── 🏷️ class CwlPseudoVoigt -│ │ │ │ ├── 🏷️ class CwlSplitPseudoVoigt -│ │ │ │ └── 🏷️ class CwlThompsonCoxHastings -│ │ │ ├── 📄 cwl_mixins.py -│ │ │ │ ├── 🏷️ class CwlBroadeningMixin -│ │ │ │ ├── 🏷️ class EmpiricalAsymmetryMixin -│ │ │ │ └── 🏷️ class FcjAsymmetryMixin -│ │ │ ├── 📄 factory.py -│ │ │ │ └── 🏷️ class PeakFactory -│ │ │ ├── 📄 tof.py -│ │ │ │ ├── 🏷️ class TofPseudoVoigt -│ │ │ │ ├── 🏷️ class TofPseudoVoigtIkedaCarpenter -│ │ │ │ └── 🏷️ class TofPseudoVoigtBackToBack -│ │ │ ├── 📄 tof_mixins.py -│ │ │ │ ├── 🏷️ class TofBroadeningMixin -│ │ │ │ └── 🏷️ class IkedaCarpenterAsymmetryMixin -│ │ │ ├── 📄 total.py -│ │ │ │ └── 🏷️ class TotalGaussianDampedSinc -│ │ │ └── 📄 total_mixins.py -│ │ │ └── 🏷️ class TotalBroadeningMixin -│ │ ├── 📄 __init__.py -│ │ ├── 📄 excluded_regions.py -│ │ │ ├── 🏷️ class ExcludedRegion -│ │ │ └── 🏷️ class ExcludedRegions -│ │ ├── 📄 experiment_type.py -│ │ │ └── 🏷️ class ExperimentType -│ │ └── 📄 linked_phases.py -│ │ ├── 🏷️ class LinkedPhase -│ │ └── 🏷️ class LinkedPhases -│ ├── 📁 experiment -│ │ ├── 📄 __init__.py -│ │ ├── 📄 base.py -│ │ │ ├── 🏷️ class ExperimentBase -│ │ │ └── 🏷️ class PdExperimentBase -│ │ ├── 📄 bragg_pd.py -│ │ │ └── 🏷️ class BraggPdExperiment -│ │ ├── 📄 bragg_sc.py -│ │ │ └── 🏷️ class BraggScExperiment -│ │ ├── 📄 enums.py -│ │ │ ├── 🏷️ class SampleFormEnum -│ │ │ ├── 🏷️ class ScatteringTypeEnum -│ │ │ ├── 🏷️ class RadiationProbeEnum -│ │ │ ├── 🏷️ class BeamModeEnum -│ │ │ └── 🏷️ class PeakProfileTypeEnum -│ │ ├── 📄 factory.py -│ │ │ └── 🏷️ class ExperimentFactory -│ │ ├── 📄 instrument_mixin.py -│ │ │ └── 🏷️ class InstrumentMixin -│ │ └── 📄 total_pd.py -│ │ └── 🏷️ class TotalPdExperiment -│ ├── 📄 __init__.py -│ └── 📄 experiments.py -│ └── 🏷️ class Experiments ├── 📁 io │ ├── 📁 cif │ │ ├── 📄 __init__.py @@ -239,30 +276,17 @@ │ │ └── 🏷️ class Project │ └── 📄 project_info.py │ └── 🏷️ class ProjectInfo -├── 📁 sample_models -│ ├── 📁 categories -│ │ ├── 📄 __init__.py -│ │ ├── 📄 atom_sites.py -│ │ │ ├── 🏷️ class AtomSite -│ │ │ └── 🏷️ class AtomSites -│ │ ├── 📄 cell.py -│ │ │ └── 🏷️ class Cell -│ │ └── 📄 space_group.py -│ │ └── 🏷️ class SpaceGroup -│ ├── 📁 sample_model -│ │ ├── 📄 __init__.py -│ │ ├── 📄 base.py -│ │ │ └── 🏷️ class SampleModelBase -│ │ └── 📄 factory.py -│ │ └── 🏷️ class SampleModelFactory -│ ├── 📄 __init__.py -│ └── 📄 sample_models.py -│ └── 🏷️ class SampleModels ├── 📁 summary │ ├── 📄 __init__.py │ └── 📄 summary.py │ └── 🏷️ class Summary ├── 📁 utils +│ ├── 📁 _vendored +│ │ ├── 📁 jupyter_dark_detect +│ │ │ ├── 📄 __init__.py +│ │ │ └── 📄 detector.py +│ │ ├── 📄 __init__.py +│ │ └── 📄 theme_detect.py │ ├── 📄 __init__.py │ ├── 📄 environment.py │ ├── 📄 logging.py diff --git a/docs/architecture/package-structure-short.md b/docs/architecture/package-structure-short.md index 7f9d5af0..efe89066 100644 --- a/docs/architecture/package-structure-short.md +++ b/docs/architecture/package-structure-short.md @@ -38,13 +38,75 @@ │ ├── 📄 factory.py │ ├── 📄 guard.py │ ├── 📄 identity.py -│ ├── 📄 parameters.py -│ ├── 📄 singletons.py -│ └── 📄 validation.py +│ ├── 📄 metadata.py +│ ├── 📄 singleton.py +│ ├── 📄 validation.py +│ └── 📄 variable.py ├── 📁 crystallography │ ├── 📄 __init__.py │ ├── 📄 crystallography.py │ └── 📄 space_groups.py +├── 📁 datablocks +│ ├── 📁 experiment +│ │ ├── 📁 categories +│ │ │ ├── 📁 background +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ ├── 📄 chebyshev.py +│ │ │ │ ├── 📄 enums.py +│ │ │ │ ├── 📄 factory.py +│ │ │ │ └── 📄 line_segment.py +│ │ │ ├── 📁 data +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 bragg_pd.py +│ │ │ │ ├── 📄 bragg_sc.py +│ │ │ │ ├── 📄 factory.py +│ │ │ │ └── 📄 total_pd.py +│ │ │ ├── 📁 instrument +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ ├── 📄 cwl.py +│ │ │ │ ├── 📄 factory.py +│ │ │ │ └── 📄 tof.py +│ │ │ ├── 📁 peak +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ ├── 📄 cwl.py +│ │ │ │ ├── 📄 cwl_mixins.py +│ │ │ │ ├── 📄 factory.py +│ │ │ │ ├── 📄 tof.py +│ │ │ │ ├── 📄 tof_mixins.py +│ │ │ │ ├── 📄 total.py +│ │ │ │ └── 📄 total_mixins.py +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 excluded_regions.py +│ │ │ ├── 📄 experiment_type.py +│ │ │ ├── 📄 extinction.py +│ │ │ ├── 📄 linked_crystal.py +│ │ │ └── 📄 linked_phases.py +│ │ ├── 📁 item +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ ├── 📄 bragg_pd.py +│ │ │ ├── 📄 bragg_sc.py +│ │ │ ├── 📄 enums.py +│ │ │ ├── 📄 factory.py +│ │ │ └── 📄 total_pd.py +│ │ ├── 📄 __init__.py +│ │ └── 📄 collection.py +│ ├── 📁 structure +│ │ ├── 📁 categories +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 atom_sites.py +│ │ │ ├── 📄 cell.py +│ │ │ └── 📄 space_group.py +│ │ ├── 📁 item +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ └── 📄 factory.py +│ │ ├── 📄 __init__.py +│ │ └── 📄 collection.py +│ └── 📄 __init__.py ├── 📁 display │ ├── 📁 plotters │ │ ├── 📄 __init__.py @@ -61,51 +123,6 @@ │ ├── 📄 plotting.py │ ├── 📄 tables.py │ └── 📄 utils.py -├── 📁 experiments -│ ├── 📁 categories -│ │ ├── 📁 background -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 base.py -│ │ │ ├── 📄 chebyshev.py -│ │ │ ├── 📄 enums.py -│ │ │ ├── 📄 factory.py -│ │ │ └── 📄 line_segment.py -│ │ ├── 📁 data -│ │ │ ├── 📄 bragg_pd.py -│ │ │ ├── 📄 bragg_sc.py -│ │ │ ├── 📄 factory.py -│ │ │ └── 📄 total.py -│ │ ├── 📁 instrument -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 base.py -│ │ │ ├── 📄 cwl.py -│ │ │ ├── 📄 factory.py -│ │ │ └── 📄 tof.py -│ │ ├── 📁 peak -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 base.py -│ │ │ ├── 📄 cwl.py -│ │ │ ├── 📄 cwl_mixins.py -│ │ │ ├── 📄 factory.py -│ │ │ ├── 📄 tof.py -│ │ │ ├── 📄 tof_mixins.py -│ │ │ ├── 📄 total.py -│ │ │ └── 📄 total_mixins.py -│ │ ├── 📄 __init__.py -│ │ ├── 📄 excluded_regions.py -│ │ ├── 📄 experiment_type.py -│ │ └── 📄 linked_phases.py -│ ├── 📁 experiment -│ │ ├── 📄 __init__.py -│ │ ├── 📄 base.py -│ │ ├── 📄 bragg_pd.py -│ │ ├── 📄 bragg_sc.py -│ │ ├── 📄 enums.py -│ │ ├── 📄 factory.py -│ │ ├── 📄 instrument_mixin.py -│ │ └── 📄 total_pd.py -│ ├── 📄 __init__.py -│ └── 📄 experiments.py ├── 📁 io │ ├── 📁 cif │ │ ├── 📄 __init__.py @@ -117,22 +134,16 @@ │ ├── 📄 __init__.py │ ├── 📄 project.py │ └── 📄 project_info.py -├── 📁 sample_models -│ ├── 📁 categories -│ │ ├── 📄 __init__.py -│ │ ├── 📄 atom_sites.py -│ │ ├── 📄 cell.py -│ │ └── 📄 space_group.py -│ ├── 📁 sample_model -│ │ ├── 📄 __init__.py -│ │ ├── 📄 base.py -│ │ └── 📄 factory.py -│ ├── 📄 __init__.py -│ └── 📄 sample_models.py ├── 📁 summary │ ├── 📄 __init__.py │ └── 📄 summary.py ├── 📁 utils +│ ├── 📁 _vendored +│ │ ├── 📁 jupyter_dark_detect +│ │ │ ├── 📄 __init__.py +│ │ │ └── 📄 detector.py +│ │ ├── 📄 __init__.py +│ │ └── 📄 theme_detect.py │ ├── 📄 __init__.py │ ├── 📄 environment.py │ ├── 📄 logging.py diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index cf1795b4..467bfec8 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -11,7 +11,7 @@ repo_url: https://github.com/easyscience/diffraction-lib/ edit_uri: edit/develop/docs/ # Copyright -copyright: © 2025 EasyDiffraction +copyright: © 2026 EasyDiffraction # Extra icons in the bottom right corner extra: @@ -62,7 +62,7 @@ nav: - Analysis Workflow: - Analysis Workflow: user-guide/analysis-workflow/index.md - Project: user-guide/analysis-workflow/project.md - - Sample Model: user-guide/analysis-workflow/model.md + - Structure: user-guide/analysis-workflow/model.md - Experiment: user-guide/analysis-workflow/experiment.md - Analysis: user-guide/analysis-workflow/analysis.md - Summary: user-guide/analysis-workflow/summary.md @@ -71,8 +71,7 @@ nav: - Getting Started: - LBCO quick CIF: tutorials/ed-1.ipynb - LBCO quick code: tutorials/ed-2.ipynb - - LBCO basic: tutorials/ed-3.ipynb - - PbSO4 advanced: tutorials/ed-4.ipynb + - LBCO complete: tutorials/ed-3.ipynb - Powder Diffraction: - Co2SiO4 pd-neut-cwl: tutorials/ed-5.ipynb - HS pd-neut-cwl: tutorials/ed-6.ipynb @@ -86,17 +85,23 @@ nav: - Ni pd-neut-cwl: tutorials/ed-10.ipynb - Si pd-neut-tof: tutorials/ed-11.ipynb - NaCl pd-xray: tutorials/ed-12.ipynb + - Multiple Data Blocks: + - PbSO4 NPD+XRD: tutorials/ed-4.ipynb + - LBCO+Si McStas: tutorials/ed-9.ipynb + - Si Bragg+PDF: tutorials/ed-16.ipynb - Workshops & Schools: - - 2025 DMSC: tutorials/ed-13.ipynb + - DMSC Summer School: tutorials/ed-13.ipynb - API Reference: - API Reference: api-reference/index.md - analysis: api-reference/analysis.md - core: api-reference/core.md - crystallography: api-reference/crystallography.md + - datablocks: + - experiment: api-reference/datablocks/experiment.md + - structure: api-reference/datablocks/structure.md - display: api-reference/display.md - - experiments: api-reference/experiments.md + - experiments: api-reference/experiment.md - io: api-reference/io.md - project: api-reference/project.md - - sample_models: api-reference/sample_models.md - summary: api-reference/summary.md - utils: api-reference/utils.md diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md index 154fd5c6..a7c2cc37 100644 --- a/docs/tutorials/index.md +++ b/docs/tutorials/index.md @@ -20,27 +20,22 @@ The tutorials are organized into the following categories. - [LBCO `quick` CIF](ed-1.ipynb) – A minimal example intended as a quick reference for users already familiar with the EasyDiffraction API or who want to see how Rietveld refinement of the La0.5Ba0.5CoO3 crystal structure can be - performed when both the sample model and experiment are loaded from CIF files. + performed when both the structure and experiment are loaded from CIF files. Data collected from constant wavelength neutron powder diffraction at HRPT at PSI. - [LBCO `quick` `code`](ed-2.ipynb) – A minimal example intended as a quick reference for users already familiar with the EasyDiffraction API or who want - to see an example refinement when both the sample model and experiment are + to see an example refinement when both the structure and experiment are defined directly in code. This tutorial covers a Rietveld refinement of the La0.5Ba0.5CoO3 crystal structure using constant wavelength neutron powder diffraction data from HRPT at PSI. -- [LBCO `basic`](ed-3.ipynb) – Demonstrates the use of the EasyDiffraction API - in a simplified, user-friendly manner that closely follows the GUI workflow - for a Rietveld refinement of the La0.5Ba0.5CoO3 crystal structure using - constant wavelength neutron powder diffraction data from HRPT at PSI. This - tutorial provides a full explanation of the workflow with detailed comments - and descriptions of every step, making it suitable for users who are new to - EasyDiffraction or those who prefer a more guided approach. -- [PbSO4 `advanced`](ed-4.ipynb) – Demonstrates a more flexible and advanced - approach to using the EasyDiffraction library, intended for users who are more - comfortable with Python programming. This tutorial covers a Rietveld - refinement of the PbSO4 crystal structure based on the joint fit of both X-ray - and neutron diffraction data. +- [LBCO `complete`](ed-3.ipynb) – Demonstrates the use of the EasyDiffraction + API in a simplified, user-friendly manner that closely follows the GUI + workflow for a Rietveld refinement of the La0.5Ba0.5CoO3 crystal structure + using constant wavelength neutron powder diffraction data from HRPT at PSI. + This tutorial provides a full explanation of the workflow with detailed + comments and descriptions of every step, making it suitable for users who are + new to EasyDiffraction or those who prefer a more guided approach. ## Powder Diffraction @@ -56,10 +51,6 @@ The tutorials are organized into the following categories. - [NCAF `pd-neut-tof`](ed-8.ipynb) – Demonstrates a Rietveld refinement of the Na2Ca3Al2F14 crystal structure using two time-of-flight neutron powder diffraction datasets (from two detector banks) of the WISH instrument at ISIS. -- [LBCO+Si McStas](ed-9.ipynb) – Demonstrates a Rietveld refinement of the - La0.5Ba0.5CoO3 crystal structure with a small amount of Si impurity as a - secondary phase using time-of-flight neutron powder diffraction data simulated - with McStas. ## Single Crystal Diffraction @@ -80,9 +71,20 @@ The tutorials are organized into the following categories. - [NaCl `pd-xray`](ed-12.ipynb) – Demonstrates a PDF analysis of NaCl using data collected from an X-ray powder diffraction experiment. +## Multi-Structure & Multi-Experiment Refinement + +- [PbSO4 NPD+XRD](ed-4.ipynb) – Joint fit of PbSO4 using X-ray and neutron + constant wavelength powder diffraction data. +- [LBCO+Si McStas](ed-9.ipynb) – Multi-phase Rietveld refinement of + La0.5Ba0.5CoO3 with Si impurity using time-of-flight neutron data simulated + with McStas. +- [Si Bragg+PDF](ed-16.ipynb) – Joint refinement of Si combining Bragg + diffraction (SEPD) and pair distribution function (NOMAD) analysis. A single + shared structure is refined simultaneously against both datasets. + ## Workshops & Schools -- [2025 DMSC](ed-13.ipynb) – A workshop tutorial that demonstrates a Rietveld - refinement of the La0.5Ba0.5CoO3 crystal structure using time-of-flight - neutron powder diffraction data simulated with McStas. This tutorial is - designed for the ESS DMSC Summer School 2025. +- [DMSC Summer School](ed-13.ipynb) – A workshop tutorial that demonstrates a + Rietveld refinement of the La0.5Ba0.5CoO3 crystal structure using + time-of-flight neutron powder diffraction data simulated with McStas. This + tutorial is designed for the ESS DMSC Summer School. diff --git a/docs/user-guide/analysis-workflow/analysis.md b/docs/user-guide/analysis-workflow/analysis.md index d22d19f1..73086b3b 100644 --- a/docs/user-guide/analysis-workflow/analysis.md +++ b/docs/user-guide/analysis-workflow/analysis.md @@ -20,7 +20,7 @@ EasyDiffraction relies on third-party crystallographic libraries, referred to as **calculation engines** or just **calculators**, to perform the calculations. The calculation engines are used to calculate the diffraction pattern for the -defined model of the studied sample using the instrumental and other required +defined model of the studied structure using the instrumental and other required experiment-related parameters, such as the wavelength, resolution, etc. You do not necessarily need the measured data to perform the calculations, but @@ -53,25 +53,26 @@ calculating the pair distribution function (PDF) from crystallographic models. ### Set Calculator -To show the supported calculation engines: +The calculator is automatically selected based on the experiment type (e.g., +`cryspy` for Bragg diffraction, `pdffit` for total scattering). To show the +supported calculation engines for a specific experiment: ```python -project.analysis.show_supported_calculators() +project.experiments['hrpt'].show_supported_calculator_types() ``` The example of the output is: -Supported calculators +Supported calculator types -| Calculator | Description | -| ---------- | ----------------------------------------------------------- | -| cryspy | CrysPy library for crystallographic calculations | -| pdffit | PDFfit2 library for pair distribution function calculations | +| Calculator | Description | +| ---------- | ------------------------------------------------ | +| cryspy | CrysPy library for crystallographic calculations | -To select the desired calculation engine, e.g., 'cryspy': +To explicitly select a calculation engine for an experiment: ```python -project.analysis.current_calculator = 'cryspy' +project.experiments['hrpt'].calculator_type = 'cryspy' ``` ## Minimization / Optimization @@ -136,13 +137,13 @@ Supported minimizers | --------------------- | ------------------------------------------------------------------------ | | lmfit | LMFIT library using the default Levenberg-Marquardt least squares method | | lmfit (leastsq) | LMFIT library with Levenberg-Marquardt least squares method | -| lmfit (least_squares) | LMFIT library with SciPy’s trust region reflective algorithm | +| lmfit (least_squares) | LMFIT library with SciPy's trust region reflective algorithm | | dfols | DFO-LS library for derivative-free least-squares optimization | -To select the desired calculation engine, e.g., 'lmfit (least_squares)': +To select the desired minimizer, e.g., 'lmfit': ```python -project.analysis.current_minimizer = 'lmfit (leastsq)' +project.analysis.current_minimizer = 'lmfit' ``` ### Fit Mode @@ -151,37 +152,28 @@ In EasyDiffraction, you can set the **fit mode** to control how the refinement process is performed. The fit mode determines whether the refinement is performed independently for each experiment or jointly across all experiments. -To show the supported fit modes: +The supported fit modes are: -```python -project.analysis.show_supported_fit_modes() -``` - -An example of supported fit modes is: - -Supported fit modes - -| Strategy | Description | -| -------- | ------------------------------------------------------------------- | -| single | Independent fitting of each experiment; no shared parameters | -| joint | Simultaneous fitting of all experiments; some parameters are shared | +| Mode | Description | +| ------ | ------------------------------------------------------------------- | +| single | Independent fitting of each experiment; no shared parameters | +| joint | Simultaneous fitting of all experiments; some parameters are shared | -You can set the fit mode using the `set_fit_mode` method of the `analysis` -object: +You can set the fit mode on the `analysis` object: ```python -project.analysis.fit_mode = 'joint' +project.analysis.fit_mode.mode = 'joint' ``` -To check the current fit mode, you can use the `show_current_fit_mode` method: +To check the current fit mode: ```python -project.analysis.show_current_fit_mode() +print(project.analysis.fit_mode.mode.value) ``` ### Perform Fit -Refining the sample model and experiment parameters against measured data is +Refining the structure and experiment parameters against measured data is usually divided into several steps, where each step involves adding or removing parameters to be refined, calculating the model data, and comparing it to the experimental data as shown in the diagram above. @@ -193,8 +185,8 @@ during the refinement process. Here is an example of how to set parameters to be refined: ```python -# Set sample model parameters to be refined. -project.sample_models['lbco'].cell.length_a.free = True +# Set structure parameters to be refined. +project.structures['lbco'].cell.length_a.free = True # Set experiment parameters to be refined. project.experiments['hrpt'].linked_phases['lbco'].scale.free = True @@ -265,27 +257,27 @@ to constrain. This can be done using the `add` method of the `aliases` object. Aliases are used to reference parameters in a more readable way, making it easier to manage constraints. -An example of setting aliases for parameters in a sample model: +An example of setting aliases for parameters in a structure: ```python # Set aliases for the atomic displacement parameters -project.analysis.aliases.add( +project.analysis.aliases.create( label='biso_La', - param_uid=project.sample_models['lbco'].atom_sites['La'].b_iso.uid, + param_uid=project.structures['lbco'].atom_sites['La'].b_iso.uid, ) -project.analysis.aliases.add( +project.analysis.aliases.create( label='biso_Ba', - param_uid=project.sample_models['lbco'].atom_sites['Ba'].b_iso.uid, + param_uid=project.structures['lbco'].atom_sites['Ba'].b_iso.uid, ) # Set aliases for the occupancies of the atom sites -project.analysis.aliases.add( +project.analysis.aliases.create( label='occ_La', - param_uid=project.sample_models['lbco'].atom_sites['La'].occupancy.uid, + param_uid=project.structures['lbco'].atom_sites['La'].occupancy.uid, ) -project.analysis.aliases.add( +project.analysis.aliases.create( label='occ_Ba', - param_uid=project.sample_models['lbco'].atom_sites['Ba'].occupancy.uid, + param_uid=project.structures['lbco'].atom_sites['Ba'].occupancy.uid, ) ``` @@ -300,12 +292,12 @@ other aliases. An example of setting constraints for the aliases defined above: ```python -project.analysis.constraints.add( +project.analysis.constraints.create( lhs_alias='biso_Ba', rhs_expr='biso_La', ) -project.analysis.constraints.add( +project.analysis.constraints.create( lhs_alias='occ_Ba', rhs_expr='1 - occ_La', ) @@ -339,8 +331,8 @@ User defined constraints To inspect an analysis configuration in CIF format, use: ```python -# Show sample model as CIF -project.sample_models['lbco'].show_as_cif() +# Show structure as CIF +project.structures['lbco'].show_as_cif() ``` Example output: diff --git a/docs/user-guide/analysis-workflow/experiment.md b/docs/user-guide/analysis-workflow/experiment.md index da08cd43..a62d9822 100644 --- a/docs/user-guide/analysis-workflow/experiment.md +++ b/docs/user-guide/analysis-workflow/experiment.md @@ -23,7 +23,7 @@ EasyDiffraction allows you to: Below, you will find instructions on how to define and manage experiments in EasyDiffraction. It is assumed that you have already created a `project` object, as described in the [Project](project.md) section as well as defined its -`sample_models`, as described in the [Sample Model](model.md) section. +`structures`, as described in the [Structure](model.md) section. ### Adding from CIF @@ -126,12 +126,12 @@ project.experiments.add_from_data_path( ``` If you do not have measured data for fitting and only want to view the simulated -pattern, you can define an experiment without measured data using the -`add_without_data` method: +pattern, you can define an experiment without measured data using the `create` +method: ```python # Add an experiment without measured data -project.experiments.add_without_data( +project.experiments.create( name='hrpt', sample_form='powder', beam_mode='constant wavelength', @@ -144,10 +144,11 @@ directly using the `add` method: ```python # Add an experiment by passing the experiment object directly -from easydiffraction import Experiment +from easydiffraction import ExperimentFactory -experiment = Experiment( +experiment = ExperimentFactory.create( name='hrpt', + data_path='data/hrpt_lbco.xye', sample_form='powder', beam_mode='constant wavelength', radiation_probe='neutron', @@ -169,9 +170,9 @@ understand the different aspects of the experiment: as broadening and asymmetry. 3. **Background Category**: Defines the background type and allows you to add background points. -4. **Linked Phases Category**: Links the sample model defined in the previous - step to the experiment, allowing you to specify the scale factor for the - linked phase. +4. **Linked Phases Category**: Links the structure defined in the previous step + to the experiment, allowing you to specify the scale factor for the linked + phase. 5. **Measured Data Category**: Contains the measured data. The expected format depends on the experiment type, but generally includes columns for 2θ angle or TOF and intensity. @@ -188,8 +189,8 @@ project.experiments['hrpt'].instrument.calib_twotheta_offset = 0.6 ```python # Add excluded regions to the experiment -project.experiments['hrpt'].excluded_regions.add(start=0, end=10) -project.experiments['hrpt'].excluded_regions.add(start=160, end=180) +project.experiments['hrpt'].excluded_regions.create(start=0, end=10) +project.experiments['hrpt'].excluded_regions.create(start=160, end=180) ``` ### 3. Peak Category { #peak-category } @@ -213,18 +214,18 @@ project.experiments['hrpt'].peak.broad_lorentz_y = 0.1 project.experiments['hrpt'].background_type = 'line-segment' # Add background points -project.experiments['hrpt'].background.add(x=10, y=170) -project.experiments['hrpt'].background.add(x=30, y=170) -project.experiments['hrpt'].background.add(x=50, y=170) -project.experiments['hrpt'].background.add(x=110, y=170) -project.experiments['hrpt'].background.add(x=165, y=170) +project.experiments['hrpt'].background.create(x=10, y=170) +project.experiments['hrpt'].background.create(x=30, y=170) +project.experiments['hrpt'].background.create(x=50, y=170) +project.experiments['hrpt'].background.create(x=110, y=170) +project.experiments['hrpt'].background.create(x=165, y=170) ``` ### 5. Linked Phases Category { #linked-phases-category } ```python -# Link the sample model defined in the previous step to the experiment -project.experiments['hrpt'].linked_phases.add(id='lbco', scale=10.0) +# Link the structure defined in the previous step to the experiment +project.experiments['hrpt'].linked_phases.create(id='lbco', scale=10.0) ``` ### 6. Measured Data Category { #measured-data-category } diff --git a/docs/user-guide/analysis-workflow/index.md b/docs/user-guide/analysis-workflow/index.md index 1ce3ec66..51c1f623 100644 --- a/docs/user-guide/analysis-workflow/index.md +++ b/docs/user-guide/analysis-workflow/index.md @@ -17,10 +17,10 @@ flowchart LR ``` - [:material-archive: Project](project.md) – Establish a **project** as a - container for sample model and experiment parameters, measured and calculated + container for structure and experiment parameters, measured and calculated data, analysis settings and results. -- [:material-puzzle: Sample Model](model.md) – Load an existing - **crystallographic model** in CIF format or define a new one from scratch. +- [:material-puzzle: Structure](model.md) – Load an existing **crystallographic + model** in CIF format or define a new one from scratch. - [:material-microscope: Experiment](experiment.md) – Import **experimental diffraction data** and configure **instrumental** and other relevant parameters. diff --git a/docs/user-guide/analysis-workflow/model.md b/docs/user-guide/analysis-workflow/model.md index 9cdb4c9c..c437ea62 100644 --- a/docs/user-guide/analysis-workflow/model.md +++ b/docs/user-guide/analysis-workflow/model.md @@ -2,17 +2,16 @@ icon: material/puzzle --- -# :material-puzzle: Sample Model +# :material-puzzle: Structure -The **Sample Model** in EasyDiffraction represents the **crystallographic +The **Structure** in EasyDiffraction represents the **crystallographic structure** used to calculate the diffraction pattern, which is then fitted to the **experimentally measured data** to refine the structural parameters. EasyDiffraction allows you to: - **Load an existing model** from a file (**CIF** format). -- **Manually define** a new sample model by specifying crystallographic - parameters. +- **Manually define** a new structure by specifying crystallographic parameters. Below, you will find instructions on how to define and manage crystallographic models in EasyDiffraction. It is assumed that you have already created a @@ -20,20 +19,20 @@ models in EasyDiffraction. It is assumed that you have already created a ## Adding a Model from CIF -This is the most straightforward way to define a sample model in -EasyDiffraction. If you have a crystallographic information file (CIF) for your -sample model, you can add it to your project using the `add_phase_from_file` -method of the `project` instance. In this case, the name of the model will be +This is the most straightforward way to define a structure in EasyDiffraction. +If you have a crystallographic information file (CIF) for your structure, you +can add it to your project using the `add_from_cif_path` method of the +`project.structures` collection. In this case, the name of the model will be taken from CIF. ```python # Load a phase from a CIF file -project.add_phase_from_file('data/lbco.cif') +project.structures.add_from_cif_path('data/lbco.cif') ``` -Accessing the model after loading it will be done through the `sample_models` -object of the `project` instance. The name of the model will be the same as the -data block id in the CIF file. For example, if the CIF file contains a data +Accessing the model after loading it will be done through the `structures` +collection of the `project` instance. The name of the model will be the same as +the data block id in the CIF file. For example, if the CIF file contains a data block with the id `lbco`, @@ -52,27 +51,27 @@ data_lbco you can access it in the code as follows: ```python -# Access the sample model by its name -project.sample_models['lbco'] +# Access the structure by its name +project.structures['lbco'] ``` ## Defining a Model Manually If you do not have a CIF file or prefer to define the model manually, you can -use the `add` method of the `sample_models` object of the `project` instance. In +use the `create` method of the `structures` object of the `project` instance. In this case, you will need to specify the name of the model, which will be used to reference it later. ```python -# Add a sample model with default parameters -# The sample model name is used to reference it later. -project.sample_models.add(name='nacl') +# Add a structure with default parameters +# The structure name is used to reference it later. +project.structures.create(name='nacl') ``` -The `add` method creates a new sample model with default parameters. You can -then modify its parameters to match your specific crystallographic structure. -All parameters are grouped into the following categories, which makes it easier -to manage the model: +The `add` method creates a new structure with default parameters. You can then +modify its parameters to match your specific crystallographic structure. All +parameters are grouped into the following categories, which makes it easier to +manage the model: 1. **Space Group Category**: Defines the symmetry of the crystal structure. 2. **Cell Category**: Specifies the dimensions and angles of the unit cell. @@ -83,21 +82,21 @@ to manage the model: ```python # Set space group -project.sample_models['nacl'].space_group.name_h_m = 'F m -3 m' +project.structures['nacl'].space_group.name_h_m = 'F m -3 m' ``` ### 2. Cell Category { #cell-category } ```python # Define unit cell parameters -project.sample_models['nacl'].cell.length_a = 5.691694 +project.structures['nacl'].cell.length_a = 5.691694 ``` ### 3. Atom Sites Category { #atom-sites-category } ```python # Add atomic sites -project.sample_models['nacl'].atom_sites.append( +project.structures['nacl'].atom_sites.create( label='Na', type_symbol='Na', fract_x=0, @@ -106,7 +105,7 @@ project.sample_models['nacl'].atom_sites.append( occupancy=1, b_iso_or_equiv=0.5, ) -project.sample_models['nacl'].atom_sites.append( +project.structures['nacl'].atom_sites.create( label='Cl', type_symbol='Cl', fract_x=0, @@ -119,33 +118,33 @@ project.sample_models['nacl'].atom_sites.append( ## Listing Defined Models -To check which sample models have been added to the `project`, use: +To check which structures have been added to the `project`, use: ```python -# Show defined sample models -project.sample_models.show_names() +# Show defined structures +project.structures.show_names() ``` Expected output: ``` -Defined sample models 🧩 +Defined structures 🧩 ['lbco', 'nacl'] ``` ## Viewing a Model as CIF -To inspect a sample model in CIF format, use: +To inspect a structure in CIF format, use: ```python -# Show sample model as CIF -project.sample_models['lbco'].show_as_cif() +# Show structure as CIF +project.structures['lbco'].show_as_cif() ``` Example output: ``` -Sample model 🧩 'lbco' as cif +Structure 🧩 'lbco' as cif ╒═══════════════════════════════════════════╕ │ data_lbco │ │ │ @@ -179,9 +178,9 @@ Sample model 🧩 'lbco' as cif ## Saving a Model Saving the project, as described in the [Project](project.md) section, will also -save the model. Each model is saved as a separate CIF file in the -`sample_models` subdirectory of the project directory. The project file contains -references to these files. +save the model. Each model is saved as a separate CIF file in the `structures` +subdirectory of the project directory. The project file contains references to +these files. Below is an example of the saved CIF file for the `lbco` model: diff --git a/docs/user-guide/analysis-workflow/project.md b/docs/user-guide/analysis-workflow/project.md index 542e9dad..45e1d9c5 100644 --- a/docs/user-guide/analysis-workflow/project.md +++ b/docs/user-guide/analysis-workflow/project.md @@ -8,7 +8,7 @@ The **Project** serves as a container for all data and metadata associated with a particular data analysis task. It acts as the top-level entity in EasyDiffraction, ensuring structured organization and easy access to relevant information. Each project can contain multiple **experimental datasets**, with -each dataset containing contribution from multiple **sample models**. +each dataset containing contribution from multiple **structures**. EasyDiffraction allows you to: @@ -73,7 +73,7 @@ The example below illustrates a typical **project structure** for a
 📁 La0.5Ba0.5CoO3     - Project directory.
 ├── 📄 project.cif    - Main project description file.
-├── 📁 sample_models  - Folder with sample models (crystallographic structures).
+├── 📁 structures  - Folder with structures (crystallographic structures).
 │   ├── 📄 lbco.cif   - File with La0.5Ba0.5CoO3 structure parameters.
 │   └── ...
 ├── 📁 experiments    - Folder with instrumental parameters and measured data.
@@ -96,13 +96,13 @@ showing the contents of all files in the project.
 
     If you save the project right after creating it, the project directory will
     only contain the `project.cif` file. The other folders and files will be
-    created as you add sample models, experiments, and set up the analysis. The
+    created as you add structures, experiments, and set up the analysis. The
     summary folder will be created after the analysis is completed.
 
 ### 1. project.cif
 
 This file provides an overview of the project, including file names of the
-**sample models** and **experiments** associated with the project.
+**structures** and **experiments** associated with the project.
 
 
 
@@ -114,7 +114,7 @@ data_La0.5Ba0.5CoO3
 _project.description "neutrons, powder, constant wavelength, HRPT@PSI"
 
 loop_
-_sample_model.cif_file_name
+_structure.cif_file_name
 lbco.cif
 
 loop_
@@ -125,9 +125,9 @@ hrpt.cif
 
 
 
-### 2. sample_models / lbco.cif
+### 2. structures / lbco.cif
 
-This file contains crystallographic information associated with the sample
+This file contains crystallographic information associated with the structure
 model, including **space group**, **unit cell parameters**, and **atomic
 positions**.
 
@@ -271,4 +271,4 @@ occ_Ba   "1 - occ_La"
 ---
 
 Now that the Project has been defined, you can proceed to the next step:
-[Sample Model](model.md).
+[Structure](model.md).
diff --git a/docs/user-guide/concept.md b/docs/user-guide/concept.md
index 2ad7e077..fb3f4687 100644
--- a/docs/user-guide/concept.md
+++ b/docs/user-guide/concept.md
@@ -58,8 +58,8 @@ Credits: DOI 10.1126/science.1238932
 ## Data Analysis
 
 Data analysis uses the reduced data to extract meaningful information about the
-sample. This may include determining the crystal or magnetic structure,
-identifying phases, performing quantitative analysis, etc.
+crystallographic structure. This may include determining the crystal or magnetic
+structure, identifying phases, performing quantitative analysis, etc.
 
 Analysis often involves comparing experimental data with data calculated from a
 crystallographic model to validate and interpret the results. For powder
@@ -73,7 +73,7 @@ By "model", we usually refer to a **crystallographic model** of the sample. This
 includes unit cell parameters, space group, atomic positions, thermal
 parameters, and more. However, the term "model" also encompasses experimental
 aspects such as instrumental resolution, background, peak shape, etc. Therefore,
-EasyDiffraction separates the model into two parts: the **sample model** and the
+EasyDiffraction separates the model into two parts: the **structure** and the
 **experiment**.
 
 The aim of data analysis is to refine the structural parameters of the sample by
diff --git a/docs/user-guide/data-format.md b/docs/user-guide/data-format.md
index acbe6bfe..63e2e52e 100644
--- a/docs/user-guide/data-format.md
+++ b/docs/user-guide/data-format.md
@@ -173,8 +173,8 @@ human-readable crystallographic data.
 
 ## Experiment Definition
 
-The previous example described the **sample model** (crystallographic model),
-but how is the **experiment** itself represented?
+The previous example described the **structure** (crystallographic model), but
+how is the **experiment** itself represented?
 
 The experiment is also saved as a CIF file. For example, background intensity in
 a powder diffraction experiment might be represented as:
@@ -206,7 +206,7 @@ EasyDiffraction uses CIF consistently throughout its workflow, including in the
 following blocks:
 
 - **project**: contains the project information
-- **sample model**: defines the sample model
+- **structure**: defines the structure
 - **experiment**: contains the experiment setup and measured data
 - **analysis**: stores fitting and analysis parameters
 - **summary**: captures analysis results
diff --git a/docs/user-guide/first-steps.md b/docs/user-guide/first-steps.md
index d5a832d6..acb50bec 100644
--- a/docs/user-guide/first-steps.md
+++ b/docs/user-guide/first-steps.md
@@ -37,12 +37,12 @@ A complete tutorial using the `import` syntax can be found
 ### Importing specific parts
 
 Alternatively, you can import specific classes or methods from the package. For
-example, you can import the `Project`, `SampleModel`, `Experiment` classes and
+example, you can import the `Project`, `Structure`, `Experiment` classes and
 `download_from_repository` method like this:
 
 ```python
 from easydiffraction import Project
-from easydiffraction import SampleModel
+from easydiffraction import Structure
 from easydiffraction import Experiment
 from easydiffraction import download_from_repository
 ```
@@ -66,7 +66,7 @@ workflow. One of them is the `download_from_repository` function, which allows
 you to download data files from our remote repository, making it easy to access
 and use them while experimenting with EasyDiffraction.
 
-For example, you can download a sample data file like this:
+For example, you can download a data file like this:
 
 ```python
 import easydiffraction as ed
@@ -91,22 +91,22 @@ calculation, minimization, and plotting. These methods can be called on the
 
 ### Supported calculators
 
-For example, you can use the `show_supported_calculators()` method to see which
-calculation engines are available for use in your project:
+The calculator is automatically selected based on the experiment type. You can
+use the `show_supported_calculator_types()` method on an experiment to see which
+calculation engines are compatible:
 
 ```python
-project.show_supported_calculators()
+project.experiments['hrpt'].show_supported_calculator_types()
 ```
 
 This will display a list of supported calculators along with their descriptions,
 allowing you to choose the one that best fits your needs.
 
-An example of the output for the `show_supported_calculators()` method is:
+An example of the output for a Bragg diffraction experiment:
 
-| Calculator | Description                                                 |
-| ---------- | ----------------------------------------------------------- |
-| cryspy     | CrysPy library for crystallographic calculations            |
-| pdffit     | PDFfit2 library for pair distribution function calculations |
+| Calculator | Description                                      |
+| ---------- | ------------------------------------------------ |
+| cryspy     | CrysPy library for crystallographic calculations |
 
 ### Supported minimizers
 
@@ -138,16 +138,16 @@ who want to quickly understand how to work with parameters in their projects.
 An example of the output for the `project.analysis.how_to_access_parameters()`
 method is:
 
-|     | Code variable                                          | Unique ID for CIF                |
-| --- | ------------------------------------------------------ | -------------------------------- |
-| 1   | project.sample_models['lbco'].atom_site['La'].adp_type | lbco.atom_site.La.ADP_type       |
-| 2   | project.sample_models['lbco'].atom_site['La'].b_iso    | lbco.atom_site.La.B_iso_or_equiv |
-| 3   | project.sample_models['lbco'].atom_site['La'].fract_x  | lbco.atom_site.La.fract_x        |
-| 4   | project.sample_models['lbco'].atom_site['La'].fract_y  | lbco.atom_site.La.fract_y        |
-| ... | ...                                                    | ...                              |
-| 59  | project.experiments['hrpt'].peak.broad_gauss_u         | hrpt.peak.broad_gauss_u          |
-| 60  | project.experiments['hrpt'].peak.broad_gauss_v         | hrpt.peak.broad_gauss_v          |
-| 61  | project.experiments['hrpt'].peak.broad_gauss_w         | hrpt.peak.broad_gauss_w          |
+|     | Code variable                                       | Unique ID for CIF                |
+| --- | --------------------------------------------------- | -------------------------------- |
+| 1   | project.structures['lbco'].atom_site['La'].adp_type | lbco.atom_site.La.ADP_type       |
+| 2   | project.structures['lbco'].atom_site['La'].b_iso    | lbco.atom_site.La.B_iso_or_equiv |
+| 3   | project.structures['lbco'].atom_site['La'].fract_x  | lbco.atom_site.La.fract_x        |
+| 4   | project.structures['lbco'].atom_site['La'].fract_y  | lbco.atom_site.La.fract_y        |
+| ... | ...                                                 | ...                              |
+| 59  | project.experiments['hrpt'].peak.broad_gauss_u      | hrpt.peak.broad_gauss_u          |
+| 60  | project.experiments['hrpt'].peak.broad_gauss_v      | hrpt.peak.broad_gauss_v          |
+| 61  | project.experiments['hrpt'].peak.broad_gauss_w      | hrpt.peak.broad_gauss_w          |
 
 ### Supported plotters
 
@@ -169,7 +169,7 @@ An example of the output is:
 
 Once the EasyDiffraction package is imported, you can proceed with the **data
 analysis**. This step can be split into several sub-steps, such as creating a
-project, defining sample models, adding experimental data, etc.
+project, defining structures, adding experimental data, etc.
 
 EasyDiffraction provides a **Python API** that allows you to perform these steps
 programmatically in a certain linear order. This is especially useful for users
diff --git a/docs/user-guide/parameters.md b/docs/user-guide/parameters.md
index 0fc1d609..794f65aa 100644
--- a/docs/user-guide/parameters.md
+++ b/docs/user-guide/parameters.md
@@ -2,7 +2,7 @@
 
 The data analysis process, introduced in the [Concept](concept.md) section,
 assumes that you mainly work with different parameters. The parameters are used
-to describe the sample model and the experiment and are required to set up the
+to describe the structure and the experiment and are required to set up the
 analysis.
 
 Each parameter in EasyDiffraction has a specific name used for code reference,
@@ -40,9 +40,9 @@ means the parameter is fixed. To optimize a parameter, set `free` to `True`.
 
 Although parameters are central, EasyDiffraction hides their creation and
 attribute handling from the user. The user only accesses the required parameters
-through the top-level objects, such as `project`, `sample_models`,
-`experiments`, etc. The parameters are created and initialized automatically
-when a new project is created or an existing one is loaded.
+through the top-level objects, such as `project`, `structures`, `experiments`,
+etc. The parameters are created and initialized automatically when a new project
+is created or an existing one is loaded.
 
 In the following sections, you can see a list of the parameters used in
 EasyDiffraction. Use the tabs to switch between how to access a parameter in
@@ -51,27 +51,26 @@ code and its CIF name for serialization.
 !!! warning "Important"
 
     Remember that parameters are accessed in code through their parent objects,
-    such as `project`, `sample_models`, or `experiments`. For example, if you
-    have a sample model with the ID `nacl`, you can access the space group name
+    such as `project`, `structures`, or `experiments`. For example, if you
+    have a structure with the ID `nacl`, you can access the space group name
     using the following syntax:
 
     ```python
-    project.sample_models['nacl'].space_group.name_h_m
+    project.structures['nacl'].space_group.name_h_m
     ```
 
-In the example above, `space_group` is a sample model category, and `name_h_m`
-is the parameter. For simplicity, only the last part (`category.parameter`) of
-the full access name will be shown in the tables below.
+In the example above, `space_group` is a structure category, and `name_h_m` is
+the parameter. For simplicity, only the last part (`category.parameter`) of the
+full access name will be shown in the tables below.
 
 In addition, the CIF names are also provided for each parameter, which are used
 to serialize the parameters in the CIF format.
 
 Tags defining the corresponding experiment type are also given before the table.
 
-## Sample model parameters
+## Structure parameters
 
-Below is a list of parameters used to describe the sample model in
-EasyDiffraction.
+Below is a list of parameters used to describe the structure in EasyDiffraction.
 
 ### Crystall structure parameters
 
diff --git a/pixi.lock b/pixi.lock
index 106286d9..1685316c 100644
--- a/pixi.lock
+++ b/pixi.lock
@@ -4669,8 +4669,8 @@ packages:
   requires_python: '>=3.9,<4.0'
 - pypi: ./
   name: easydiffraction
-  version: 0.10.2+devdirty78
-  sha256: 735c06d04ba73c012b4d00d6562d8f55a629fd23dfa89532d2818e52d493013d
+  version: 0.10.2+devdirty36
+  sha256: c26412f987f3f60607ea00f77d4f12aadc3b38ea31833f634b218e09965dbdbc
   requires_dist:
   - asciichartpy
   - asteval
diff --git a/pyproject.toml b/pyproject.toml
index d59c6205..dd645c71 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -265,8 +265,9 @@ ban-relative-imports = 'all'
 force-single-line = true
 
 [tool.ruff.lint.per-file-ignores]
-'*test_*.py' = ['S101']  # allow asserts in test files
-'conftest.py' = ['S101'] # allow asserts in test files
+'*test_*.py' = ['S101']    # allow asserts in test files
+'conftest.py' = ['S101']   # allow asserts in test files
+'*/__init__.py' = ['F401'] # re-exports are intentional in __init__.py
 # Vendored jupyter_dark_detect: keep as-is from upstream for easy updates
 # https://github.com/OpenMined/jupyter-dark-detect/tree/main/jupyter_dark_detect
 'src/easydiffraction/utils/_vendored/jupyter_dark_detect/*' = [
diff --git a/src/easydiffraction/__init__.py b/src/easydiffraction/__init__.py
index 259c931d..d15d6682 100644
--- a/src/easydiffraction/__init__.py
+++ b/src/easydiffraction/__init__.py
@@ -1,9 +1,9 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.experiments.experiment.factory import ExperimentFactory
+from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory
+from easydiffraction.datablocks.structure.item.factory import StructureFactory
 from easydiffraction.project.project import Project
-from easydiffraction.sample_models.sample_model.factory import SampleModelFactory
 from easydiffraction.utils.logging import Logger
 from easydiffraction.utils.logging import console
 from easydiffraction.utils.logging import log
@@ -13,18 +13,3 @@
 from easydiffraction.utils.utils import get_value_from_xye_header
 from easydiffraction.utils.utils import list_tutorials
 from easydiffraction.utils.utils import show_version
-
-__all__ = [
-    'Project',
-    'ExperimentFactory',
-    'SampleModelFactory',
-    'download_data',
-    'download_tutorial',
-    'download_all_tutorials',
-    'list_tutorials',
-    'get_value_from_xye_header',
-    'show_version',
-    'Logger',
-    'log',
-    'console',
-]
diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py
index 3532290e..bf403247 100644
--- a/src/easydiffraction/analysis/analysis.py
+++ b/src/easydiffraction/analysis/analysis.py
@@ -7,18 +7,19 @@
 
 import pandas as pd
 
-from easydiffraction.analysis.calculators.factory import CalculatorFactory
-from easydiffraction.analysis.categories.aliases import Aliases
-from easydiffraction.analysis.categories.constraints import Constraints
+from easydiffraction.analysis.categories.aliases.factory import AliasesFactory
+from easydiffraction.analysis.categories.constraints.factory import ConstraintsFactory
+from easydiffraction.analysis.categories.fit_mode import FitModeEnum
+from easydiffraction.analysis.categories.fit_mode import FitModeFactory
 from easydiffraction.analysis.categories.joint_fit_experiments import JointFitExperiments
 from easydiffraction.analysis.fitting import Fitter
 from easydiffraction.analysis.minimizers.factory import MinimizerFactory
-from easydiffraction.core.parameters import NumericDescriptor
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.parameters import StringDescriptor
-from easydiffraction.core.singletons import ConstraintsHandler
+from easydiffraction.core.singleton import ConstraintsHandler
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.core.variable import Parameter
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.collection import Experiments
 from easydiffraction.display.tables import TableRenderer
-from easydiffraction.experiments.experiments import Experiments
 from easydiffraction.utils.logging import console
 from easydiffraction.utils.logging import log
 from easydiffraction.utils.utils import render_cif
@@ -30,7 +31,7 @@ class Analysis:
 
     This class wires calculators and minimizers, exposes a compact
     interface for parameters, constraints and results, and coordinates
-    computations across the project's sample models and experiments.
+    computations across the project's structures and experiments.
 
     Typical usage:
 
@@ -46,8 +47,6 @@ class Analysis:
         fitter: Active fitter/minimizer driver.
     """
 
-    _calculator = CalculatorFactory.create_calculator('cryspy')
-
     def __init__(self, project) -> None:
         """Create a new Analysis instance bound to a project.
 
@@ -55,13 +54,152 @@ def __init__(self, project) -> None:
             project: The project that owns models and experiments.
         """
         self.project = project
-        self.aliases = Aliases()
-        self.constraints = Constraints()
+        self._aliases_type: str = AliasesFactory.default_tag()
+        self.aliases = AliasesFactory.create(self._aliases_type)
+        self._constraints_type: str = ConstraintsFactory.default_tag()
+        self.constraints = ConstraintsFactory.create(self._constraints_type)
         self.constraints_handler = ConstraintsHandler.get()
-        self.calculator = Analysis._calculator  # Default calculator shared by project
-        self._calculator_key: str = 'cryspy'  # Added to track the current calculator
-        self._fit_mode: str = 'single'
-        self.fitter = Fitter('lmfit (leastsq)')
+        self._fit_mode_type: str = FitModeFactory.default_tag()
+        self._fit_mode = FitModeFactory.create(self._fit_mode_type)
+        self._joint_fit_experiments = JointFitExperiments()
+        self.fitter = Fitter('lmfit')
+
+    def help(self) -> None:
+        """Print a summary of analysis properties and methods."""
+        from easydiffraction.core.guard import GuardedBase
+
+        console.paragraph("Help for 'Analysis'")
+
+        cls = type(self)
+
+        # Auto-discover properties from MRO
+        seen_props: dict = {}
+        for base in cls.mro():
+            for key, attr in base.__dict__.items():
+                if key.startswith('_') or not isinstance(attr, property):
+                    continue
+                if key not in seen_props:
+                    seen_props[key] = attr
+
+        prop_rows = []
+        for i, key in enumerate(sorted(seen_props), 1):
+            prop = seen_props[key]
+            writable = '✓' if prop.fset else '✗'
+            doc = GuardedBase._first_sentence(prop.fget.__doc__ if prop.fget else None)
+            prop_rows.append([str(i), key, writable, doc])
+
+        if prop_rows:
+            console.paragraph('Properties')
+            render_table(
+                columns_headers=['#', 'Name', 'Writable', 'Description'],
+                columns_alignment=['right', 'left', 'center', 'left'],
+                columns_data=prop_rows,
+            )
+
+        # Auto-discover methods from MRO
+        seen_methods: set = set()
+        methods_list: list = []
+        for base in cls.mro():
+            for key, attr in base.__dict__.items():
+                if key.startswith('_') or key in seen_methods:
+                    continue
+                if isinstance(attr, property):
+                    continue
+                raw = attr
+                if isinstance(raw, (staticmethod, classmethod)):
+                    raw = raw.__func__
+                if callable(raw):
+                    seen_methods.add(key)
+                    methods_list.append((key, raw))
+
+        method_rows = []
+        for i, (key, method) in enumerate(sorted(methods_list), 1):
+            doc = GuardedBase._first_sentence(getattr(method, '__doc__', None))
+            method_rows.append([str(i), f'{key}()', doc])
+
+        if method_rows:
+            console.paragraph('Methods')
+            render_table(
+                columns_headers=['#', 'Name', 'Description'],
+                columns_alignment=['right', 'left', 'left'],
+                columns_data=method_rows,
+            )
+
+    # ------------------------------------------------------------------
+    #  Aliases (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def aliases_type(self) -> str:
+        """Tag of the active aliases collection type."""
+        return self._aliases_type
+
+    @aliases_type.setter
+    def aliases_type(self, new_type: str) -> None:
+        """Switch to a different aliases collection type.
+
+        Args:
+            new_type: Aliases tag (e.g. ``'default'``).
+        """
+        supported_tags = AliasesFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported aliases type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_aliases_types()'",
+            )
+            return
+        self.aliases = AliasesFactory.create(new_type)
+        self._aliases_type = new_type
+        console.paragraph('Aliases type changed to')
+        console.print(new_type)
+
+    def show_supported_aliases_types(self) -> None:
+        """Print a table of supported aliases collection types."""
+        AliasesFactory.show_supported()
+
+    def show_current_aliases_type(self) -> None:
+        """Print the currently used aliases collection type."""
+        console.paragraph('Current aliases type')
+        console.print(self._aliases_type)
+
+    # ------------------------------------------------------------------
+    #  Constraints (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def constraints_type(self) -> str:
+        """Tag of the active constraints collection type."""
+        return self._constraints_type
+
+    @constraints_type.setter
+    def constraints_type(self, new_type: str) -> None:
+        """Switch to a different constraints collection type.
+
+        Args:
+            new_type: Constraints tag (e.g. ``'default'``).
+        """
+        supported_tags = ConstraintsFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported constraints type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_constraints_types()'",
+            )
+            return
+        self.constraints = ConstraintsFactory.create(new_type)
+        self._constraints_type = new_type
+        console.paragraph('Constraints type changed to')
+        console.print(new_type)
+
+    def show_supported_constraints_types(self) -> None:
+        """Print a table of supported constraints collection types."""
+        ConstraintsFactory.show_supported()
+
+    def show_current_constraints_type(self) -> None:
+        """Print the currently used constraints collection type."""
+        console.paragraph('Current constraints type')
+        console.print(self._constraints_type)
 
     def _get_params_as_dataframe(
         self,
@@ -108,13 +246,13 @@ def _get_params_as_dataframe(
         return df
 
     def show_all_params(self) -> None:
-        """Print a table with all parameters for sample models and
+        """Print a table with all parameters for structures and
         experiments.
         """
-        sample_models_params = self.project.sample_models.parameters
+        structures_params = self.project.structures.parameters
         experiments_params = self.project.experiments.parameters
 
-        if not sample_models_params and not experiments_params:
+        if not structures_params and not experiments_params:
             log.warning('No parameters found.')
             return
 
@@ -129,8 +267,8 @@ def show_all_params(self) -> None:
             'fittable',
         ]
 
-        console.paragraph('All parameters for all sample models (🧩 data blocks)')
-        df = self._get_params_as_dataframe(sample_models_params)
+        console.paragraph('All parameters for all structures (🧩 data blocks)')
+        df = self._get_params_as_dataframe(structures_params)
         filtered_df = df[filtered_headers]
         tabler.render(filtered_df)
 
@@ -143,10 +281,10 @@ def show_fittable_params(self) -> None:
         """Print a table with parameters that can be included in
         fitting.
         """
-        sample_models_params = self.project.sample_models.fittable_parameters
+        structures_params = self.project.structures.fittable_parameters
         experiments_params = self.project.experiments.fittable_parameters
 
-        if not sample_models_params and not experiments_params:
+        if not structures_params and not experiments_params:
             log.warning('No fittable parameters found.')
             return
 
@@ -163,8 +301,8 @@ def show_fittable_params(self) -> None:
             'free',
         ]
 
-        console.paragraph('Fittable parameters for all sample models (🧩 data blocks)')
-        df = self._get_params_as_dataframe(sample_models_params)
+        console.paragraph('Fittable parameters for all structures (🧩 data blocks)')
+        df = self._get_params_as_dataframe(structures_params)
         filtered_df = df[filtered_headers]
         tabler.render(filtered_df)
 
@@ -177,9 +315,9 @@ def show_free_params(self) -> None:
         """Print a table with only currently-free (varying)
         parameters.
         """
-        sample_models_params = self.project.sample_models.free_parameters
+        structures_params = self.project.structures.free_parameters
         experiments_params = self.project.experiments.free_parameters
-        free_params = sample_models_params + experiments_params
+        free_params = structures_params + experiments_params
 
         if not free_params:
             log.warning('No free parameters found.')
@@ -200,8 +338,7 @@ def show_free_params(self) -> None:
         ]
 
         console.paragraph(
-            'Free parameters for both sample models (🧩 data blocks) '
-            'and experiments (🔬 data blocks)'
+            'Free parameters for both structures (🧩 data blocks) and experiments (🔬 data blocks)'
         )
         df = self._get_params_as_dataframe(free_params)
         filtered_df = df[filtered_headers]
@@ -213,10 +350,10 @@ def how_to_access_parameters(self) -> None:
         The output explains how to reference specific parameters in
         code.
         """
-        sample_models_params = self.project.sample_models.parameters
+        structures_params = self.project.structures.parameters
         experiments_params = self.project.experiments.parameters
         all_params = {
-            'sample_models': sample_models_params,
+            'structures': structures_params,
             'experiments': experiments_params,
         }
 
@@ -277,10 +414,10 @@ def show_parameter_cif_uids(self) -> None:
         The output explains which unique identifiers are used when
         creating CIF-based constraints.
         """
-        sample_models_params = self.project.sample_models.parameters
+        structures_params = self.project.structures.parameters
         experiments_params = self.project.experiments.parameters
         all_params = {
-            'sample_models': sample_models_params,
+            'structures': structures_params,
             'experiments': experiments_params,
         }
 
@@ -328,40 +465,6 @@ def show_parameter_cif_uids(self) -> None:
             columns_data=columns_data,
         )
 
-    def show_current_calculator(self) -> None:
-        """Print the name of the currently selected calculator
-        engine.
-        """
-        console.paragraph('Current calculator')
-        console.print(self.current_calculator)
-
-    @staticmethod
-    def show_supported_calculators() -> None:
-        """Print a table of available calculator backends on this
-        system.
-        """
-        CalculatorFactory.show_supported_calculators()
-
-    @property
-    def current_calculator(self) -> str:
-        """The key/name of the active calculator backend."""
-        return self._calculator_key
-
-    @current_calculator.setter
-    def current_calculator(self, calculator_name: str) -> None:
-        """Switch to a different calculator backend.
-
-        Args:
-            calculator_name: Calculator key to use (e.g. 'cryspy').
-        """
-        calculator = CalculatorFactory.create_calculator(calculator_name)
-        if calculator is None:
-            return
-        self.calculator = calculator
-        self._calculator_key = calculator_name
-        console.paragraph('Current calculator changed to')
-        console.print(self.current_calculator)
-
     def show_current_minimizer(self) -> None:
         """Print the name of the currently selected minimizer."""
         console.paragraph('Current minimizer')
@@ -372,7 +475,7 @@ def show_available_minimizers() -> None:
         """Print a table of available minimizer drivers on this
         system.
         """
-        MinimizerFactory.show_available_minimizers()
+        MinimizerFactory.show_supported()
 
     @property
     def current_minimizer(self) -> Optional[str]:
@@ -384,78 +487,63 @@ def current_minimizer(self, selection: str) -> None:
         """Switch to a different minimizer implementation.
 
         Args:
-            selection: Minimizer selection string, e.g.
-                'lmfit (leastsq)'.
+            selection: Minimizer selection string, e.g. 'lmfit'.
         """
         self.fitter = Fitter(selection)
         console.paragraph('Current minimizer changed to')
         console.print(self.current_minimizer)
 
+    # ------------------------------------------------------------------
+    #  Fit mode (switchable-category pattern)
+    # ------------------------------------------------------------------
+
     @property
-    def fit_mode(self) -> str:
-        """Current fitting strategy: either 'single' or 'joint'."""
+    def fit_mode(self):
+        """Fit-mode category item holding the active strategy."""
         return self._fit_mode
 
-    @fit_mode.setter
-    def fit_mode(self, strategy: str) -> None:
-        """Set the fitting strategy.
+    @property
+    def fit_mode_type(self) -> str:
+        """Tag of the active fit-mode category type."""
+        return self._fit_mode_type
 
-        When set to 'joint', all experiments get default weights and
-        are used together in a single optimization.
+    @fit_mode_type.setter
+    def fit_mode_type(self, new_type: str) -> None:
+        """Switch to a different fit-mode category type.
 
         Args:
-                strategy: Either 'single' or 'joint'.
-
-        Raises:
-            ValueError: If an unsupported strategy value is
-                provided.
+            new_type: Fit-mode tag (e.g. ``'default'``).
         """
-        if strategy not in ['single', 'joint']:
-            raise ValueError("Fit mode must be either 'single' or 'joint'")
-        self._fit_mode = strategy
-        if strategy == 'joint' and not hasattr(self, 'joint_fit_experiments'):
-            # Pre-populate all experiments with weight 0.5
-            self.joint_fit_experiments = JointFitExperiments()
-            for id in self.project.experiments.names:
-                self.joint_fit_experiments.add(id=id, weight=0.5)
-        console.paragraph('Current fit mode changed to')
-        console.print(self._fit_mode)
-
-    def show_available_fit_modes(self) -> None:
-        """Print all supported fitting strategies and their
-        descriptions.
-        """
-        strategies = [
-            {
-                'Strategy': 'single',
-                'Description': 'Independent fitting of each experiment; no shared parameters',
-            },
-            {
-                'Strategy': 'joint',
-                'Description': 'Simultaneous fitting of all experiments; '
-                'some parameters are shared',
-            },
-        ]
+        supported_tags = FitModeFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported fit-mode type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_fit_mode_types()'",
+            )
+            return
+        self._fit_mode = FitModeFactory.create(new_type)
+        self._fit_mode_type = new_type
+        console.paragraph('Fit-mode type changed to')
+        console.print(new_type)
 
-        columns_headers = ['Strategy', 'Description']
-        columns_alignment = ['left', 'left']
-        columns_data = []
-        for item in strategies:
-            strategy = item['Strategy']
-            description = item['Description']
-            columns_data.append([strategy, description])
+    def show_supported_fit_mode_types(self) -> None:
+        """Print a table of supported fit-mode category types."""
+        FitModeFactory.show_supported()
 
-        console.paragraph('Available fit modes')
-        render_table(
-            columns_headers=columns_headers,
-            columns_alignment=columns_alignment,
-            columns_data=columns_data,
-        )
+    def show_current_fit_mode_type(self) -> None:
+        """Print the currently used fit-mode category type."""
+        console.paragraph('Current fit-mode type')
+        console.print(self._fit_mode_type)
 
-    def show_current_fit_mode(self) -> None:
-        """Print the currently active fitting strategy."""
-        console.paragraph('Current fit mode')
-        console.print(self.fit_mode)
+    # ------------------------------------------------------------------
+    #  Joint-fit experiments (category)
+    # ------------------------------------------------------------------
+
+    @property
+    def joint_fit_experiments(self):
+        """Per-experiment weight collection for joint fitting."""
+        return self._joint_fit_experiments
 
     def show_constraints(self) -> None:
         """Print a table of all user-defined symbolic constraints."""
@@ -519,9 +607,9 @@ def fit(self):
             project.analysis.fit()
             project.analysis.show_fit_results()  # Display results
         """
-        sample_models = self.project.sample_models
-        if not sample_models:
-            log.warning('No sample models found in the project. Cannot run fit.')
+        structures = self.project.structures
+        if not structures:
+            log.warning('No structures found in the project. Cannot run fit.')
             return
 
         experiments = self.project.experiments
@@ -530,23 +618,26 @@ def fit(self):
             return
 
         # Run the fitting process
-        if self.fit_mode == 'joint':
+        mode = FitModeEnum(self._fit_mode.mode.value)
+        if mode is FitModeEnum.JOINT:
+            # Auto-populate joint_fit_experiments if empty
+            if not len(self._joint_fit_experiments):
+                for id in experiments.names:
+                    self._joint_fit_experiments.create(id=id, weight=0.5)
             console.paragraph(
-                f"Using all experiments 🔬 {experiments.names} for '{self.fit_mode}' fitting"
+                f"Using all experiments 🔬 {experiments.names} for '{mode.value}' fitting"
             )
             self.fitter.fit(
-                sample_models,
+                structures,
                 experiments,
-                weights=self.joint_fit_experiments,
+                weights=self._joint_fit_experiments,
                 analysis=self,
             )
-        elif self.fit_mode == 'single':
+        elif mode is FitModeEnum.SINGLE:
             # TODO: Find a better way without creating dummy
             #  experiments?
             for expt_name in experiments.names:
-                console.paragraph(
-                    f"Using experiment 🔬 '{expt_name}' for '{self.fit_mode}' fitting"
-                )
+                console.paragraph(f"Using experiment 🔬 '{expt_name}' for '{mode.value}' fitting")
                 experiment = experiments[expt_name]
                 dummy_experiments = Experiments()  # TODO: Find a better name
 
@@ -555,14 +646,14 @@ def fit(self):
                 # parameters can be resolved correctly during fitting.
                 object.__setattr__(dummy_experiments, '_parent', self.project)
 
-                dummy_experiments._add(experiment)
+                dummy_experiments.add(experiment)
                 self.fitter.fit(
-                    sample_models,
+                    structures,
                     dummy_experiments,
                     analysis=self,
                 )
         else:
-            raise NotImplementedError(f'Fit mode {self.fit_mode} not implemented yet.')
+            raise NotImplementedError(f'Fit mode {mode.value} not implemented yet.')
 
         # After fitting, get the results
         self.fit_results = self.fitter.results
@@ -586,10 +677,10 @@ def show_fit_results(self) -> None:
             log.warning('No fit results available. Run fit() first.')
             return
 
-        sample_models = self.project.sample_models
+        structures = self.project.structures
         experiments = self.project.experiments
 
-        self.fitter._process_fit_results(sample_models, experiments)
+        self.fitter._process_fit_results(structures, experiments)
 
     def _update_categories(self, called_by_minimizer=False) -> None:
         """Update all categories owned by Analysis.
diff --git a/src/easydiffraction/analysis/calculators/__init__.py b/src/easydiffraction/analysis/calculators/__init__.py
index 429f2648..5874b1a4 100644
--- a/src/easydiffraction/analysis/calculators/__init__.py
+++ b/src/easydiffraction/analysis/calculators/__init__.py
@@ -1,2 +1,6 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.analysis.calculators.crysfml import CrysfmlCalculator
+from easydiffraction.analysis.calculators.cryspy import CryspyCalculator
+from easydiffraction.analysis.calculators.pdffit import PdffitCalculator
diff --git a/src/easydiffraction/analysis/calculators/base.py b/src/easydiffraction/analysis/calculators/base.py
index f4d99c4d..5ebd3086 100644
--- a/src/easydiffraction/analysis/calculators/base.py
+++ b/src/easydiffraction/analysis/calculators/base.py
@@ -6,9 +6,9 @@
 
 import numpy as np
 
-from easydiffraction.experiments.experiment.base import ExperimentBase
-from easydiffraction.sample_models.sample_model.base import SampleModelBase
-from easydiffraction.sample_models.sample_models import SampleModels
+from easydiffraction.datablocks.experiment.item.base import ExperimentBase
+from easydiffraction.datablocks.structure.collection import Structures
+from easydiffraction.datablocks.structure.item.base import Structure
 
 
 class CalculatorBase(ABC):
@@ -27,11 +27,11 @@ def engine_imported(self) -> bool:
     @abstractmethod
     def calculate_structure_factors(
         self,
-        sample_model: SampleModelBase,
+        structure: Structure,
         experiment: ExperimentBase,
         called_by_minimizer: bool,
     ) -> None:
-        """Calculate structure factors for a single sample model and
+        """Calculate structure factors for a single structure and
         experiment.
         """
         pass
@@ -39,15 +39,15 @@ def calculate_structure_factors(
     @abstractmethod
     def calculate_pattern(
         self,
-        sample_model: SampleModels,  # TODO: SampleModelBase?
+        structure: Structures,  # TODO: Structure?
         experiment: ExperimentBase,
         called_by_minimizer: bool,
     ) -> np.ndarray:
-        """Calculate the diffraction pattern for a single sample model
-        and experiment.
+        """Calculate the diffraction pattern for a single structure and
+        experiment.
 
         Args:
-            sample_model: The sample model object.
+            structure: The structure object.
             experiment: The experiment object.
             called_by_minimizer: Whether the calculation is called by a
                 minimizer.
diff --git a/src/easydiffraction/analysis/calculators/crysfml.py b/src/easydiffraction/analysis/calculators/crysfml.py
index babd3e08..5d47300e 100644
--- a/src/easydiffraction/analysis/calculators/crysfml.py
+++ b/src/easydiffraction/analysis/calculators/crysfml.py
@@ -9,10 +9,12 @@
 import numpy as np
 
 from easydiffraction.analysis.calculators.base import CalculatorBase
-from easydiffraction.experiments.experiment.base import ExperimentBase
-from easydiffraction.experiments.experiments import Experiments
-from easydiffraction.sample_models.sample_model.base import SampleModelBase
-from easydiffraction.sample_models.sample_models import SampleModels
+from easydiffraction.analysis.calculators.factory import CalculatorFactory
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.datablocks.experiment.collection import Experiments
+from easydiffraction.datablocks.experiment.item.base import ExperimentBase
+from easydiffraction.datablocks.structure.collection import Structures
+from easydiffraction.datablocks.structure.item.base import Structure
 
 try:
     from pycrysfml import cfml_py_utilities
@@ -27,9 +29,14 @@
     cfml_py_utilities = None
 
 
+@CalculatorFactory.register
 class CrysfmlCalculator(CalculatorBase):
     """Wrapper for Crysfml library."""
 
+    type_info = TypeInfo(
+        tag='crysfml',
+        description='CrysFML library for crystallographic calculations',
+    )
     engine_imported: bool = cfml_py_utilities is not None
 
     @property
@@ -38,13 +45,13 @@ def name(self) -> str:
 
     def calculate_structure_factors(
         self,
-        sample_models: SampleModels,
+        structures: Structures,
         experiments: Experiments,
     ) -> None:
         """Call Crysfml to calculate structure factors.
 
         Args:
-            sample_models: The sample models to calculate structure
+            structures: The structures to calculate structure
                 factors for.
             experiments: The experiments associated with the sample
                 models.
@@ -53,16 +60,16 @@ def calculate_structure_factors(
 
     def calculate_pattern(
         self,
-        sample_model: SampleModels,
+        structure: Structures,
         experiment: ExperimentBase,
         called_by_minimizer: bool = False,
     ) -> Union[np.ndarray, List[float]]:
         """Calculates the diffraction pattern using Crysfml for the
-        given sample model and experiment.
+        given structure and experiment.
 
         Args:
-            sample_model: The sample model to calculate the pattern for.
-            experiment: The experiment associated with the sample model.
+            structure: The structure to calculate the pattern for.
+            experiment: The experiment associated with the structure.
             called_by_minimizer: Whether the calculation is called by a
             minimizer.
 
@@ -73,7 +80,7 @@ def calculate_pattern(
         # Intentionally unused, required by public API/signature
         del called_by_minimizer
 
-        crysfml_dict = self._crysfml_dict(sample_model, experiment)
+        crysfml_dict = self._crysfml_dict(structure, experiment)
         try:
             _, y = cfml_py_utilities.cw_powder_pattern_from_dict(crysfml_dict)
             y = self._adjust_pattern_length(y, len(experiment.data.x))
@@ -104,53 +111,53 @@ def _adjust_pattern_length(
 
     def _crysfml_dict(
         self,
-        sample_model: SampleModels,
+        structure: Structures,
         experiment: ExperimentBase,
-    ) -> Dict[str, Union[ExperimentBase, SampleModelBase]]:
-        """Converts the sample model and experiment into a dictionary
+    ) -> Dict[str, Union[ExperimentBase, Structure]]:
+        """Converts the structure and experiment into a dictionary
         format for Crysfml.
 
         Args:
-            sample_model: The sample model to convert.
+            structure: The structure to convert.
             experiment: The experiment to convert.
 
         Returns:
-            A dictionary representation of the sample model and
+            A dictionary representation of the structure and
                 experiment.
         """
-        sample_model_dict = self._convert_sample_model_to_dict(sample_model)
+        structure_dict = self._convert_structure_to_dict(structure)
         experiment_dict = self._convert_experiment_to_dict(experiment)
         return {
-            'phases': [sample_model_dict],
+            'phases': [structure_dict],
             'experiments': [experiment_dict],
         }
 
-    def _convert_sample_model_to_dict(
+    def _convert_structure_to_dict(
         self,
-        sample_model: SampleModelBase,
+        structure: Structure,
     ) -> Dict[str, Any]:
-        """Converts a sample model into a dictionary format.
+        """Converts a structure into a dictionary format.
 
         Args:
-            sample_model: The sample model to convert.
+            structure: The structure to convert.
 
         Returns:
-            A dictionary representation of the sample model.
+            A dictionary representation of the structure.
         """
-        sample_model_dict = {
-            sample_model.name: {
-                '_space_group_name_H-M_alt': sample_model.space_group.name_h_m.value,
-                '_cell_length_a': sample_model.cell.length_a.value,
-                '_cell_length_b': sample_model.cell.length_b.value,
-                '_cell_length_c': sample_model.cell.length_c.value,
-                '_cell_angle_alpha': sample_model.cell.angle_alpha.value,
-                '_cell_angle_beta': sample_model.cell.angle_beta.value,
-                '_cell_angle_gamma': sample_model.cell.angle_gamma.value,
+        structure_dict = {
+            structure.name: {
+                '_space_group_name_H-M_alt': structure.space_group.name_h_m.value,
+                '_cell_length_a': structure.cell.length_a.value,
+                '_cell_length_b': structure.cell.length_b.value,
+                '_cell_length_c': structure.cell.length_c.value,
+                '_cell_angle_alpha': structure.cell.angle_alpha.value,
+                '_cell_angle_beta': structure.cell.angle_beta.value,
+                '_cell_angle_gamma': structure.cell.angle_gamma.value,
                 '_atom_site': [],
             }
         }
 
-        for atom in sample_model.atom_sites:
+        for atom in structure.atom_sites:
             atom_site = {
                 '_label': atom.label.value,
                 '_type_symbol': atom.type_symbol.value,
@@ -161,9 +168,9 @@ def _convert_sample_model_to_dict(
                 '_adp_type': 'Biso',  # Assuming Biso for simplicity
                 '_B_iso_or_equiv': atom.b_iso.value,
             }
-            sample_model_dict[sample_model.name]['_atom_site'].append(atom_site)
+            structure_dict[structure.name]['_atom_site'].append(atom_site)
 
-        return sample_model_dict
+        return structure_dict
 
     def _convert_experiment_to_dict(
         self,
diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py
index 4e5e9b2a..ad09dc2a 100644
--- a/src/easydiffraction/analysis/calculators/cryspy.py
+++ b/src/easydiffraction/analysis/calculators/cryspy.py
@@ -12,10 +12,12 @@
 import numpy as np
 
 from easydiffraction.analysis.calculators.base import CalculatorBase
-from easydiffraction.experiments.experiment.base import ExperimentBase
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import SampleFormEnum
-from easydiffraction.sample_models.sample_model.base import SampleModelBase
+from easydiffraction.analysis.calculators.factory import CalculatorFactory
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.datablocks.experiment.item.base import ExperimentBase
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.structure.item.base import Structure
 
 try:
     import cryspy
@@ -31,6 +33,7 @@
     cryspy = None
 
 
+@CalculatorFactory.register
 class CryspyCalculator(CalculatorBase):
     """Cryspy-based diffraction calculator.
 
@@ -38,6 +41,10 @@ class CryspyCalculator(CalculatorBase):
     patterns.
     """
 
+    type_info = TypeInfo(
+        tag='cryspy',
+        description='CrysPy library for crystallographic calculations',
+    )
     engine_imported: bool = cryspy is not None
 
     @property
@@ -50,7 +57,7 @@ def __init__(self) -> None:
 
     def calculate_structure_factors(
         self,
-        sample_model: SampleModelBase,
+        structure: Structure,
         experiment: ExperimentBase,
         called_by_minimizer: bool = False,
     ):
@@ -58,23 +65,23 @@ def calculate_structure_factors(
         implemented.
 
         Args:
-            sample_model: The sample model to calculate structure
+            structure: The structure to calculate structure
                 factors for.
             experiment: The experiment associated with the sample
                 models.
             called_by_minimizer: Whether the calculation is called by a
                 minimizer.
         """
-        combined_name = f'{sample_model.name}_{experiment.name}'
+        combined_name = f'{structure.name}_{experiment.name}'
 
         if called_by_minimizer:
             if self._cryspy_dicts and combined_name in self._cryspy_dicts:
-                cryspy_dict = self._recreate_cryspy_dict(sample_model, experiment)
+                cryspy_dict = self._recreate_cryspy_dict(structure, experiment)
             else:
-                cryspy_obj = self._recreate_cryspy_obj(sample_model, experiment)
+                cryspy_obj = self._recreate_cryspy_obj(structure, experiment)
                 cryspy_dict = cryspy_obj.get_dictionary()
         else:
-            cryspy_obj = self._recreate_cryspy_obj(sample_model, experiment)
+            cryspy_obj = self._recreate_cryspy_obj(structure, experiment)
             cryspy_dict = cryspy_obj.get_dictionary()
 
         self._cryspy_dicts[combined_name] = copy.deepcopy(cryspy_dict)
@@ -108,12 +115,12 @@ def calculate_structure_factors(
 
     def calculate_pattern(
         self,
-        sample_model: SampleModelBase,
+        structure: Structure,
         experiment: ExperimentBase,
         called_by_minimizer: bool = False,
     ) -> Union[np.ndarray, List[float]]:
         """Calculates the diffraction pattern using Cryspy for the given
-        sample model and experiment.
+        structure and experiment.
 
         We only recreate the cryspy_obj if this method is
          - NOT called by the minimizer, or
@@ -122,8 +129,8 @@ def calculate_pattern(
         This allows significantly speeding up the calculation
 
         Args:
-            sample_model: The sample model to calculate the pattern for.
-            experiment: The experiment associated with the sample model.
+            structure: The structure to calculate the pattern for.
+            experiment: The experiment associated with the structure.
             called_by_minimizer: Whether the calculation is called by a
                 minimizer.
 
@@ -131,16 +138,16 @@ def calculate_pattern(
             The calculated diffraction pattern as a NumPy array or a
                 list of floats.
         """
-        combined_name = f'{sample_model.name}_{experiment.name}'
+        combined_name = f'{structure.name}_{experiment.name}'
 
         if called_by_minimizer:
             if self._cryspy_dicts and combined_name in self._cryspy_dicts:
-                cryspy_dict = self._recreate_cryspy_dict(sample_model, experiment)
+                cryspy_dict = self._recreate_cryspy_dict(structure, experiment)
             else:
-                cryspy_obj = self._recreate_cryspy_obj(sample_model, experiment)
+                cryspy_obj = self._recreate_cryspy_obj(structure, experiment)
                 cryspy_dict = cryspy_obj.get_dictionary()
         else:
-            cryspy_obj = self._recreate_cryspy_obj(sample_model, experiment)
+            cryspy_obj = self._recreate_cryspy_obj(structure, experiment)
             cryspy_dict = cryspy_obj.get_dictionary()
 
         self._cryspy_dicts[combined_name] = copy.deepcopy(cryspy_dict)
@@ -184,53 +191,53 @@ def calculate_pattern(
 
     def _recreate_cryspy_dict(
         self,
-        sample_model: SampleModelBase,
+        structure: Structure,
         experiment: ExperimentBase,
     ) -> Dict[str, Any]:
-        """Recreates the Cryspy dictionary for the given sample model
-        and experiment.
+        """Recreates the Cryspy dictionary for the given structure and
+        experiment.
 
         Args:
-            sample_model: The sample model to update.
+            structure: The structure to update.
             experiment: The experiment to update.
 
         Returns:
             The updated Cryspy dictionary.
         """
-        combined_name = f'{sample_model.name}_{experiment.name}'
+        combined_name = f'{structure.name}_{experiment.name}'
         cryspy_dict = copy.deepcopy(self._cryspy_dicts[combined_name])
 
-        cryspy_model_id = f'crystal_{sample_model.name}'
+        cryspy_model_id = f'crystal_{structure.name}'
         cryspy_model_dict = cryspy_dict[cryspy_model_id]
 
         ################################
-        # Update sample model parameters
+        # Update structure parameters
         ################################
 
         # Cell
         cryspy_cell = cryspy_model_dict['unit_cell_parameters']
-        cryspy_cell[0] = sample_model.cell.length_a.value
-        cryspy_cell[1] = sample_model.cell.length_b.value
-        cryspy_cell[2] = sample_model.cell.length_c.value
-        cryspy_cell[3] = np.deg2rad(sample_model.cell.angle_alpha.value)
-        cryspy_cell[4] = np.deg2rad(sample_model.cell.angle_beta.value)
-        cryspy_cell[5] = np.deg2rad(sample_model.cell.angle_gamma.value)
+        cryspy_cell[0] = structure.cell.length_a.value
+        cryspy_cell[1] = structure.cell.length_b.value
+        cryspy_cell[2] = structure.cell.length_c.value
+        cryspy_cell[3] = np.deg2rad(structure.cell.angle_alpha.value)
+        cryspy_cell[4] = np.deg2rad(structure.cell.angle_beta.value)
+        cryspy_cell[5] = np.deg2rad(structure.cell.angle_gamma.value)
 
         # Atomic coordinates
         cryspy_xyz = cryspy_model_dict['atom_fract_xyz']
-        for idx, atom_site in enumerate(sample_model.atom_sites):
+        for idx, atom_site in enumerate(structure.atom_sites):
             cryspy_xyz[0][idx] = atom_site.fract_x.value
             cryspy_xyz[1][idx] = atom_site.fract_y.value
             cryspy_xyz[2][idx] = atom_site.fract_z.value
 
         # Atomic occupancies
         cryspy_occ = cryspy_model_dict['atom_occupancy']
-        for idx, atom_site in enumerate(sample_model.atom_sites):
+        for idx, atom_site in enumerate(structure.atom_sites):
             cryspy_occ[idx] = atom_site.occupancy.value
 
         # Atomic ADPs - Biso only for now
         cryspy_biso = cryspy_model_dict['atom_b_iso']
-        for idx, atom_site in enumerate(sample_model.atom_sites):
+        for idx, atom_site in enumerate(structure.atom_sites):
             cryspy_biso[idx] = atom_site.b_iso.value
 
         ##############################
@@ -298,14 +305,14 @@ def _recreate_cryspy_dict(
 
     def _recreate_cryspy_obj(
         self,
-        sample_model: SampleModelBase,
+        structure: Structure,
         experiment: ExperimentBase,
     ) -> Any:
-        """Recreates the Cryspy object for the given sample model and
+        """Recreates the Cryspy object for the given structure and
         experiment.
 
         Args:
-            sample_model: The sample model to recreate.
+            structure: The structure to recreate.
             experiment: The experiment to recreate.
 
         Returns:
@@ -313,14 +320,14 @@ def _recreate_cryspy_obj(
         """
         cryspy_obj = str_to_globaln('')
 
-        cryspy_sample_model_cif = self._convert_sample_model_to_cryspy_cif(sample_model)
-        cryspy_sample_model_obj = str_to_globaln(cryspy_sample_model_cif)
-        cryspy_obj.add_items(cryspy_sample_model_obj.items)
+        cryspy_structure_cif = self._convert_structure_to_cryspy_cif(structure)
+        cryspy_structure_obj = str_to_globaln(cryspy_structure_cif)
+        cryspy_obj.add_items(cryspy_structure_obj.items)
 
         # Add single experiment to cryspy_obj
         cryspy_experiment_cif = self._convert_experiment_to_cryspy_cif(
             experiment,
-            linked_sample_model=sample_model,
+            linked_structure=structure,
         )
 
         cryspy_experiment_obj = str_to_globaln(cryspy_experiment_cif)
@@ -328,30 +335,30 @@ def _recreate_cryspy_obj(
 
         return cryspy_obj
 
-    def _convert_sample_model_to_cryspy_cif(
+    def _convert_structure_to_cryspy_cif(
         self,
-        sample_model: SampleModelBase,
+        structure: Structure,
     ) -> str:
-        """Converts a sample model to a Cryspy CIF string.
+        """Converts a structure to a Cryspy CIF string.
 
         Args:
-            sample_model: The sample model to convert.
+            structure: The structure to convert.
 
         Returns:
-            The Cryspy CIF string representation of the sample model.
+            The Cryspy CIF string representation of the structure.
         """
-        return sample_model.as_cif
+        return structure.as_cif
 
     def _convert_experiment_to_cryspy_cif(
         self,
         experiment: ExperimentBase,
-        linked_sample_model: Any,
+        linked_structure: Any,
     ) -> str:
         """Converts an experiment to a Cryspy CIF string.
 
         Args:
             experiment: The experiment to convert.
-            linked_sample_model: The sample model linked to the
+            linked_structure: The structure linked to the
                 experiment.
 
         Returns:
@@ -487,14 +494,14 @@ def _convert_experiment_to_cryspy_cif(
         # Add phase data
         if expt_type.sample_form.value == SampleFormEnum.SINGLE_CRYSTAL:
             cif_lines.append('')
-            cif_lines.append(f'_phase_label {linked_sample_model.name}')
+            cif_lines.append(f'_phase_label {linked_structure.name}')
             cif_lines.append('_phase_scale 1.0')
         elif expt_type.sample_form.value == SampleFormEnum.POWDER:
             cif_lines.append('')
             cif_lines.append('loop_')
             cif_lines.append('_phase_label')
             cif_lines.append('_phase_scale')
-            cif_lines.append(f'{linked_sample_model.name} 1.0')
+            cif_lines.append(f'{linked_structure.name} 1.0')
 
         # Add background data
         if expt_type.sample_form.value == SampleFormEnum.POWDER:
diff --git a/src/easydiffraction/analysis/calculators/factory.py b/src/easydiffraction/analysis/calculators/factory.py
index 79b4eba9..fa3812da 100644
--- a/src/easydiffraction/analysis/calculators/factory.py
+++ b/src/easydiffraction/analysis/calculators/factory.py
@@ -1,105 +1,38 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
+"""Calculator factory — delegates to ``FactoryBase``.
+
+Overrides ``_supported_map`` to filter out calculators whose engines
+are not importable in the current environment.
+"""
+
+from __future__ import annotations
 
 from typing import Dict
-from typing import List
-from typing import Optional
 from typing import Type
-from typing import Union
 
-from easydiffraction.analysis.calculators.base import CalculatorBase
-from easydiffraction.analysis.calculators.crysfml import CrysfmlCalculator
-from easydiffraction.analysis.calculators.cryspy import CryspyCalculator
-from easydiffraction.analysis.calculators.pdffit import PdffitCalculator
-from easydiffraction.utils.logging import console
-from easydiffraction.utils.logging import log
-from easydiffraction.utils.utils import render_table
+from easydiffraction.core.factory import FactoryBase
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 
 
-class CalculatorFactory:
+class CalculatorFactory(FactoryBase):
     """Factory for creating calculation engine instances.
 
-    The factory exposes discovery helpers to list and show available
-    calculators in the current environment and a creator that returns an
-    instantiated calculator or ``None`` if the requested one is not
-    available.
+    Only calculators whose ``engine_imported`` flag is ``True`` are
+    available for creation.
     """
 
-    _potential_calculators: Dict[str, Dict[str, Union[str, Type[CalculatorBase]]]] = {
-        'crysfml': {
-            'description': 'CrysFML library for crystallographic calculations',
-            'class': CrysfmlCalculator,
-        },
-        'cryspy': {
-            'description': 'CrysPy library for crystallographic calculations',
-            'class': CryspyCalculator,
-        },
-        'pdffit': {
-            'description': 'PDFfit2 library for pair distribution function calculations',
-            'class': PdffitCalculator,
-        },
+    _default_rules = {
+        frozenset({
+            ('scattering_type', ScatteringTypeEnum.BRAGG),
+        }): CalculatorEnum.CRYSPY,
+        frozenset({
+            ('scattering_type', ScatteringTypeEnum.TOTAL),
+        }): CalculatorEnum.PDFFIT,
     }
 
     @classmethod
-    def _supported_calculators(
-        cls,
-    ) -> Dict[str, Dict[str, Union[str, Type[CalculatorBase]]]]:
-        """Return calculators whose engines are importable.
-
-        This filters the list of potential calculators by instantiating
-        their classes and checking the ``engine_imported`` property.
-
-        Returns:
-            Mapping from calculator name to its config dict.
-        """
-        return {
-            name: cfg
-            for name, cfg in cls._potential_calculators.items()
-            if cfg['class']().engine_imported  # instantiate and check the @property
-        }
-
-    @classmethod
-    def list_supported_calculators(cls) -> List[str]:
-        """List names of calculators available in the environment.
-
-        Returns:
-            List of calculator identifiers, e.g. ``["crysfml", ...]``.
-        """
-        return list(cls._supported_calculators().keys())
-
-    @classmethod
-    def show_supported_calculators(cls) -> None:
-        """Pretty-print supported calculators and their descriptions."""
-        columns_headers: List[str] = ['Calculator', 'Description']
-        columns_alignment = ['left', 'left']
-        columns_data: List[List[str]] = []
-        for name, config in cls._supported_calculators().items():
-            description: str = config.get('description', 'No description provided.')
-            columns_data.append([name, description])
-
-        console.paragraph('Supported calculators')
-        render_table(
-            columns_headers=columns_headers,
-            columns_alignment=columns_alignment,
-            columns_data=columns_data,
-        )
-
-    @classmethod
-    def create_calculator(cls, calculator_name: str) -> Optional[CalculatorBase]:
-        """Create a calculator instance by name.
-
-        Args:
-            calculator_name: Identifier of the calculator to create.
-
-        Returns:
-            A calculator instance or ``None`` if unknown or unsupported.
-        """
-        config = cls._supported_calculators().get(calculator_name)
-        if not config:
-            log.warning(
-                f"Unknown calculator '{calculator_name}', "
-                f'Supported calculators: {cls.list_supported_calculators()}'
-            )
-            return None
-
-        return config['class']()
+    def _supported_map(cls) -> Dict[str, Type]:
+        """Only include calculators whose engines are importable."""
+        return {klass.type_info.tag: klass for klass in cls._registry if klass.engine_imported}
diff --git a/src/easydiffraction/analysis/calculators/pdffit.py b/src/easydiffraction/analysis/calculators/pdffit.py
index 9f099ff6..debd90de 100644
--- a/src/easydiffraction/analysis/calculators/pdffit.py
+++ b/src/easydiffraction/analysis/calculators/pdffit.py
@@ -14,8 +14,10 @@
 import numpy as np
 
 from easydiffraction.analysis.calculators.base import CalculatorBase
-from easydiffraction.experiments.experiment.base import ExperimentBase
-from easydiffraction.sample_models.sample_model.base import SampleModelBase
+from easydiffraction.analysis.calculators.factory import CalculatorFactory
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.datablocks.experiment.item.base import ExperimentBase
+from easydiffraction.datablocks.structure.item.base import Structure
 
 try:
     from diffpy.pdffit2 import PdfFit
@@ -38,25 +40,30 @@
     PdfFit = None
 
 
+@CalculatorFactory.register
 class PdffitCalculator(CalculatorBase):
     """Wrapper for Pdffit library."""
 
+    type_info = TypeInfo(
+        tag='pdffit',
+        description='PDFfit2 for pair distribution function calculations',
+    )
     engine_imported: bool = PdfFit is not None
 
     @property
     def name(self):
         return 'pdffit'
 
-    def calculate_structure_factors(self, sample_models, experiments):
+    def calculate_structure_factors(self, structures, experiments):
         # PDF doesn't compute HKL but we keep interface consistent
         # Intentionally unused, required by public API/signature
-        del sample_models, experiments
+        del structures, experiments
         print('[pdffit] Calculating HKLs (not applicable)...')
         return []
 
     def calculate_pattern(
         self,
-        sample_model: SampleModelBase,
+        structure: Structure,
         experiment: ExperimentBase,
         called_by_minimizer: bool = False,
     ):
@@ -67,12 +74,12 @@ def calculate_pattern(
         calculator = PdfFit()
 
         # ---------------------------
-        # Set sample model parameters
+        # Set structure parameters
         # ---------------------------
 
         # TODO: move CIF v2 -> CIF v1 conversion to a separate module
-        # Convert the sample model to CIF supported by PDFfit
-        cif_string_v2 = sample_model.as_cif
+        # Convert the structure to CIF supported by PDFfit
+        cif_string_v2 = structure.as_cif
         # convert to version 1 of CIF format
         # this means: replace all dots with underscores for
         # cases where the dot is surrounded by letters on both sides.
@@ -80,18 +87,18 @@ def calculate_pattern(
         cif_string_v1 = re.sub(pattern, '_', cif_string_v2)
 
         # Create the PDFit structure
-        structure = pdffit_cif_parser().parse(cif_string_v1)
+        pdffit_structure = pdffit_cif_parser().parse(cif_string_v1)
 
         # Set all model parameters:
         # space group, cell parameters, and atom sites (including ADPs)
-        calculator.add_structure(structure)
+        calculator.add_structure(pdffit_structure)
 
         # -------------------------
         # Set experiment parameters
         # -------------------------
 
         # Set some peak-related parameters
-        calculator.setvar('pscale', experiment.linked_phases[sample_model.name].scale.value)
+        calculator.setvar('pscale', experiment.linked_phases[structure.name].scale.value)
         calculator.setvar('delta1', experiment.peak.sharp_delta_1.value)
         calculator.setvar('delta2', experiment.peak.sharp_delta_2.value)
         calculator.setvar('spdiameter', experiment.peak.damp_particle_diameter.value)
diff --git a/src/easydiffraction/analysis/categories/aliases/__init__.py b/src/easydiffraction/analysis/categories/aliases/__init__.py
new file mode 100644
index 00000000..aa72de6b
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/aliases/__init__.py
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.analysis.categories.aliases.default import Alias
+from easydiffraction.analysis.categories.aliases.default import Aliases
diff --git a/src/easydiffraction/analysis/categories/aliases.py b/src/easydiffraction/analysis/categories/aliases/default.py
similarity index 58%
rename from src/easydiffraction/analysis/categories/aliases.py
rename to src/easydiffraction/analysis/categories/aliases/default.py
index b55db11d..49b263f4 100644
--- a/src/easydiffraction/analysis/categories/aliases.py
+++ b/src/easydiffraction/analysis/categories/aliases/default.py
@@ -6,12 +6,15 @@
 parameters via readable labels instead of raw unique identifiers.
 """
 
+from __future__ import annotations
+
+from easydiffraction.analysis.categories.aliases.factory import AliasesFactory
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import StringDescriptor
 from easydiffraction.io.cif.handler import CifHandler
 
 
@@ -27,80 +30,61 @@ class Alias(CategoryItem):
             ``label``.
     """
 
-    def __init__(
-        self,
-        *,
-        label: str,
-        param_uid: str,
-    ) -> None:
+    def __init__(self) -> None:
         super().__init__()
 
-        self._label: StringDescriptor = StringDescriptor(
+        self._label = StringDescriptor(
             name='label',
-            description='...',
+            description='...',  # TODO
             value_spec=AttributeSpec(
-                value=label,
-                type_=DataTypes.STRING,
-                default='...',
-                content_validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_alias.label',
-                ]
+                default='_',
+                validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
             ),
+            cif_handler=CifHandler(names=['_alias.label']),
         )
-        self._param_uid: StringDescriptor = StringDescriptor(
+        self._param_uid = StringDescriptor(
             name='param_uid',
-            description='...',
+            description='...',  # TODO
             value_spec=AttributeSpec(
-                value=param_uid,
-                type_=DataTypes.STRING,
-                default='...',
-                content_validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_alias.param_uid',
-                ]
+                default='_',
+                validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
             ),
+            cif_handler=CifHandler(names=['_alias.param_uid']),
         )
 
         self._identity.category_code = 'alias'
         self._identity.category_entry_name = lambda: str(self.label.value)
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def label(self):
-        """Alias label descriptor."""
         return self._label
 
     @label.setter
     def label(self, value):
-        """Set alias label.
-
-        Args:
-            value: New label.
-        """
         self._label.value = value
 
     @property
     def param_uid(self):
-        """Parameter uid descriptor the alias points to."""
         return self._param_uid
 
     @param_uid.setter
     def param_uid(self, value):
-        """Set the parameter uid.
-
-        Args:
-            value: New uid.
-        """
         self._param_uid.value = value
 
 
+@AliasesFactory.register
 class Aliases(CategoryCollection):
     """Collection of :class:`Alias` items."""
 
+    type_info = TypeInfo(
+        tag='default',
+        description='Parameter alias mappings',
+    )
+
     def __init__(self):
         """Create an empty collection of aliases."""
         super().__init__(item_type=Alias)
diff --git a/src/easydiffraction/analysis/categories/aliases/factory.py b/src/easydiffraction/analysis/categories/aliases/factory.py
new file mode 100644
index 00000000..07e1fe38
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/aliases/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Aliases factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class AliasesFactory(FactoryBase):
+    """Create alias collections by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/analysis/categories/constraints/__init__.py b/src/easydiffraction/analysis/categories/constraints/__init__.py
new file mode 100644
index 00000000..ded70ca6
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/constraints/__init__.py
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.analysis.categories.constraints.default import Constraint
+from easydiffraction.analysis.categories.constraints.default import Constraints
diff --git a/src/easydiffraction/analysis/categories/constraints.py b/src/easydiffraction/analysis/categories/constraints/default.py
similarity index 58%
rename from src/easydiffraction/analysis/categories/constraints.py
rename to src/easydiffraction/analysis/categories/constraints/default.py
index 205a28b1..647dd273 100644
--- a/src/easydiffraction/analysis/categories/constraints.py
+++ b/src/easydiffraction/analysis/categories/constraints/default.py
@@ -6,13 +6,16 @@
 ``rhs_expr`` is evaluated elsewhere by the analysis engine.
 """
 
+from __future__ import annotations
+
+from easydiffraction.analysis.categories.constraints.factory import ConstraintsFactory
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import StringDescriptor
-from easydiffraction.core.singletons import ConstraintsHandler
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.core.singleton import ConstraintsHandler
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import StringDescriptor
 from easydiffraction.io.cif.handler import CifHandler
 
 
@@ -24,80 +27,61 @@ class Constraint(CategoryItem):
         rhs_expr: Right-hand side expression as a string.
     """
 
-    def __init__(
-        self,
-        *,
-        lhs_alias: str,
-        rhs_expr: str,
-    ) -> None:
+    def __init__(self) -> None:
         super().__init__()
 
-        self._lhs_alias: StringDescriptor = StringDescriptor(
+        self._lhs_alias = StringDescriptor(
             name='lhs_alias',
-            description='...',
+            description='Left-hand side of the equation.',  # TODO
             value_spec=AttributeSpec(
-                value=lhs_alias,
-                type_=DataTypes.STRING,
-                default='...',
-                content_validator=RegexValidator(pattern=r'.*'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_constraint.lhs_alias',
-                ]
+                default='...',  # TODO
+                validator=RegexValidator(pattern=r'.*'),
             ),
+            cif_handler=CifHandler(names=['_constraint.lhs_alias']),
         )
-        self._rhs_expr: StringDescriptor = StringDescriptor(
+        self._rhs_expr = StringDescriptor(
             name='rhs_expr',
-            description='...',
+            description='Right-hand side expression.',  # TODO
             value_spec=AttributeSpec(
-                value=rhs_expr,
-                type_=DataTypes.STRING,
-                default='...',
-                content_validator=RegexValidator(pattern=r'.*'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_constraint.rhs_expr',
-                ]
+                default='...',  # TODO
+                validator=RegexValidator(pattern=r'.*'),
             ),
+            cif_handler=CifHandler(names=['_constraint.rhs_expr']),
         )
 
         self._identity.category_code = 'constraint'
         self._identity.category_entry_name = lambda: str(self.lhs_alias.value)
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def lhs_alias(self):
-        """Alias name on the left-hand side of the equation."""
         return self._lhs_alias
 
     @lhs_alias.setter
     def lhs_alias(self, value):
-        """Set the left-hand side alias.
-
-        Args:
-            value: New alias string.
-        """
         self._lhs_alias.value = value
 
     @property
     def rhs_expr(self):
-        """Right-hand side expression string."""
         return self._rhs_expr
 
     @rhs_expr.setter
     def rhs_expr(self, value):
-        """Set the right-hand side expression.
-
-        Args:
-            value: New expression string.
-        """
         self._rhs_expr.value = value
 
 
+@ConstraintsFactory.register
 class Constraints(CategoryCollection):
     """Collection of :class:`Constraint` items."""
 
+    type_info = TypeInfo(
+        tag='default',
+        description='Symbolic parameter constraints',
+    )
+
     _update_priority = 90  # After most others, but before data categories
 
     def __init__(self):
diff --git a/src/easydiffraction/analysis/categories/constraints/factory.py b/src/easydiffraction/analysis/categories/constraints/factory.py
new file mode 100644
index 00000000..829260f4
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/constraints/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Constraints factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class ConstraintsFactory(FactoryBase):
+    """Create constraint collections by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/analysis/categories/fit_mode/__init__.py b/src/easydiffraction/analysis/categories/fit_mode/__init__.py
new file mode 100644
index 00000000..45267810
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/fit_mode/__init__.py
@@ -0,0 +1,6 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.analysis.categories.fit_mode.enums import FitModeEnum
+from easydiffraction.analysis.categories.fit_mode.factory import FitModeFactory
+from easydiffraction.analysis.categories.fit_mode.fit_mode import FitMode
diff --git a/src/easydiffraction/analysis/categories/fit_mode/enums.py b/src/easydiffraction/analysis/categories/fit_mode/enums.py
new file mode 100644
index 00000000..156e9c30
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/fit_mode/enums.py
@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Enumeration for fit-mode values."""
+
+from __future__ import annotations
+
+from enum import Enum
+
+
+class FitModeEnum(str, Enum):
+    """Fitting strategy for the analysis."""
+
+    SINGLE = 'single'
+    JOINT = 'joint'
+
+    @classmethod
+    def default(cls) -> FitModeEnum:
+        return cls.SINGLE
+
+    def description(self) -> str:
+        if self is FitModeEnum.SINGLE:
+            return 'Independent fitting of each experiment; no shared parameters'
+        elif self is FitModeEnum.JOINT:
+            return 'Simultaneous fitting of all experiments; some parameters are shared'
diff --git a/src/easydiffraction/analysis/categories/fit_mode/factory.py b/src/easydiffraction/analysis/categories/fit_mode/factory.py
new file mode 100644
index 00000000..48edef66
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/fit_mode/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Fit-mode factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class FitModeFactory(FactoryBase):
+    """Create fit-mode category items by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/analysis/categories/fit_mode/fit_mode.py b/src/easydiffraction/analysis/categories/fit_mode/fit_mode.py
new file mode 100644
index 00000000..8a5bd24b
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/fit_mode/fit_mode.py
@@ -0,0 +1,61 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Fit-mode category item.
+
+Stores the active fitting strategy as a CIF-serializable descriptor
+validated by ``FitModeEnum``.
+"""
+
+from __future__ import annotations
+
+from easydiffraction.analysis.categories.fit_mode.enums import FitModeEnum
+from easydiffraction.analysis.categories.fit_mode.factory import FitModeFactory
+from easydiffraction.core.category import CategoryItem
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.validation import MembershipValidator
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.io.cif.handler import CifHandler
+
+
+@FitModeFactory.register
+class FitMode(CategoryItem):
+    """Fitting strategy selector.
+
+    Holds a single ``mode`` descriptor whose value is one of
+    ``FitModeEnum`` members (``'single'`` or ``'joint'``).
+    """
+
+    type_info = TypeInfo(
+        tag='default',
+        description='Fit-mode category',
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
+
+        self._mode: StringDescriptor = StringDescriptor(
+            name='mode',
+            description='Fitting strategy',
+            value_spec=AttributeSpec(
+                default=FitModeEnum.default().value,
+                validator=MembershipValidator(allowed=[member.value for member in FitModeEnum]),
+            ),
+            cif_handler=CifHandler(names=['_analysis.fit_mode']),
+        )
+
+        self._identity.category_code = 'fit_mode'
+
+    @property
+    def mode(self):
+        """Active fitting strategy descriptor."""
+        return self._mode
+
+    @mode.setter
+    def mode(self, value: str) -> None:
+        """Set the fitting strategy value.
+
+        Args:
+            value: ``'single'`` or ``'joint'``.
+        """
+        self._mode.value = value
diff --git a/src/easydiffraction/analysis/categories/joint_fit_experiments/__init__.py b/src/easydiffraction/analysis/categories/joint_fit_experiments/__init__.py
new file mode 100644
index 00000000..2857b28d
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/joint_fit_experiments/__init__.py
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.analysis.categories.joint_fit_experiments.default import JointFitExperiment
+from easydiffraction.analysis.categories.joint_fit_experiments.default import JointFitExperiments
diff --git a/src/easydiffraction/analysis/categories/joint_fit_experiments.py b/src/easydiffraction/analysis/categories/joint_fit_experiments/default.py
similarity index 61%
rename from src/easydiffraction/analysis/categories/joint_fit_experiments.py
rename to src/easydiffraction/analysis/categories/joint_fit_experiments/default.py
index 818dd6f6..6acf4f44 100644
--- a/src/easydiffraction/analysis/categories/joint_fit_experiments.py
+++ b/src/easydiffraction/analysis/categories/joint_fit_experiments/default.py
@@ -6,14 +6,19 @@
 fitted simultaneously.
 """
 
+from __future__ import annotations
+
+from easydiffraction.analysis.categories.joint_fit_experiments.factory import (
+    JointFitExperimentsFactory,
+)
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import NumericDescriptor
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
 from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.core.variable import StringDescriptor
 from easydiffraction.io.cif.handler import CifHandler
 
 
@@ -25,80 +30,61 @@ class JointFitExperiment(CategoryItem):
         weight: Relative weight factor in the combined objective.
     """
 
-    def __init__(
-        self,
-        *,
-        id: str,
-        weight: float,
-    ) -> None:
+    def __init__(self) -> None:
         super().__init__()
 
         self._id: StringDescriptor = StringDescriptor(
             name='id',  # TODO: need new name instead of id
-            description='...',
+            description='Experiment identifier',  # TODO
             value_spec=AttributeSpec(
-                value=id,
-                type_=DataTypes.STRING,
-                default='...',
-                content_validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_joint_fit_experiment.id',
-                ]
+                default='_',
+                validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
             ),
+            cif_handler=CifHandler(names=['_joint_fit_experiment.id']),
         )
         self._weight: NumericDescriptor = NumericDescriptor(
             name='weight',
-            description='...',
+            description='Weight factor',  # TODO
             value_spec=AttributeSpec(
-                value=weight,
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_joint_fit_experiment.weight',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_joint_fit_experiment.weight']),
         )
 
         self._identity.category_code = 'joint_fit_experiment'
         self._identity.category_entry_name = lambda: str(self.id.value)
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def id(self):
-        """Experiment identifier descriptor."""
         return self._id
 
     @id.setter
     def id(self, value):
-        """Set the experiment identifier.
-
-        Args:
-            value: New id string.
-        """
         self._id.value = value
 
     @property
     def weight(self):
-        """Weight factor descriptor."""
         return self._weight
 
     @weight.setter
     def weight(self, value):
-        """Set the weight factor.
-
-        Args:
-            value: New weight value.
-        """
         self._weight.value = value
 
 
+@JointFitExperimentsFactory.register
 class JointFitExperiments(CategoryCollection):
     """Collection of :class:`JointFitExperiment` items."""
 
+    type_info = TypeInfo(
+        tag='default',
+        description='Joint-fit experiment weights',
+    )
+
     def __init__(self):
         """Create an empty joint-fit experiments collection."""
         super().__init__(item_type=JointFitExperiment)
diff --git a/src/easydiffraction/analysis/categories/joint_fit_experiments/factory.py b/src/easydiffraction/analysis/categories/joint_fit_experiments/factory.py
new file mode 100644
index 00000000..2919c741
--- /dev/null
+++ b/src/easydiffraction/analysis/categories/joint_fit_experiments/factory.py
@@ -0,0 +1,17 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Joint-fit-experiments factory — delegates entirely to
+``FactoryBase``.
+"""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class JointFitExperimentsFactory(FactoryBase):
+    """Create joint-fit experiment collections by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/analysis/fit_helpers/metrics.py b/src/easydiffraction/analysis/fit_helpers/metrics.py
index 23d5d42b..97c715d8 100644
--- a/src/easydiffraction/analysis/fit_helpers/metrics.py
+++ b/src/easydiffraction/analysis/fit_helpers/metrics.py
@@ -6,8 +6,8 @@
 
 import numpy as np
 
-from easydiffraction.experiments.experiments import Experiments
-from easydiffraction.sample_models.sample_models import SampleModels
+from easydiffraction.datablocks.experiment.collection import Experiments
+from easydiffraction.datablocks.structure.collection import Structures
 
 
 def calculate_r_factor(
@@ -121,14 +121,14 @@ def calculate_reduced_chi_square(
 
 
 def get_reliability_inputs(
-    sample_models: SampleModels,
+    structures: Structures,
     experiments: Experiments,
 ) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
     """Collect observed and calculated data points for reliability
     calculations.
 
     Args:
-        sample_models: Collection of sample models.
+        structures: Collection of structures.
         experiments: Collection of experiments.
 
     Returns:
@@ -139,8 +139,8 @@ def get_reliability_inputs(
     y_calc_all = []
     y_err_all = []
     for experiment in experiments.values():
-        for sample_model in sample_models:
-            sample_model._update_categories()
+        for structure in structures:
+            structure._update_categories()
         experiment._update_categories()
 
         y_calc = experiment.data.intensity_calc
diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py
index 0541318e..453aaf1a 100644
--- a/src/easydiffraction/analysis/fitting.py
+++ b/src/easydiffraction/analysis/fitting.py
@@ -11,9 +11,9 @@
 
 from easydiffraction.analysis.fit_helpers.metrics import get_reliability_inputs
 from easydiffraction.analysis.minimizers.factory import MinimizerFactory
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.experiments.experiments import Experiments
-from easydiffraction.sample_models.sample_models import SampleModels
+from easydiffraction.core.variable import Parameter
+from easydiffraction.datablocks.experiment.collection import Experiments
+from easydiffraction.datablocks.structure.collection import Structures
 
 if TYPE_CHECKING:
     from easydiffraction.analysis.fit_helpers.reporting import FitResults
@@ -22,15 +22,15 @@
 class Fitter:
     """Handles the fitting workflow using a pluggable minimizer."""
 
-    def __init__(self, selection: str = 'lmfit (leastsq)') -> None:
+    def __init__(self, selection: str = 'lmfit') -> None:
         self.selection: str = selection
-        self.engine: str = selection.split(' ')[0]  # Extracts 'lmfit' or 'dfols'
-        self.minimizer = MinimizerFactory.create_minimizer(selection)
+        self.engine: str = selection
+        self.minimizer = MinimizerFactory.create(selection)
         self.results: Optional[FitResults] = None
 
     def fit(
         self,
-        sample_models: SampleModels,
+        structures: Structures,
         experiments: Experiments,
         weights: Optional[np.array] = None,
         analysis=None,
@@ -42,13 +42,13 @@ def fit(
         to display the fit results after fitting is complete.
 
         Args:
-            sample_models: Collection of sample models.
+            structures: Collection of structures.
             experiments: Collection of experiments.
             weights: Optional weights for joint fitting.
             analysis: Optional Analysis object to update its categories
                 during fitting.
         """
-        params = sample_models.free_parameters + experiments.free_parameters
+        params = structures.free_parameters + experiments.free_parameters
 
         if not params:
             print('⚠️ No parameters selected for fitting.')
@@ -61,7 +61,7 @@ def objective_function(engine_params: Dict[str, Any]) -> np.ndarray:
             return self._residual_function(
                 engine_params=engine_params,
                 parameters=params,
-                sample_models=sample_models,
+                structures=structures,
                 experiments=experiments,
                 weights=weights,
                 analysis=analysis,
@@ -72,7 +72,7 @@ def objective_function(engine_params: Dict[str, Any]) -> np.ndarray:
 
     def _process_fit_results(
         self,
-        sample_models: SampleModels,
+        structures: Structures,
         experiments: Experiments,
     ) -> None:
         """Collect reliability inputs and display fit results.
@@ -83,11 +83,11 @@ def _process_fit_results(
         the console.
 
         Args:
-            sample_models: Collection of sample models.
+            structures: Collection of structures.
             experiments: Collection of experiments.
         """
         y_obs, y_calc, y_err = get_reliability_inputs(
-            sample_models,
+            structures,
             experiments,
         )
 
@@ -105,26 +105,26 @@ def _process_fit_results(
 
     def _collect_free_parameters(
         self,
-        sample_models: SampleModels,
+        structures: Structures,
         experiments: Experiments,
     ) -> List[Parameter]:
-        """Collect free parameters from sample models and experiments.
+        """Collect free parameters from structures and experiments.
 
         Args:
-            sample_models: Collection of sample models.
+            structures: Collection of structures.
             experiments: Collection of experiments.
 
         Returns:
             List of free parameters.
         """
-        free_params: List[Parameter] = sample_models.free_parameters + experiments.free_parameters
+        free_params: List[Parameter] = structures.free_parameters + experiments.free_parameters
         return free_params
 
     def _residual_function(
         self,
         engine_params: Dict[str, Any],
         parameters: List[Parameter],
-        sample_models: SampleModels,
+        structures: Structures,
         experiments: Experiments,
         weights: Optional[np.array] = None,
         analysis=None,
@@ -136,7 +136,7 @@ def _residual_function(
         Args:
             engine_params: Engine-specific parameter dict.
             parameters: List of parameters being optimized.
-            sample_models: Collection of sample models.
+            structures: Collection of structures.
             experiments: Collection of experiments.
             weights: Optional weights for joint fitting.
             analysis: Optional Analysis object to update its categories
@@ -149,10 +149,10 @@ def _residual_function(
         self.minimizer._sync_result_to_parameters(parameters, engine_params)
 
         # Update categories to reflect new parameter values
-        # Order matters: sample models first (symmetry, structure),
+        # Order matters: structures first (symmetry, structure),
         # then analysis (constraints), then experiments (calculations)
-        for sample_model in sample_models:
-            sample_model._update_categories()
+        for structure in structures:
+            structure._update_categories()
 
         if analysis is not None:
             analysis._update_categories(called_by_minimizer=True)
diff --git a/src/easydiffraction/analysis/minimizers/__init__.py b/src/easydiffraction/analysis/minimizers/__init__.py
index 429f2648..8a41a6f2 100644
--- a/src/easydiffraction/analysis/minimizers/__init__.py
+++ b/src/easydiffraction/analysis/minimizers/__init__.py
@@ -1,2 +1,5 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.analysis.minimizers.dfols import DfolsMinimizer
+from easydiffraction.analysis.minimizers.lmfit import LmfitMinimizer
diff --git a/src/easydiffraction/analysis/minimizers/base.py b/src/easydiffraction/analysis/minimizers/base.py
index 275ad973..069601ed 100644
--- a/src/easydiffraction/analysis/minimizers/base.py
+++ b/src/easydiffraction/analysis/minimizers/base.py
@@ -155,7 +155,7 @@ def _objective_function(
         self,
         engine_params: Dict[str, Any],
         parameters: List[Any],
-        sample_models: Any,
+        structures: Any,
         experiments: Any,
         calculator: Any,
     ) -> np.ndarray:
@@ -163,7 +163,7 @@ def _objective_function(
         return self._compute_residuals(
             engine_params,
             parameters,
-            sample_models,
+            structures,
             experiments,
             calculator,
         )
@@ -171,7 +171,7 @@ def _objective_function(
     def _create_objective_function(
         self,
         parameters: List[Any],
-        sample_models: Any,
+        structures: Any,
         experiments: Any,
         calculator: Any,
     ) -> Callable[[Dict[str, Any]], np.ndarray]:
@@ -179,7 +179,7 @@ def _create_objective_function(
         return lambda engine_params: self._objective_function(
             engine_params,
             parameters,
-            sample_models,
+            structures,
             experiments,
             calculator,
         )
diff --git a/src/easydiffraction/analysis/minimizers/dfols.py b/src/easydiffraction/analysis/minimizers/dfols.py
index 544d4b7a..b68a034a 100644
--- a/src/easydiffraction/analysis/minimizers/dfols.py
+++ b/src/easydiffraction/analysis/minimizers/dfols.py
@@ -9,15 +9,23 @@
 from dfols import solve
 
 from easydiffraction.analysis.minimizers.base import MinimizerBase
+from easydiffraction.analysis.minimizers.factory import MinimizerFactory
+from easydiffraction.core.metadata import TypeInfo
 
 DEFAULT_MAX_ITERATIONS = 1000
 
 
+@MinimizerFactory.register
 class DfolsMinimizer(MinimizerBase):
     """Minimizer using the DFO-LS package (Derivative-Free Optimization
     for Least-Squares).
     """
 
+    type_info = TypeInfo(
+        tag='dfols',
+        description='DFO-LS derivative-free least-squares optimization',
+    )
+
     def __init__(
         self,
         name: str = 'dfols',
@@ -59,7 +67,9 @@ def _sync_result_to_parameters(
         result_values = raw_result.x if hasattr(raw_result, 'x') else raw_result
 
         for i, param in enumerate(parameters):
-            param.value = result_values[i]
+            # Bypass validation but set the dirty flag so
+            # _update_categories() knows work is needed.
+            param._set_value_from_minimizer(result_values[i])
             # DFO-LS doesn't provide uncertainties; set to None or
             # calculate later if needed
             param.uncertainty = None
diff --git a/src/easydiffraction/analysis/minimizers/factory.py b/src/easydiffraction/analysis/minimizers/factory.py
index 0b1afaa2..e12a9533 100644
--- a/src/easydiffraction/analysis/minimizers/factory.py
+++ b/src/easydiffraction/analysis/minimizers/factory.py
@@ -1,126 +1,15 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
+"""Minimizer factory — delegates to ``FactoryBase``."""
 
-from typing import Any
-from typing import Dict
-from typing import List
-from typing import Optional
-from typing import Type
+from __future__ import annotations
 
-from easydiffraction.analysis.minimizers.base import MinimizerBase
-from easydiffraction.analysis.minimizers.dfols import DfolsMinimizer
-from easydiffraction.analysis.minimizers.lmfit import LmfitMinimizer
-from easydiffraction.utils.logging import console
-from easydiffraction.utils.utils import render_table
+from easydiffraction.core.factory import FactoryBase
 
 
-class MinimizerFactory:
-    _available_minimizers: Dict[str, Dict[str, Any]] = {
-        'lmfit': {
-            'engine': 'lmfit',
-            'method': 'leastsq',
-            'description': 'LMFIT library using the default Levenberg-Marquardt '
-            'least squares method',
-            'class': LmfitMinimizer,
-        },
-        'lmfit (leastsq)': {
-            'engine': 'lmfit',
-            'method': 'leastsq',
-            'description': 'LMFIT library with Levenberg-Marquardt least squares method',
-            'class': LmfitMinimizer,
-        },
-        'lmfit (least_squares)': {
-            'engine': 'lmfit',
-            'method': 'least_squares',
-            'description': 'LMFIT library with SciPy’s trust region reflective algorithm',
-            'class': LmfitMinimizer,
-        },
-        'dfols': {
-            'engine': 'dfols',
-            'method': None,
-            'description': 'DFO-LS library for derivative-free least-squares optimization',
-            'class': DfolsMinimizer,
-        },
-    }
-
-    @classmethod
-    def list_available_minimizers(cls) -> List[str]:
-        """List all available minimizers.
-
-        Returns:
-            A list of minimizer names.
-        """
-        return list(cls._available_minimizers.keys())
-
-    @classmethod
-    def show_available_minimizers(cls) -> None:
-        # TODO: Rename this method to `show_supported_minimizers` for
-        #  consistency with other methods in the library. E.g.
-        #  `show_supported_calculators`, etc.
-        """Display a table of available minimizers and their
-        descriptions.
-        """
-        columns_headers: List[str] = ['Minimizer', 'Description']
-        columns_alignment = ['left', 'left']
-        columns_data: List[List[str]] = []
-        for name, config in cls._available_minimizers.items():
-            description: str = config.get('description', 'No description provided.')
-            columns_data.append([name, description])
-
-        console.paragraph('Supported minimizers')
-        render_table(
-            columns_headers=columns_headers,
-            columns_alignment=columns_alignment,
-            columns_data=columns_data,
-        )
-
-    @classmethod
-    def create_minimizer(cls, selection: str) -> MinimizerBase:
-        """Create a minimizer instance based on the selection.
-
-        Args:
-            selection: The name of the minimizer to create.
+class MinimizerFactory(FactoryBase):
+    """Factory for creating minimizer instances."""
 
-        Returns:
-            An instance of the selected minimizer.
-
-        Raises:
-            ValueError: If the selection is not a valid minimizer.
-        """
-        config = cls._available_minimizers.get(selection)
-        if not config:
-            raise ValueError(
-                f"Unknown minimizer '{selection}'. Use one of {cls.list_available_minimizers()}"
-            )
-
-        minimizer_class: Type[MinimizerBase] = config.get('class')
-        method: Optional[str] = config.get('method')
-
-        kwargs: Dict[str, Any] = {}
-        if method is not None:
-            kwargs['method'] = method
-
-        return minimizer_class(**kwargs)
-
-    @classmethod
-    def register_minimizer(
-        cls,
-        name: str,
-        minimizer_cls: Type[MinimizerBase],
-        method: Optional[str] = None,
-        description: str = 'No description provided.',
-    ) -> None:
-        """Register a new minimizer.
-
-        Args:
-            name: The name of the minimizer.
-            minimizer_cls: The class of the minimizer.
-            method: The method used by the minimizer (optional).
-            description: A description of the minimizer.
-        """
-        cls._available_minimizers[name] = {
-            'engine': name,
-            'method': method,
-            'description': description,
-            'class': minimizer_cls,
-        }
+    _default_rules = {
+        frozenset(): 'lmfit',
+    }
diff --git a/src/easydiffraction/analysis/minimizers/lmfit.py b/src/easydiffraction/analysis/minimizers/lmfit.py
index d7a651cb..cb0a59fd 100644
--- a/src/easydiffraction/analysis/minimizers/lmfit.py
+++ b/src/easydiffraction/analysis/minimizers/lmfit.py
@@ -8,14 +8,22 @@
 import lmfit
 
 from easydiffraction.analysis.minimizers.base import MinimizerBase
+from easydiffraction.analysis.minimizers.factory import MinimizerFactory
+from easydiffraction.core.metadata import TypeInfo
 
 DEFAULT_METHOD = 'leastsq'
 DEFAULT_MAX_ITERATIONS = 1000
 
 
+@MinimizerFactory.register
 class LmfitMinimizer(MinimizerBase):
     """Minimizer using the lmfit package."""
 
+    type_info = TypeInfo(
+        tag='lmfit',
+        description='LMFIT with Levenberg-Marquardt least squares',
+    )
+
     def __init__(
         self,
         name: str = 'lmfit',
@@ -88,7 +96,9 @@ def _sync_result_to_parameters(
         for param in parameters:
             param_result = param_values.get(param._minimizer_uid)
             if param_result is not None:
-                param._value = param_result.value  # Bypass ranges check
+                # Bypass validation but set the dirty flag so
+                # _update_categories() knows work is needed.
+                param._set_value_from_minimizer(param_result.value)
                 param.uncertainty = getattr(param_result, 'stderr', None)
 
     def _check_success(self, raw_result: Any) -> bool:
diff --git a/src/easydiffraction/core/category.py b/src/easydiffraction/core/category.py
index 6be057da..d4b57dc3 100644
--- a/src/easydiffraction/core/category.py
+++ b/src/easydiffraction/core/category.py
@@ -5,13 +5,15 @@
 
 from easydiffraction.core.collection import CollectionBase
 from easydiffraction.core.guard import GuardedBase
-from easydiffraction.core.parameters import GenericDescriptorBase
-from easydiffraction.core.validation import checktype
+from easydiffraction.core.variable import GenericDescriptorBase
+from easydiffraction.core.variable import GenericStringDescriptor
 from easydiffraction.io.cif.serialize import category_collection_from_cif
 from easydiffraction.io.cif.serialize import category_collection_to_cif
 from easydiffraction.io.cif.serialize import category_item_from_cif
 from easydiffraction.io.cif.serialize import category_item_to_cif
 
+# ======================================================================
+
 
 class CategoryItem(GuardedBase):
     """Base class for items in a category collection."""
@@ -56,6 +58,106 @@ def from_cif(self, block, idx=0):
         """Populate this item from a CIF block."""
         category_item_from_cif(self, block, idx)
 
+    def help(self) -> None:
+        """Print parameters, other properties, and methods."""
+        from easydiffraction.utils.logging import console
+        from easydiffraction.utils.utils import render_table
+
+        cls = type(self)
+        console.paragraph(f"Help for '{cls.__name__}'")
+
+        # Deduplicate properties
+        seen: dict = {}
+        for key, prop in cls._iter_properties():
+            if key not in seen:
+                seen[key] = prop
+
+        # Split into descriptor-backed and other
+        param_rows = []
+        other_rows = []
+        p_idx = 0
+        o_idx = 0
+        for key in sorted(seen):
+            prop = seen[key]
+            try:
+                val = getattr(self, key)
+            except Exception:
+                val = None
+            if isinstance(val, GenericDescriptorBase):
+                p_idx += 1
+                type_str = 'string' if isinstance(val, GenericStringDescriptor) else 'numeric'
+                writable = '✓' if prop.fset else '✗'
+                param_rows.append([
+                    str(p_idx),
+                    key,
+                    type_str,
+                    str(val.value),
+                    writable,
+                    val.description or '',
+                ])
+            else:
+                o_idx += 1
+                writable = '✓' if prop.fset else '✗'
+                doc = self._first_sentence(prop.fget.__doc__ if prop.fget else None)
+                other_rows.append([str(o_idx), key, writable, doc])
+
+        if param_rows:
+            console.paragraph('Parameters')
+            render_table(
+                columns_headers=[
+                    '#',
+                    'Name',
+                    'Type',
+                    'Value',
+                    'Writable',
+                    'Description',
+                ],
+                columns_alignment=[
+                    'right',
+                    'left',
+                    'left',
+                    'right',
+                    'center',
+                    'left',
+                ],
+                columns_data=param_rows,
+            )
+
+        if other_rows:
+            console.paragraph('Other properties')
+            render_table(
+                columns_headers=[
+                    '#',
+                    'Name',
+                    'Writable',
+                    'Description',
+                ],
+                columns_alignment=[
+                    'right',
+                    'left',
+                    'center',
+                    'left',
+                ],
+                columns_data=other_rows,
+            )
+
+        methods = dict(cls._iter_methods())
+        method_rows = []
+        for i, key in enumerate(sorted(methods), 1):
+            doc = self._first_sentence(getattr(methods[key], '__doc__', None))
+            method_rows.append([str(i), f'{key}()', doc])
+
+        if method_rows:
+            console.paragraph('Methods')
+            render_table(
+                columns_headers=['#', 'Name', 'Description'],
+                columns_alignment=['right', 'left', 'left'],
+                columns_data=method_rows,
+            )
+
+
+# ======================================================================
+
 
 class CategoryCollection(CollectionBase):
     """Handles loop-style category containers (e.g. AtomSites).
@@ -66,6 +168,21 @@ class CategoryCollection(CollectionBase):
     # TODO: Common for all categories
     _update_priority = 10  # Default. Lower values run first.
 
+    def _key_for(self, item):
+        """Return the category-level identity key for *item*."""
+        return item._identity.category_entry_name
+
+    def _mark_parent_dirty(self) -> None:
+        """Set ``_need_categories_update`` on the parent datablock.
+
+        Called whenever the collection content changes (items added or
+        removed) so that subsequent ``_update_categories()`` calls
+        re-run all category updates.
+        """
+        parent = getattr(self, '_parent', None)
+        if parent is not None and hasattr(parent, '_need_categories_update'):
+            parent._need_categories_update = True
+
     def __str__(self) -> str:
         """Human-readable representation of this component."""
         name = self._log_name
@@ -98,18 +215,27 @@ def from_cif(self, block):
         """Populate this collection from a CIF block."""
         category_collection_from_cif(self, block)
 
-    @checktype
-    def _add(self, item) -> None:
-        """Add an item to the collection."""
+    def add(self, item) -> None:
+        """Insert or replace a pre-built item into the collection.
+
+        Args:
+            item: A ``CategoryItem`` instance to add.
+        """
         self[item._identity.category_entry_name] = item
+        self._mark_parent_dirty()
+
+    def create(self, **kwargs) -> None:
+        """Create a new item with the given attributes and add it.
 
-    # TODO: Disallow args and only allow kwargs?
-    # TODO: Check kwargs as for, e.g.,
-    #  ExperimentFactory.create(**kwargs)?
-    @checktype
-    def add(self, *args, **kwargs) -> None:
-        """Create and add a new child instance from the provided
-        arguments.
+        A default instance of the collection's item type is created,
+        then each keyword argument is applied via ``setattr``.
+
+        Args:
+            **kwargs: Attribute names and values for the new item.
         """
-        child_obj = self._item_type(*args, **kwargs)
-        self._add(child_obj)
+        child_obj = self._item_type()
+
+        for attr, val in kwargs.items():
+            setattr(child_obj, attr, val)
+
+        self.add(child_obj)
diff --git a/src/easydiffraction/core/collection.py b/src/easydiffraction/core/collection.py
index a84bdb51..164f3d77 100644
--- a/src/easydiffraction/core/collection.py
+++ b/src/easydiffraction/core/collection.py
@@ -40,9 +40,9 @@ def __getitem__(self, name: str):
 
     def __setitem__(self, name: str, item) -> None:
         """Insert or replace an item under the given identity key."""
-        # Check if item with same identity exists; if so, replace it
+        # Check if item with same key exists; if so, replace it
         for i, existing_item in enumerate(self._items):
-            if existing_item._identity.category_entry_name == name:
+            if self._key_for(existing_item) == name:
                 self._items[i] = item
                 self._rebuild_index()
                 return
@@ -53,15 +53,19 @@ def __setitem__(self, name: str, item) -> None:
 
     def __delitem__(self, name: str) -> None:
         """Delete an item by key or raise ``KeyError`` if missing."""
-        # Remove from _items by identity entry name
         for i, item in enumerate(self._items):
-            if item._identity.category_entry_name == name:
+            if self._key_for(item) == name:
                 object.__setattr__(item, '_parent', None)  # Unlink the parent before removal
                 del self._items[i]
                 self._rebuild_index()
                 return
         raise KeyError(name)
 
+    def __contains__(self, name: str) -> bool:
+        """Check whether an item with the given key exists."""
+        self._rebuild_index()
+        return name in self._index
+
     def __iter__(self):
         """Iterate over items in insertion order."""
         return iter(self._items)
@@ -70,9 +74,22 @@ def __len__(self) -> int:
         """Return the number of items in the collection."""
         return len(self._items)
 
+    def remove(self, name: str) -> None:
+        """Remove an item by its key.
+
+        Args:
+            name: Identity key of the item to remove.
+
+        Raises:
+            KeyError: If no item with the given key exists.
+        """
+        del self[name]
+
     def _key_for(self, item):
-        """Return the identity key for ``item`` (category or
-        datablock).
+        """Return the identity key for *item*.
+
+        Subclasses must override to return the appropriate key
+        (``category_entry_name`` or ``datablock_entry_name``).
         """
         return item._identity.category_entry_name or item._identity.datablock_entry_name
 
@@ -100,3 +117,25 @@ def items(self):
     def names(self):
         """List of all item keys in the collection."""
         return list(self.keys())
+
+    def help(self) -> None:
+        """Print a summary of public attributes and contained items."""
+        super().help()
+
+        from easydiffraction.utils.logging import console
+        from easydiffraction.utils.utils import render_table
+
+        if self._items:
+            console.paragraph(f'Items ({len(self._items)})')
+            rows = []
+            for i, item in enumerate(self._items, 1):
+                key = self._key_for(item)
+                rows.append([str(i), str(key), f"['{key}']"])
+            render_table(
+                columns_headers=['#', 'Name', 'Access'],
+                columns_alignment=['right', 'left', 'left'],
+                columns_data=rows,
+            )
+        else:
+            console.paragraph('Items')
+            console.print('(empty)')
diff --git a/src/easydiffraction/core/datablock.py b/src/easydiffraction/core/datablock.py
index a8cc0ed8..221845b6 100644
--- a/src/easydiffraction/core/datablock.py
+++ b/src/easydiffraction/core/datablock.py
@@ -3,13 +3,11 @@
 
 from __future__ import annotations
 
-from typeguard import typechecked
-
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
 from easydiffraction.core.collection import CollectionBase
 from easydiffraction.core.guard import GuardedBase
-from easydiffraction.core.parameters import Parameter
+from easydiffraction.core.variable import Parameter
 
 
 class DatablockItem(GuardedBase):
@@ -17,13 +15,21 @@ class DatablockItem(GuardedBase):
 
     def __init__(self):
         super().__init__()
-        self._need_categories_update = False
+        self._need_categories_update = True
 
     def __str__(self) -> str:
         """Human-readable representation of this component."""
-        name = self._log_name
-        items = getattr(self, '_items', None)
-        return f'<{name} ({items})>'
+        name = self.unique_name
+        cls = type(self).__name__
+        categories = '\n'.join(f'  - {c}' for c in self.categories)
+        return f"{cls} datablock '{name}':\n{categories}"
+
+    def __repr__(self) -> str:
+        """Developer-oriented representation of this component."""
+        name = self.unique_name
+        cls = type(self).__name__
+        num_categories = len(self.categories)
+        return f'<{cls} datablock "{name}" ({num_categories} categories)>'
 
     def _update_categories(
         self,
@@ -31,7 +37,7 @@ def _update_categories(
     ) -> None:
         # TODO: Make abstract method and implement in subclasses.
         # This should call apply_symmetry and apply_constraints in the
-        # case of sample models. In the case of experiments, it should
+        # case of structures. In the case of experiments, it should
         # run calculations to update the "data" categories.
         # Any parameter change should set _need_categories_update to
         # True.
@@ -40,9 +46,15 @@ def _update_categories(
         # Should this be also called when parameters are accessed? E.g.
         # if one change background coefficients, then access the
         # background points in the data category?
-        # return
-        # if not self._need_categories_update:
-        #    return
+        #
+        # Dirty-flag guard: skip if no parameter has changed since the
+        # last update.  Minimisers use _set_value_from_minimizer()
+        # which bypasses validation but still sets this flag.
+        # During fitting the guard is bypassed because experiment
+        # calculations depend on structure parameters owned by a
+        # different DatablockItem whose flag changes are invisible here.
+        if not called_by_minimizer and not self._need_categories_update:
+            return
 
         for category in self.categories:
             category._update(called_by_minimizer=called_by_minimizer)
@@ -79,14 +91,56 @@ def as_cif(self) -> str:
         self._update_categories()
         return datablock_item_to_cif(self)
 
+    def help(self) -> None:
+        """Print a summary of public attributes and categories."""
+        super().help()
+
+        from easydiffraction.utils.logging import console
+        from easydiffraction.utils.utils import render_table
+
+        cats = self.categories
+        if cats:
+            console.paragraph('Categories')
+            rows = []
+            for c in cats:
+                code = c._identity.category_code or type(c).__name__
+                type_name = type(c).__name__
+                num_params = len(c.parameters)
+                rows.append([code, type_name, str(num_params)])
+            render_table(
+                columns_headers=['Category', 'Type', '# Parameters'],
+                columns_alignment=['left', 'left', 'right'],
+                columns_data=rows,
+            )
+
+
+# ======================================================================
+
 
 class DatablockCollection(CollectionBase):
-    """Handles top-level category collections (e.g. SampleModels,
+    """Handles top-level category collections (e.g. Structures,
     Experiments).
 
     Each item is a DatablockItem.
+
+    Subclasses provide explicit ``add_from_*`` convenience methods
+    that delegate to the corresponding factory classmethods, then
+    call :meth:`add` with the resulting item.
     """
 
+    def _key_for(self, item):
+        """Return the datablock-level identity key for *item*."""
+        return item._identity.datablock_entry_name
+
+    def add(self, item) -> None:
+        """Add a pre-built item to the collection.
+
+        Args:
+            item: A ``DatablockItem`` instance (e.g. a ``Structure``
+                or ``ExperimentBase`` subclass).
+        """
+        self[item._identity.datablock_entry_name] = item
+
     def __str__(self) -> str:
         """Human-readable representation of this component."""
         name = self._log_name
@@ -121,8 +175,3 @@ def as_cif(self) -> str:
         from easydiffraction.io.cif.serialize import datablock_collection_to_cif
 
         return datablock_collection_to_cif(self)
-
-    @typechecked
-    def _add(self, item) -> None:
-        """Add an item to the collection."""
-        self[item._identity.datablock_entry_name] = item
diff --git a/src/easydiffraction/core/factory.py b/src/easydiffraction/core/factory.py
index 3500768f..8e699085 100644
--- a/src/easydiffraction/core/factory.py
+++ b/src/easydiffraction/core/factory.py
@@ -1,36 +1,225 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
+"""Base factory with registration, lookup, and context-dependent
+defaults.
 
-from typing import Iterable
-from typing import Mapping
+Concrete factories inherit from ``FactoryBase`` and only need to
+define ``_default_rules``.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from typing import Dict
+from typing import FrozenSet
+from typing import List
+from typing import Tuple
+from typing import Type
+
+from easydiffraction.utils.logging import console
+from easydiffraction.utils.utils import render_table
 
 
 class FactoryBase:
-    """Reusable argument validation mixin."""
+    """Shared base for all factories.
+
+    Subclasses must set:
+
+    * ``_default_rules`` -- mapping of ``frozenset`` conditions to tag
+      strings.  Use ``frozenset(): 'tag'`` for a universal default.
+
+    The ``__init_subclass__`` hook ensures every subclass gets its own
+    independent ``_registry`` list.
+    """
+
+    _registry: List[Type] = []
+    _default_rules: Dict[FrozenSet[Tuple[str, Any]], str] = {}
+
+    def __init_subclass__(cls, **kwargs):
+        """Give each subclass its own independent registry and rules."""
+        super().__init_subclass__(**kwargs)
+        cls._registry = []
+        if '_default_rules' not in cls.__dict__:
+            cls._default_rules = {}
+
+    # ------------------------------------------------------------------
+    # Registration
+    # ------------------------------------------------------------------
+
+    @classmethod
+    def register(cls, klass):
+        """Class decorator to register a concrete class.
+
+        Usage::
+
+            @SomeFactory.register
+            class MyClass(SomeBase):
+                type_info = TypeInfo(...)
+
+        Returns the class unmodified.
+        """
+        cls._registry.append(klass)
+        return klass
+
+    # ------------------------------------------------------------------
+    # Supported-map helpers
+    # ------------------------------------------------------------------
+
+    @classmethod
+    def _supported_map(cls) -> Dict[str, Type]:
+        """Build ``{tag: class}`` from all registered classes."""
+        return {klass.type_info.tag: klass for klass in cls._registry}
+
+    @classmethod
+    def supported_tags(cls) -> List[str]:
+        """Return list of all supported tags."""
+        return list(cls._supported_map().keys())
+
+    # ------------------------------------------------------------------
+    # Default resolution
+    # ------------------------------------------------------------------
+
+    @classmethod
+    def default_tag(cls, **conditions) -> str:
+        """Resolve the default tag for a given experimental context.
+
+        Uses *largest-subset matching*: the rule whose key is the
+        biggest subset of the given conditions wins.  A rule with an
+        empty key (``frozenset()``) acts as a universal fallback.
 
-    @staticmethod
-    def _validate_args(
-        present: set[str],
-        allowed_specs: Iterable[Mapping[str, Iterable[str]]],
-        factory_name: str,
+        Args:
+            **conditions: Experimental-axis values, e.g.
+                ``scattering_type=ScatteringTypeEnum.BRAGG``.
+
+        Returns:
+            The resolved default tag string.
+
+        Raises:
+            ValueError: If no rule matches the given conditions.
+        """
+        condition_set = frozenset(conditions.items())
+        best_match_tag: str | None = None
+        best_match_size = -1
+
+        for rule_key, rule_tag in cls._default_rules.items():
+            if rule_key <= condition_set and len(rule_key) > best_match_size:
+                best_match_tag = rule_tag
+                best_match_size = len(rule_key)
+
+        if best_match_tag is None:
+            raise ValueError(
+                f'No default rule matches conditions {dict(conditions)}. '
+                f'Available rules: {cls._default_rules}'
+            )
+        return best_match_tag
+
+    # ------------------------------------------------------------------
+    # Creation
+    # ------------------------------------------------------------------
+
+    @classmethod
+    def create(cls, tag: str, **kwargs) -> Any:
+        """Instantiate a registered class by *tag*.
+
+        Args:
+            tag: ``type_info.tag`` value.
+            **kwargs: Forwarded to the class constructor.
+
+        Raises:
+            ValueError: If *tag* is not in the registry.
+        """
+        supported = cls._supported_map()
+        if tag not in supported:
+            raise ValueError(f"Unsupported type: '{tag}'. Supported: {list(supported.keys())}")
+        return supported[tag](**kwargs)
+
+    @classmethod
+    def create_default_for(cls, **conditions) -> Any:
+        """Instantiate the default class for a given context.
+
+        Combines ``default_tag(**conditions)`` with ``create(tag)``.
+
+        Args:
+            **conditions: Experimental-axis values.
+        """
+        tag = cls.default_tag(**conditions)
+        return cls.create(tag)
+
+    # ------------------------------------------------------------------
+    # Querying
+    # ------------------------------------------------------------------
+
+    @classmethod
+    def supported_for(
+        cls,
+        *,
+        calculator=None,
+        sample_form=None,
+        scattering_type=None,
+        beam_mode=None,
+        radiation_probe=None,
+    ) -> List[Type]:
+        """Return classes matching conditions and/or calculator.
+
+        Args:
+            calculator: Optional ``CalculatorEnum`` value.
+            sample_form: Optional ``SampleFormEnum`` value.
+            scattering_type: Optional ``ScatteringTypeEnum`` value.
+            beam_mode: Optional ``BeamModeEnum`` value.
+            radiation_probe: Optional ``RadiationProbeEnum`` value.
+        """
+        result = []
+        for klass in cls._supported_map().values():
+            compat = getattr(klass, 'compatibility', None)
+            if compat and not compat.supports(
+                sample_form=sample_form,
+                scattering_type=scattering_type,
+                beam_mode=beam_mode,
+                radiation_probe=radiation_probe,
+            ):
+                continue
+            calc_support = getattr(klass, 'calculator_support', None)
+            if calculator and calc_support and not calc_support.supports(calculator):
+                continue
+            result.append(klass)
+        return result
+
+    # ------------------------------------------------------------------
+    # Display
+    # ------------------------------------------------------------------
+
+    @classmethod
+    def show_supported(
+        cls,
+        *,
+        calculator=None,
+        sample_form=None,
+        scattering_type=None,
+        beam_mode=None,
+        radiation_probe=None,
     ) -> None:
-        """Validate provided arguments against allowed combinations."""
-        for spec in allowed_specs:
-            required = set(spec.get('required', []))
-            optional = set(spec.get('optional', []))
-            if required.issubset(present) and present <= (required | optional):
-                return  # valid combo
-        # build readable error message
-        combos = []
-        for spec in allowed_specs:
-            req = ', '.join(spec.get('required', []))
-            opt = ', '.join(spec.get('optional', []))
-            if opt:
-                combos.append(f'({req}[, {opt}])')
-            else:
-                combos.append(f'({req})')
-        raise ValueError(
-            f'Invalid argument combination for {factory_name} creation.\n'
-            f'Provided: {sorted(present)}\n'
-            f'Allowed combinations:\n  ' + '\n  '.join(combos)
+        """Pretty-print a table of supported types.
+
+        Args:
+            calculator: Optional ``CalculatorEnum`` filter.
+            sample_form: Optional ``SampleFormEnum`` filter.
+            scattering_type: Optional ``ScatteringTypeEnum`` filter.
+            beam_mode: Optional ``BeamModeEnum`` filter.
+            radiation_probe: Optional ``RadiationProbeEnum`` filter.
+        """
+        matching = cls.supported_for(
+            calculator=calculator,
+            sample_form=sample_form,
+            scattering_type=scattering_type,
+            beam_mode=beam_mode,
+            radiation_probe=radiation_probe,
+        )
+        columns_headers = ['Type', 'Description']
+        columns_alignment = ['left', 'left']
+        columns_data = [[klass.type_info.tag, klass.type_info.description] for klass in matching]
+        console.paragraph('Supported types')
+        render_table(
+            columns_headers=columns_headers,
+            columns_alignment=columns_alignment,
+            columns_data=columns_data,
         )
diff --git a/src/easydiffraction/core/guard.py b/src/easydiffraction/core/guard.py
index 0979f4cd..a0033d14 100644
--- a/src/easydiffraction/core/guard.py
+++ b/src/easydiffraction/core/guard.py
@@ -1,6 +1,8 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
+from __future__ import annotations
+
 from abc import ABC
 from abc import abstractmethod
 
@@ -16,6 +18,7 @@ class GuardedBase(ABC):
     _diagnoser = Diagnostics()
 
     def __init__(self):
+        super().__init__()
         self._identity = Identity(owner=self)
 
     def __str__(self) -> str:
@@ -141,3 +144,79 @@ def as_cif(self) -> str:
         by subclasses).
         """
         raise NotImplementedError
+
+    @staticmethod
+    def _first_sentence(docstring: str | None) -> str:
+        """Extract the first paragraph from a docstring.
+
+        Returns text before the first blank line, with continuation
+        lines joined into a single string.
+        """
+        if not docstring:
+            return ''
+        first_para = docstring.strip().split('\n\n')[0]
+        return ' '.join(line.strip() for line in first_para.splitlines())
+
+    @classmethod
+    def _iter_methods(cls):
+        """Iterate over public methods in the class hierarchy.
+
+        Yields:
+            tuple[str, callable]: Each (name, function) pair.
+        """
+        seen: set = set()
+        for base in cls.mro():
+            for key, attr in base.__dict__.items():
+                if key.startswith('_') or key in seen:
+                    continue
+                if isinstance(attr, property):
+                    continue
+                raw = attr
+                if isinstance(raw, (staticmethod, classmethod)):
+                    raw = raw.__func__
+                if callable(raw):
+                    seen.add(key)
+                    yield key, raw
+
+    def help(self) -> None:
+        """Print a summary of public properties and methods."""
+        from easydiffraction.utils.logging import console
+        from easydiffraction.utils.utils import render_table
+
+        cls = type(self)
+        console.paragraph(f"Help for '{cls.__name__}'")
+
+        # Deduplicate (MRO may yield the same name)
+        seen: dict = {}
+        for key, prop in cls._iter_properties():
+            if key not in seen:
+                seen[key] = prop
+
+        prop_rows = []
+        for i, key in enumerate(sorted(seen), 1):
+            prop = seen[key]
+            writable = '✓' if prop.fset else '✗'
+            doc = self._first_sentence(prop.fget.__doc__ if prop.fget else None)
+            prop_rows.append([str(i), key, writable, doc])
+
+        if prop_rows:
+            console.paragraph('Properties')
+            render_table(
+                columns_headers=['#', 'Name', 'Writable', 'Description'],
+                columns_alignment=['right', 'left', 'center', 'left'],
+                columns_data=prop_rows,
+            )
+
+        methods = dict(cls._iter_methods())
+        method_rows = []
+        for i, key in enumerate(sorted(methods), 1):
+            doc = self._first_sentence(getattr(methods[key], '__doc__', None))
+            method_rows.append([str(i), f'{key}()', doc])
+
+        if method_rows:
+            console.paragraph('Methods')
+            render_table(
+                columns_headers=['#', 'Name', 'Description'],
+                columns_alignment=['right', 'left', 'left'],
+                columns_data=method_rows,
+            )
diff --git a/src/easydiffraction/core/metadata.py b/src/easydiffraction/core/metadata.py
new file mode 100644
index 00000000..4a820515
--- /dev/null
+++ b/src/easydiffraction/core/metadata.py
@@ -0,0 +1,107 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Metadata dataclasses for factory-created classes.
+
+Three frozen dataclasses describe a concrete class:
+
+- ``TypeInfo`` — stable tag and human-readable description.
+- ``Compatibility`` — experimental conditions (multiple fields).
+- ``CalculatorSupport`` — which calculation engines can handle it.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import FrozenSet
+
+
+@dataclass(frozen=True)
+class TypeInfo:
+    """Stable identity and human-readable description for a factory-
+    created class.
+
+    Attributes:
+        tag: Short, stable string identifier used for serialization,
+            user-facing selection, and factory lookup.  Must be unique
+            within a factory's registry.  Examples: ``'line-segment'``,
+            ``'pseudo-voigt'``, ``'cryspy'``.
+        description: One-line human-readable explanation.  Used in
+            ``show_supported()`` tables and documentation.
+    """
+
+    tag: str
+    description: str = ''
+
+
+@dataclass(frozen=True)
+class Compatibility:
+    """Experimental conditions under which a class can be used.
+
+    Each field is a frozenset of enum values representing the set of
+    supported values for that axis.  An empty frozenset means
+    "compatible with any value of this axis" (i.e. no restriction).
+    """
+
+    sample_form: FrozenSet = frozenset()
+    scattering_type: FrozenSet = frozenset()
+    beam_mode: FrozenSet = frozenset()
+    radiation_probe: FrozenSet = frozenset()
+
+    def supports(
+        self,
+        sample_form=None,
+        scattering_type=None,
+        beam_mode=None,
+        radiation_probe=None,
+    ) -> bool:
+        """Check if this compatibility matches the given conditions.
+
+        Each argument is an optional enum member.  Returns ``True`` if
+        every provided value is in the corresponding frozenset (or the
+        frozenset is empty, meaning *any*).
+
+        Example::
+
+            compat.supports(
+                scattering_type=ScatteringTypeEnum.BRAGG,
+                beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH,
+            )
+        """
+        for axis, value in (
+            ('sample_form', sample_form),
+            ('scattering_type', scattering_type),
+            ('beam_mode', beam_mode),
+            ('radiation_probe', radiation_probe),
+        ):
+            if value is None:
+                continue
+            allowed = getattr(self, axis)
+            if allowed and value not in allowed:
+                return False
+        return True
+
+
+@dataclass(frozen=True)
+class CalculatorSupport:
+    """Which calculation engines can handle this class.
+
+    Attributes:
+        calculators: Frozenset of ``CalculatorEnum`` values.  Empty
+            means "any calculator" (no restriction).
+    """
+
+    calculators: FrozenSet = frozenset()
+
+    def supports(self, calculator) -> bool:
+        """Check if a specific calculator can handle this class.
+
+        Args:
+            calculator: A ``CalculatorEnum`` value.
+
+        Returns:
+            ``True`` if the calculator is in the set, or if the set is
+            empty (meaning any calculator is accepted).
+        """
+        if not self.calculators:
+            return True
+        return calculator in self.calculators
diff --git a/src/easydiffraction/core/singletons.py b/src/easydiffraction/core/singleton.py
similarity index 94%
rename from src/easydiffraction/core/singletons.py
rename to src/easydiffraction/core/singleton.py
index 2cd553c1..10af4667 100644
--- a/src/easydiffraction/core/singletons.py
+++ b/src/easydiffraction/core/singleton.py
@@ -12,6 +12,8 @@
 
 T = TypeVar('T', bound='SingletonBase')
 
+# ======================================================================
+
 
 class SingletonBase:
     """Base class to implement Singleton pattern.
@@ -30,6 +32,9 @@ def get(cls: Type[T]) -> T:
         return cls._instance
 
 
+# ======================================================================
+
+
 class UidMapHandler(SingletonBase):
     """Global handler to manage UID-to-Parameter object mapping."""
 
@@ -47,7 +52,7 @@ def add_to_uid_map(self, parameter):
         Only Descriptor or Parameter instances are allowed (not
         Components or others).
         """
-        from easydiffraction.core.parameters import GenericDescriptorBase
+        from easydiffraction.core.variable import GenericDescriptorBase
 
         if not isinstance(parameter, GenericDescriptorBase):
             raise TypeError(
@@ -71,6 +76,9 @@ def replace_uid(self, old_uid, new_uid):
     # TODO: Implement removing from the UID map
 
 
+# ======================================================================
+
+
 # TODO: Implement changing atrr '.constrained' back to False
 #  when removing constraints
 class ConstraintsHandler(SingletonBase):
@@ -164,8 +172,7 @@ def apply(self) -> None:
                 param = uid_map[dependent_uid]
 
                 # Update its value and mark it as constrained
-                param._value = rhs_value  # To bypass ranges check
-                param._constrained = True  # To bypass read-only check
+                param._set_value_constrained(rhs_value)
 
             except Exception as error:
                 print(f"Failed to apply constraint '{lhs_alias} = {rhs_expr}': {error}")
diff --git a/src/easydiffraction/core/validation.py b/src/easydiffraction/core/validation.py
index 5e31c485..1fd268d9 100644
--- a/src/easydiffraction/core/validation.py
+++ b/src/easydiffraction/core/validation.py
@@ -6,7 +6,6 @@
 descriptors and parameters. Only documentation was added here.
 """
 
-import functools
 import re
 from abc import ABC
 from abc import abstractmethod
@@ -14,19 +13,26 @@
 from enum import auto
 
 import numpy as np
-from typeguard import TypeCheckError
-from typeguard import typechecked
 
 from easydiffraction.core.diagnostic import Diagnostics
-from easydiffraction.utils.logging import log
 
-# ==============================================================
+# ======================================================================
 # Shared constants
-# ==============================================================
+# ======================================================================
+
+
+# TODO: MkDocs doesn't unpack types
+class DataTypeHints:
+    Numeric = int | float | np.integer | np.floating
+    String = str
+    Bool = bool
+
+
+# ======================================================================
 
 
 class DataTypes(Enum):
-    NUMERIC = (int, float, np.integer, np.floating, np.number)
+    NUMERIC = (int, float, np.integer, np.floating)
     STRING = (str,)
     BOOL = (bool,)
     ANY = (object,)  # fallback for unconstrained
@@ -40,47 +46,9 @@ def expected_type(self):
         return self.value
 
 
-# ==============================================================
-# Runtime type checking decorator
-# ==============================================================
-
-# Runtime type checking decorator for validating those methods
-# annotated with type hints, which are writable for the user, and
-# which are not covered by custom validators for Parameter attribute
-# types and content, implemented below.
-
-
-def checktype(func=None, *, context=None):
-    """Runtime type check decorator using typeguard.
-
-    When a TypeCheckError occurs, the error is logged and None is
-    returned. If context is provided, it is added to the message.
-    """
-
-    def decorator(f):
-        checked_func = typechecked(f)
-
-        @functools.wraps(f)
-        def wrapper(*args, **kwargs):
-            try:
-                return checked_func(*args, **kwargs)
-            except TypeCheckError as err:
-                msg = str(err)
-                if context:
-                    msg = f'{context}: {msg}'
-                log.error(message=msg, exc_type=TypeError)
-                return None
-
-        return wrapper
-
-    if func is None:
-        return decorator
-    return decorator(func)
-
-
-# ==============================================================
+# ======================================================================
 # Validation stages (enum/constant)
-# ==============================================================
+# ======================================================================
 
 
 class ValidationStage(Enum):
@@ -95,9 +63,9 @@ def __str__(self):
         return self.name.lower()
 
 
-# ==============================================================
+# ======================================================================
 # Advanced runtime custom validators for Parameter types/content
-# ==============================================================
+# ======================================================================
 
 
 class ValidatorBase(ABC):
@@ -120,8 +88,11 @@ def _fallback(
         return current if current is not None else default
 
 
+# ======================================================================
+
+
 class TypeValidator(ValidatorBase):
-    """Ensure a value is of the expected Python type."""
+    """Ensure a value is of the expected data type."""
 
     def __init__(self, expected_type: DataTypes):
         if isinstance(expected_type, DataTypes):
@@ -171,6 +142,9 @@ def validated(
         return value
 
 
+# ======================================================================
+
+
 class RangeValidator(ValidatorBase):
     """Ensure a numeric value lies within [ge, le]."""
 
@@ -209,6 +183,9 @@ def validated(
         return value
 
 
+# ======================================================================
+
+
 class MembershipValidator(ValidatorBase):
     """Ensure that a value is among allowed choices.
 
@@ -248,6 +225,9 @@ def validated(
         return value
 
 
+# ======================================================================
+
+
 class RegexValidator(ValidatorBase):
     """Ensure that a string matches a given regular expression."""
 
@@ -280,9 +260,9 @@ def validated(
         return value
 
 
-# ==============================================================
+# ======================================================================
 # Attribute specification holding metadata and validators
-# ==============================================================
+# ======================================================================
 
 
 class AttributeSpec:
@@ -291,17 +271,15 @@ class AttributeSpec:
     def __init__(
         self,
         *,
-        value=None,
-        type_=None,
         default=None,
-        content_validator=None,
+        data_type=None,
+        validator=None,
         allow_none: bool = False,
     ):
-        self.value = value
         self.default = default
         self.allow_none = allow_none
-        self._type_validator = TypeValidator(type_) if type_ else None
-        self._content_validator = content_validator
+        self._data_type_validator = TypeValidator(data_type) if data_type else None
+        self._validator = validator
 
     def validated(
         self,
@@ -319,8 +297,8 @@ def validated(
         default = self.default() if callable(self.default) else self.default
 
         # Type validation
-        if self._type_validator:
-            val = self._type_validator.validated(
+        if self._data_type_validator:
+            val = self._data_type_validator.validated(
                 val,
                 name,
                 default=default,
@@ -334,8 +312,8 @@ def validated(
             return None
 
         # Content validation
-        if self._content_validator and val is not None:
-            val = self._content_validator.validated(
+        if self._validator and val is not None:
+            val = self._validator.validated(
                 val,
                 name,
                 default=default,
diff --git a/src/easydiffraction/core/parameters.py b/src/easydiffraction/core/variable.py
similarity index 80%
rename from src/easydiffraction/core/parameters.py
rename to src/easydiffraction/core/variable.py
index 37b982e0..6b33a39e 100644
--- a/src/easydiffraction/core/parameters.py
+++ b/src/easydiffraction/core/variable.py
@@ -12,7 +12,7 @@
 
 from easydiffraction.core.diagnostic import Diagnostics
 from easydiffraction.core.guard import GuardedBase
-from easydiffraction.core.singletons import UidMapHandler
+from easydiffraction.core.singleton import UidMapHandler
 from easydiffraction.core.validation import AttributeSpec
 from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
@@ -23,6 +23,8 @@
 if TYPE_CHECKING:
     from easydiffraction.io.cif.handler import CifHandler
 
+# ======================================================================
+
 
 class GenericDescriptorBase(GuardedBase):
     """Base class for all parameter-like descriptors.
@@ -40,7 +42,7 @@ class GenericDescriptorBase(GuardedBase):
     """
 
     _BOOL_SPEC_TEMPLATE = AttributeSpec(
-        type_=DataTypes.BOOL,
+        data_type=DataTypes.BOOL,
         default=False,
     )
 
@@ -64,8 +66,8 @@ def __init__(
 
         if expected_type:
             user_type = (
-                value_spec._type_validator.expected_type
-                if value_spec._type_validator is not None
+                value_spec._data_type_validator.expected_type
+                if value_spec._data_type_validator is not None
                 else None
             )
             if user_type and user_type is not expected_type:
@@ -76,17 +78,24 @@ def __init__(
                 )
             else:
                 # Enforce descriptor's own type if not already defined
-                value_spec._type_validator = TypeValidator(expected_type)
+                value_spec._data_type_validator = TypeValidator(expected_type)
 
         self._value_spec = value_spec
         self._name = name
         self._description = description
 
         # Initial validated states
-        self._value = self._value_spec.validated(
-            value_spec.value,
-            name=self.unique_name,
-        )
+        # self._value = self._value_spec.validated(
+        #    value_spec.value,
+        #    name=self.unique_name,
+        # )
+
+        # Assign default directly.
+        # Skip validation — defaults are trusted.
+        # Callable is needed for dynamic defaults like SpaceGroup
+        # it_coordinate_system_code, and similar cases.
+        default = value_spec.default
+        self._value = default() if callable(default) else default
 
     def __str__(self) -> str:
         return f'<{self.unique_name} = {self.value!r}>'
@@ -154,6 +163,25 @@ def value(self, v):
         if parent_datablock is not None:
             parent_datablock._need_categories_update = True
 
+    def _set_value_from_minimizer(self, v) -> None:
+        """Set the value from a minimizer, bypassing validation.
+
+        Writes ``_value`` directly — no type or range checks — but
+        still marks the owning :class:`DatablockItem` dirty so that
+        ``_update_categories()`` knows work is needed.
+
+        This exists because:
+
+        1. Physical-range validators (e.g. intensity ≥ 0) would reject
+           trial values the minimizer needs to explore.
+        2. Validation overhead is measurable over thousands of
+           objective-function evaluations.
+        """
+        self._value = v
+        parent_datablock = self._datablock_item()
+        if parent_datablock is not None:
+            parent_datablock._need_categories_update = True
+
     @property
     def description(self):
         """Optional human-readable description."""
@@ -179,6 +207,9 @@ def from_cif(self, block, idx=0):
         param_from_cif(self, block, idx)
 
 
+# ======================================================================
+
+
 class GenericStringDescriptor(GenericDescriptorBase):
     _value_type = DataTypes.STRING
 
@@ -189,6 +220,9 @@ def __init__(
         super().__init__(**kwargs)
 
 
+# ======================================================================
+
+
 class GenericNumericDescriptor(GenericDescriptorBase):
     _value_type = DataTypes.NUMERIC
 
@@ -214,6 +248,9 @@ def units(self) -> str:
         return self._units
 
 
+# ======================================================================
+
+
 class GenericParameter(GenericNumericDescriptor):
     """Numeric descriptor extended with fitting-related attributes.
 
@@ -232,16 +269,16 @@ def __init__(
         self._free_spec = self._BOOL_SPEC_TEMPLATE
         self._free = self._free_spec.default
         self._uncertainty_spec = AttributeSpec(
-            type_=DataTypes.NUMERIC,
-            content_validator=RangeValidator(ge=0),
+            data_type=DataTypes.NUMERIC,
+            validator=RangeValidator(ge=0),
             allow_none=True,
         )
         self._uncertainty = self._uncertainty_spec.default
-        self._fit_min_spec = AttributeSpec(type_=DataTypes.NUMERIC, default=-np.inf)
+        self._fit_min_spec = AttributeSpec(data_type=DataTypes.NUMERIC, default=-np.inf)
         self._fit_min = self._fit_min_spec.default
-        self._fit_max_spec = AttributeSpec(type_=DataTypes.NUMERIC, default=np.inf)
+        self._fit_max_spec = AttributeSpec(data_type=DataTypes.NUMERIC, default=np.inf)
         self._fit_max = self._fit_max_spec.default
-        self._start_value_spec = AttributeSpec(type_=DataTypes.NUMERIC, default=0.0)
+        self._start_value_spec = AttributeSpec(data_type=DataTypes.NUMERIC, default=0.0)
         self._start_value = self._start_value_spec.default
         self._constrained_spec = self._BOOL_SPEC_TEMPLATE
         self._constrained = self._constrained_spec.default
@@ -275,27 +312,21 @@ def _minimizer_uid(self):
         # return self.unique_name.replace('.', '__')
         return self.uid
 
-    @property
-    def name(self) -> str:
-        """Local name of the parameter (without category/datablock)."""
-        return self._name
-
-    @property
-    def unique_name(self):
-        """Fully qualified parameter name including its context path."""
-        parts = [
-            self._identity.datablock_entry_name,
-            self._identity.category_code,
-            self._identity.category_entry_name,
-            self.name,
-        ]
-        return '.'.join(filter(None, parts))
-
     @property
     def constrained(self):
         """Whether this parameter is part of a constraint expression."""
         return self._constrained
 
+    def _set_value_constrained(self, v) -> None:
+        """Set the value from a constraint expression.
+
+        Validates against the spec, marks the parent datablock dirty,
+        and flags the parameter as constrained. Used exclusively by
+        ``ConstraintsHandler.apply()``.
+        """
+        self.value = v
+        self._constrained = True
+
     @property
     def free(self):
         """Whether this parameter is currently varied during fitting."""
@@ -347,6 +378,9 @@ def fit_max(self, v):
         )
 
 
+# ======================================================================
+
+
 class StringDescriptor(GenericStringDescriptor):
     def __init__(
         self,
@@ -365,6 +399,9 @@ def __init__(
         self._cif_handler.attach(self)
 
 
+# ======================================================================
+
+
 class NumericDescriptor(GenericNumericDescriptor):
     def __init__(
         self,
@@ -383,6 +420,9 @@ def __init__(
         self._cif_handler.attach(self)
 
 
+# ======================================================================
+
+
 class Parameter(GenericParameter):
     def __init__(
         self,
diff --git a/src/easydiffraction/experiments/__init__.py b/src/easydiffraction/datablocks/__init__.py
similarity index 100%
rename from src/easydiffraction/experiments/__init__.py
rename to src/easydiffraction/datablocks/__init__.py
diff --git a/src/easydiffraction/experiments/categories/__init__.py b/src/easydiffraction/datablocks/experiment/__init__.py
similarity index 100%
rename from src/easydiffraction/experiments/categories/__init__.py
rename to src/easydiffraction/datablocks/experiment/__init__.py
diff --git a/src/easydiffraction/experiments/categories/background/__init__.py b/src/easydiffraction/datablocks/experiment/categories/__init__.py
similarity index 100%
rename from src/easydiffraction/experiments/categories/background/__init__.py
rename to src/easydiffraction/datablocks/experiment/categories/__init__.py
diff --git a/src/easydiffraction/datablocks/experiment/categories/background/__init__.py b/src/easydiffraction/datablocks/experiment/categories/background/__init__.py
new file mode 100644
index 00000000..b7b3b47d
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/background/__init__.py
@@ -0,0 +1,9 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.background.chebyshev import (
+    ChebyshevPolynomialBackground,
+)
+from easydiffraction.datablocks.experiment.categories.background.line_segment import (
+    LineSegmentBackground,
+)
diff --git a/src/easydiffraction/experiments/categories/background/base.py b/src/easydiffraction/datablocks/experiment/categories/background/base.py
similarity index 100%
rename from src/easydiffraction/experiments/categories/background/base.py
rename to src/easydiffraction/datablocks/experiment/categories/background/base.py
diff --git a/src/easydiffraction/experiments/categories/background/chebyshev.py b/src/easydiffraction/datablocks/experiment/categories/background/chebyshev.py
similarity index 69%
rename from src/easydiffraction/experiments/categories/background/chebyshev.py
rename to src/easydiffraction/datablocks/experiment/categories/background/chebyshev.py
index 2a21b0d0..4a6a714d 100644
--- a/src/easydiffraction/experiments/categories/background/chebyshev.py
+++ b/src/easydiffraction/datablocks/experiment/categories/background/chebyshev.py
@@ -14,14 +14,19 @@
 from numpy.polynomial.chebyshev import chebval
 
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import NumericDescriptor
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
 from easydiffraction.core.validation import RegexValidator
-from easydiffraction.experiments.categories.background.base import BackgroundBase
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.core.variable import Parameter
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.categories.background.base import BackgroundBase
+from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
 from easydiffraction.io.cif.handler import CifHandler
 from easydiffraction.utils.logging import console
 from easydiffraction.utils.logging import log
@@ -37,67 +42,47 @@ class PolynomialTerm(CategoryItem):
     not break immediately. Tests should migrate to the short names.
     """
 
-    def __init__(
-        self,
-        *,
-        id=None,  # TODO: rename as in the case of data points?
-        order=None,
-        coef=None,
-    ) -> None:
+    def __init__(self) -> None:
         super().__init__()
 
         self._id = StringDescriptor(
             name='id',
             description='Identifier for this background polynomial term.',
             value_spec=AttributeSpec(
-                type_=DataTypes.STRING,
-                value=id,
                 default='0',
                 # TODO: the following pattern is valid for dict key
                 #  (keywords are not checked). CIF label is less strict.
                 #  Do we need conversion between CIF and internal label?
-                content_validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_background.id',
-                ]
+                validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
             ),
+            cif_handler=CifHandler(names=['_pd_background.id']),
         )
         self._order = NumericDescriptor(
             name='order',
             description='Order used in a Chebyshev polynomial background term',
             value_spec=AttributeSpec(
-                value=order,
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_background.Chebyshev_order',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_pd_background.Chebyshev_order']),
         )
         self._coef = Parameter(
             name='coef',
             description='Coefficient used in a Chebyshev polynomial background term',
             value_spec=AttributeSpec(
-                value=coef,
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_background.Chebyshev_coef',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_pd_background.Chebyshev_coef']),
         )
 
         self._identity.category_code = 'background'
         self._identity.category_entry_name = lambda: str(self._id.value)
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def id(self):
         return self._id
@@ -123,8 +108,24 @@ def coef(self, value):
         self._coef.value = value
 
 
+@BackgroundFactory.register
 class ChebyshevPolynomialBackground(BackgroundBase):
-    _description: str = 'Chebyshev polynomial background'
+    type_info = TypeInfo(
+        tag='chebyshev',
+        description='Chebyshev polynomial background',
+    )
+    compatibility = Compatibility(
+        beam_mode=frozenset({
+            BeamModeEnum.CONSTANT_WAVELENGTH,
+            BeamModeEnum.TIME_OF_FLIGHT,
+        }),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({
+            CalculatorEnum.CRYSPY,
+            CalculatorEnum.CRYSFML,
+        }),
+    )
 
     def __init__(self):
         super().__init__(item_type=PolynomialTerm)
diff --git a/src/easydiffraction/experiments/categories/background/enums.py b/src/easydiffraction/datablocks/experiment/categories/background/enums.py
similarity index 95%
rename from src/easydiffraction/experiments/categories/background/enums.py
rename to src/easydiffraction/datablocks/experiment/categories/background/enums.py
index d7edf42e..2356702a 100644
--- a/src/easydiffraction/experiments/categories/background/enums.py
+++ b/src/easydiffraction/datablocks/experiment/categories/background/enums.py
@@ -12,7 +12,7 @@ class BackgroundTypeEnum(str, Enum):
     """Supported background model types."""
 
     LINE_SEGMENT = 'line-segment'
-    CHEBYSHEV = 'chebyshev polynomial'
+    CHEBYSHEV = 'chebyshev'
 
     @classmethod
     def default(cls) -> 'BackgroundTypeEnum':
diff --git a/src/easydiffraction/datablocks/experiment/categories/background/factory.py b/src/easydiffraction/datablocks/experiment/categories/background/factory.py
new file mode 100644
index 00000000..c52b7dd7
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/background/factory.py
@@ -0,0 +1,14 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Background factory — delegates entirely to ``FactoryBase``."""
+
+from easydiffraction.core.factory import FactoryBase
+from easydiffraction.datablocks.experiment.categories.background.enums import BackgroundTypeEnum
+
+
+class BackgroundFactory(FactoryBase):
+    """Create background collections by tag."""
+
+    _default_rules = {
+        frozenset(): BackgroundTypeEnum.LINE_SEGMENT,
+    }
diff --git a/src/easydiffraction/experiments/categories/background/line_segment.py b/src/easydiffraction/datablocks/experiment/categories/background/line_segment.py
similarity index 74%
rename from src/easydiffraction/experiments/categories/background/line_segment.py
rename to src/easydiffraction/datablocks/experiment/categories/background/line_segment.py
index 2df4ca2b..822f6e0d 100644
--- a/src/easydiffraction/experiments/categories/background/line_segment.py
+++ b/src/easydiffraction/datablocks/experiment/categories/background/line_segment.py
@@ -13,14 +13,19 @@
 from scipy.interpolate import interp1d
 
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import NumericDescriptor
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
 from easydiffraction.core.validation import RegexValidator
-from easydiffraction.experiments.categories.background.base import BackgroundBase
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.core.variable import Parameter
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.categories.background.base import BackgroundBase
+from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
 from easydiffraction.io.cif.handler import CifHandler
 from easydiffraction.utils.logging import console
 from easydiffraction.utils.logging import log
@@ -30,32 +35,20 @@
 class LineSegment(CategoryItem):
     """Single background control point for interpolation."""
 
-    def __init__(
-        self,
-        *,
-        id=None,  # TODO: rename as in the case of data points?
-        x=None,
-        y=None,
-    ) -> None:
+    def __init__(self) -> None:
         super().__init__()
 
         self._id = StringDescriptor(
             name='id',
             description='Identifier for this background line segment.',
             value_spec=AttributeSpec(
-                type_=DataTypes.STRING,
-                value=id,
                 default='0',
                 # TODO: the following pattern is valid for dict key
                 #  (keywords are not checked). CIF label is less strict.
                 #  Do we need conversion between CIF and internal label?
-                content_validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_background.id',
-                ]
+                validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
             ),
+            cif_handler=CifHandler(names=['_pd_background.id']),
         )
         self._x = NumericDescriptor(
             name='x',
@@ -64,10 +57,8 @@ def __init__(
                 'representing the background in a calculated diffractogram.'
             ),
             value_spec=AttributeSpec(
-                value=x,
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
+                validator=RangeValidator(),
             ),
             cif_handler=CifHandler(
                 names=[
@@ -83,10 +74,8 @@ def __init__(
                 'representing the background in a calculated diffractogram'
             ),
             value_spec=AttributeSpec(
-                value=y,
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
+                validator=RangeValidator(),
             ),  # TODO: rename to intensity
             cif_handler=CifHandler(
                 names=[
@@ -99,6 +88,10 @@ def __init__(
         self._identity.category_code = 'background'
         self._identity.category_entry_name = lambda: str(self._id.value)
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def id(self):
         return self._id
@@ -124,8 +117,18 @@ def y(self, value):
         self._y.value = value
 
 
+@BackgroundFactory.register
 class LineSegmentBackground(BackgroundBase):
-    _description: str = 'Linear interpolation between points'
+    type_info = TypeInfo(
+        tag='line-segment',
+        description='Linear interpolation between points',
+    )
+    compatibility = Compatibility(
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH, BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
+    )
 
     def __init__(self):
         super().__init__(item_type=LineSegment)
diff --git a/src/easydiffraction/datablocks/experiment/categories/data/__init__.py b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py
new file mode 100644
index 00000000..c228ecd8
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py
@@ -0,0 +1,7 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData
+from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdTofData
+from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData
+from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData
diff --git a/src/easydiffraction/experiments/categories/data/bragg_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py
similarity index 79%
rename from src/easydiffraction/experiments/categories/data/bragg_pd.py
rename to src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py
index 70b0991c..73402d30 100644
--- a/src/easydiffraction/experiments/categories/data/bragg_pd.py
+++ b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py
@@ -7,13 +7,20 @@
 
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import NumericDescriptor
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import MembershipValidator
 from easydiffraction.core.validation import RangeValidator
 from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 from easydiffraction.io.cif.handler import CifHandler
 from easydiffraction.utils.utils import tof_to_d
 from easydiffraction.utils.utils import twotheta_to_d
@@ -22,19 +29,18 @@
 class PdDataPointBaseMixin:
     """Single base data point mixin for powder diffraction data."""
 
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
+    def __init__(self):
+        super().__init__()
 
         self._point_id = StringDescriptor(
             name='point_id',
             description='Identifier for this data point in the dataset.',
             value_spec=AttributeSpec(
-                type_=DataTypes.STRING,
                 default='0',
                 # TODO: the following pattern is valid for dict key
                 #  (keywords are not checked). CIF label is less strict.
                 #  Do we need conversion between CIF and internal label?
-                content_validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
+                validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
             ),
             cif_handler=CifHandler(
                 names=[
@@ -46,23 +52,17 @@ def __init__(self, **kwargs):
             name='d_spacing',
             description='d-spacing value corresponding to this data point.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_proc.d_spacing',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_pd_proc.d_spacing']),
         )
         self._intensity_meas = NumericDescriptor(
             name='intensity_meas',
             description='Intensity recorded at each measurement point as a function of angle/time',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
+                validator=RangeValidator(ge=0),
             ),
             cif_handler=CifHandler(
                 names=[
@@ -75,9 +75,8 @@ def __init__(self, **kwargs):
             name='intensity_meas_su',
             description='Standard uncertainty of the measured intensity at this data point.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=1.0,
-                content_validator=RangeValidator(ge=0),
+                validator=RangeValidator(ge=0),
             ),
             cif_handler=CifHandler(
                 names=[
@@ -90,37 +89,26 @@ def __init__(self, **kwargs):
             name='intensity_calc',
             description='Intensity value for a computed diffractogram at this data point.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_calc.intensity_total',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_pd_calc.intensity_total']),
         )
         self._intensity_bkg = NumericDescriptor(
             name='intensity_bkg',
             description='Intensity value for a computed background at this data point.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_calc.intensity_bkg',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_pd_calc.intensity_bkg']),
         )
         self._calc_status = StringDescriptor(
             name='calc_status',
             description='Status code of the data point in the calculation process.',
             value_spec=AttributeSpec(
-                type_=DataTypes.STRING,
                 default='incl',  # TODO: Make Enum
-                content_validator=MembershipValidator(allowed=['incl', 'excl']),
+                validator=MembershipValidator(allowed=['incl', 'excl']),
             ),
             cif_handler=CifHandler(
                 names=[
@@ -129,6 +117,10 @@ def __init__(self, **kwargs):
             ),
         )
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def point_id(self) -> StringDescriptor:
         return self._point_id
@@ -163,17 +155,17 @@ class PdCwlDataPointMixin:
     wavelength.
     """
 
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
+    def __init__(self):
+        super().__init__()
+
         self._two_theta = NumericDescriptor(
             name='two_theta',
             description='Measured 2θ diffraction angle.',
+            units='deg',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0, le=180),
+                validator=RangeValidator(ge=0, le=180),
             ),
-            units='deg',
             cif_handler=CifHandler(
                 names=[
                     '_pd_proc.2theta_scan',
@@ -182,34 +174,38 @@ def __init__(self, **kwargs):
             ),
         )
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
-    def two_theta(self) -> NumericDescriptor:
+    def two_theta(self):
         return self._two_theta
 
 
 class PdTofDataPointMixin:
     """Mixin for powder diffraction data points with time-of-flight."""
 
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
+    def __init__(self):
+        super().__init__()
+
         self._time_of_flight = NumericDescriptor(
             name='time_of_flight',
             description='Measured time for time-of-flight neutron measurement.',
+            units='µs',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            units='µs',
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_meas.time_of_flight',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_pd_meas.time_of_flight']),
         )
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
-    def time_of_flight(self) -> NumericDescriptor:
+    def time_of_flight(self):
         return self._time_of_flight
 
 
@@ -217,6 +213,14 @@ class PdCwlDataPoint(
     PdDataPointBaseMixin,  # TODO: rename to BasePdDataPointMixin???
     PdCwlDataPointMixin,  # TODO: rename to CwlPdDataPointMixin???
     CategoryItem,  # Must be last to ensure mixins initialized first
+    # TODO: Check this. AI suggest class
+    #  CwlThompsonCoxHastings(
+    #     PeakBase, # From CategoryItem
+    #     CwlBroadeningMixin,
+    #     FcjAsymmetryMixin,
+    #  ):
+    #  But also says, that in fact, it is just for consistency. And both
+    #  orders work.
 ):
     """Powder diffraction data point for constant-wavelength
     experiments.
@@ -317,9 +321,8 @@ def _update(self, called_by_minimizer=False):
         experiment = self._parent
         experiments = experiment._parent
         project = experiments._parent
-        sample_models = project.sample_models
-        # calculator = experiment.calculator  # TODO: move from analysis
-        calculator = project.analysis.calculator
+        structures = project.structures
+        calculator = experiment.calculator
 
         initial_calc = np.zeros_like(self.x)
         calc = initial_calc
@@ -328,19 +331,19 @@ def _update(self, called_by_minimizer=False):
         #  for returning list. Warning message should be defined here,
         #  at least some of them.
         # TODO: Adapt following the _update method in bragg_sc.py
-        for linked_phase in experiment._get_valid_linked_phases(sample_models):
-            sample_model_id = linked_phase._identity.category_entry_name
-            sample_model_scale = linked_phase.scale.value
-            sample_model = sample_models[sample_model_id]
+        for linked_phase in experiment._get_valid_linked_phases(structures):
+            structure_id = linked_phase._identity.category_entry_name
+            structure_scale = linked_phase.scale.value
+            structure = structures[structure_id]
 
-            sample_model_calc = calculator.calculate_pattern(
-                sample_model,
+            structure_calc = calculator.calculate_pattern(
+                structure,
                 experiment,
                 called_by_minimizer=called_by_minimizer,
             )
 
-            sample_model_scaled_calc = sample_model_scale * sample_model_calc
-            calc += sample_model_scaled_calc
+            structure_scaled_calc = structure_scale * structure_calc
+            calc += structure_scaled_calc
 
         self._set_intensity_calc(calc + self.intensity_bkg)
 
@@ -378,7 +381,7 @@ def intensity_meas_su(self) -> np.ndarray:
         #  The current implementation is inefficient.
         #  In the future, we should extend the functionality of
         #  the NumericDescriptor to automatically replace the value
-        #  outside of the valid range (`content_validator`) with a
+        #  outside of the valid range (`validator`) with a
         #  default value (`default`), when the value is set.
         #  BraggPdExperiment._load_ascii_data_to_experiment() handles
         #  this for ASCII data, but we also need to handle CIF data and
@@ -406,10 +409,20 @@ def intensity_bkg(self) -> np.ndarray:
         )
 
 
+@DataFactory.register
 class PdCwlData(PdDataBase):
     # TODO: ???
     # _description: str = 'Powder diffraction data points for
     # constant-wavelength experiments.'
+    type_info = TypeInfo(tag='bragg-pd', description='Bragg powder CWL data')
+    compatibility = Compatibility(
+        sample_form=frozenset({SampleFormEnum.POWDER}),
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH, BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY}),
+    )
 
     def __init__(self):
         super().__init__(item_type=PdCwlDataPoint)
@@ -474,10 +487,17 @@ def unfiltered_x(self) -> np.ndarray:
         )
 
 
+@DataFactory.register
 class PdTofData(PdDataBase):
-    # TODO: ???
-    # _description: str = 'Powder diffraction data points for
-    # time-of-flight experiments.'
+    type_info = TypeInfo(tag='bragg-pd-tof', description='Bragg powder TOF data')
+    compatibility = Compatibility(
+        sample_form=frozenset({SampleFormEnum.POWDER}),
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
+    )
 
     def __init__(self):
         super().__init__(item_type=PdTofDataPoint)
diff --git a/src/easydiffraction/experiments/categories/data/bragg_sc.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py
similarity index 76%
rename from src/easydiffraction/experiments/categories/data/bragg_sc.py
rename to src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py
index c48a15e9..39552b63 100644
--- a/src/easydiffraction/experiments/categories/data/bragg_sc.py
+++ b/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py
@@ -7,12 +7,19 @@
 
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import NumericDescriptor
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
 from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 from easydiffraction.io.cif.handler import CifHandler
 from easydiffraction.utils.logging import log
 from easydiffraction.utils.utils import sin_theta_over_lambda_to_d_spacing
@@ -30,152 +37,106 @@ def __init__(self) -> None:
             name='id',
             description='Identifier of the reflection.',
             value_spec=AttributeSpec(
-                type_=DataTypes.STRING,
                 default='0',
                 # TODO: the following pattern is valid for dict key
                 #  (keywords are not checked). CIF label is less strict.
                 #  Do we need conversion between CIF and internal label?
-                content_validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.id',
-                ]
+                validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
             ),
+            cif_handler=CifHandler(names=['_refln.id']),
         )
         self._d_spacing = NumericDescriptor(
             name='d_spacing',
             description='The distance between lattice planes in the crystal for this reflection.',
+            units='Å',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            units='Å',
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.d_spacing',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_refln.d_spacing']),
         )
         self._sin_theta_over_lambda = NumericDescriptor(
             name='sin_theta_over_lambda',
             description='The sin(θ)/λ value for this reflection.',
+            units='Å⁻¹',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            units='Å⁻¹',
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.sin_theta_over_lambda',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_refln.sin_theta_over_lambda']),
         )
         self._index_h = NumericDescriptor(
             name='index_h',
             description='Miller index h of a measured reflection.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.index_h',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_refln.index_h']),
         )
         self._index_k = NumericDescriptor(
             name='index_k',
             description='Miller index k of a measured reflection.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.index_k',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_refln.index_k']),
         )
         self._index_l = NumericDescriptor(
             name='index_l',
             description='Miller index l of a measured reflection.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.index_l',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_refln.index_l']),
         )
         self._intensity_meas = NumericDescriptor(
             name='intensity_meas',
             description=' The intensity of the reflection derived from the measurements.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.intensity_meas',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_refln.intensity_meas']),
         )
         self._intensity_meas_su = NumericDescriptor(
             name='intensity_meas_su',
             description='Standard uncertainty of the measured intensity.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.intensity_meas_su',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_refln.intensity_meas_su']),
         )
         self._intensity_calc = NumericDescriptor(
             name='intensity_calc',
             description='The intensity of the reflection calculated from the atom site data.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.intensity_calc',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_refln.intensity_calc']),
         )
         self._wavelength = NumericDescriptor(
             name='wavelength',
             description='The mean wavelength of radiation used to measure this reflection.',
+            units='Å',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
-            ),
-            units='Å',
-            cif_handler=CifHandler(
-                names=[
-                    '_refln.wavelength',
-                ]
+                validator=RangeValidator(ge=0),
             ),
+            cif_handler=CifHandler(names=['_refln.wavelength']),
         )
 
         self._identity.category_code = 'refln'
         self._identity.category_entry_name = lambda: str(self.id.value)
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def id(self) -> StringDescriptor:
         return self._id
@@ -217,9 +178,20 @@ def wavelength(self) -> NumericDescriptor:
         return self._wavelength
 
 
+@DataFactory.register
 class ReflnData(CategoryCollection):
     """Collection of reflections for single crystal diffraction data."""
 
+    type_info = TypeInfo(tag='bragg-sc', description='Bragg single-crystal reflection data')
+    compatibility = Compatibility(
+        sample_form=frozenset({SampleFormEnum.SINGLE_CRYSTAL}),
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH, BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY}),
+    )
+
     _update_priority = 100
 
     def __init__(self):
@@ -294,40 +266,39 @@ def _update(self, called_by_minimizer=False):
         experiment = self._parent
         experiments = experiment._parent
         project = experiments._parent
-        sample_models = project.sample_models
-        # calculator = experiment.calculator  # TODO: move from analysis
-        calculator = project.analysis.calculator
+        structures = project.structures
+        calculator = experiment.calculator
 
         linked_crystal = experiment.linked_crystal
         linked_crystal_id = experiment.linked_crystal.id.value
 
-        if linked_crystal_id not in sample_models.names:
+        if linked_crystal_id not in structures.names:
             log.error(
                 f"Linked crystal ID '{linked_crystal_id}' not found in "
-                f'sample model IDs {sample_models.names}.'
+                f'structure IDs {structures.names}.'
             )
             return
 
-        sample_model_id = linked_crystal_id
-        sample_model_scale = linked_crystal.scale.value
-        sample_model = sample_models[sample_model_id]
+        structure_id = linked_crystal_id
+        structure_scale = linked_crystal.scale.value
+        structure = structures[structure_id]
 
         stol, raw_calc = calculator.calculate_structure_factors(
-            sample_model,
+            structure,
             experiment,
             called_by_minimizer=called_by_minimizer,
         )
 
         d_spacing = sin_theta_over_lambda_to_d_spacing(stol)
-        calc = sample_model_scale * raw_calc
+        calc = structure_scale * raw_calc
 
         self._set_d_spacing(d_spacing)
         self._set_sin_theta_over_lambda(stol)
         self._set_intensity_calc(calc)
 
-    ###################
-    # Public properties
-    ###################
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
 
     @property
     def d_spacing(self) -> np.ndarray:
diff --git a/src/easydiffraction/datablocks/experiment/categories/data/factory.py b/src/easydiffraction/datablocks/experiment/categories/data/factory.py
new file mode 100644
index 00000000..1ef25c0b
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/data/factory.py
@@ -0,0 +1,33 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Data collection factory — delegates to ``FactoryBase``."""
+
+from easydiffraction.core.factory import FactoryBase
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+
+
+class DataFactory(FactoryBase):
+    """Factory for creating diffraction data collections."""
+
+    _default_rules = {
+        frozenset({
+            ('sample_form', SampleFormEnum.POWDER),
+            ('scattering_type', ScatteringTypeEnum.BRAGG),
+            ('beam_mode', BeamModeEnum.CONSTANT_WAVELENGTH),
+        }): 'bragg-pd',
+        frozenset({
+            ('sample_form', SampleFormEnum.POWDER),
+            ('scattering_type', ScatteringTypeEnum.BRAGG),
+            ('beam_mode', BeamModeEnum.TIME_OF_FLIGHT),
+        }): 'bragg-pd-tof',
+        frozenset({
+            ('sample_form', SampleFormEnum.POWDER),
+            ('scattering_type', ScatteringTypeEnum.TOTAL),
+        }): 'total-pd',
+        frozenset({
+            ('sample_form', SampleFormEnum.SINGLE_CRYSTAL),
+            ('scattering_type', ScatteringTypeEnum.BRAGG),
+        }): 'bragg-sc',
+    }
diff --git a/src/easydiffraction/experiments/categories/data/total_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py
similarity index 79%
rename from src/easydiffraction/experiments/categories/data/total_pd.py
rename to src/easydiffraction/datablocks/experiment/categories/data/total_pd.py
index 0e43c3a4..0aa63be5 100644
--- a/src/easydiffraction/experiments/categories/data/total_pd.py
+++ b/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py
@@ -8,13 +8,20 @@
 
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import NumericDescriptor
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import MembershipValidator
 from easydiffraction.core.validation import RangeValidator
 from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 from easydiffraction.io.cif.handler import CifHandler
 
 
@@ -32,9 +39,8 @@ def __init__(self) -> None:
             name='point_id',
             description='Identifier for this data point in the dataset.',
             value_spec=AttributeSpec(
-                type_=DataTypes.STRING,
                 default='0',
-                content_validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
+                validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
             ),
             cif_handler=CifHandler(
                 names=[
@@ -45,12 +51,11 @@ def __init__(self) -> None:
         self._r = NumericDescriptor(
             name='r',
             description='Interatomic distance in real space.',
+            units='Å',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
+                validator=RangeValidator(ge=0),
             ),
-            units='Å',
             cif_handler=CifHandler(
                 names=[
                     '_pd_proc.r',  # TODO: Use PDF-specific CIF names
@@ -61,7 +66,6 @@ def __init__(self) -> None:
             name='g_r_meas',
             description='Measured pair distribution function G(r).',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
             ),
             cif_handler=CifHandler(
@@ -74,9 +78,8 @@ def __init__(self) -> None:
             name='g_r_meas_su',
             description='Standard uncertainty of measured G(r).',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(ge=0),
+                validator=RangeValidator(ge=0),
             ),
             cif_handler=CifHandler(
                 names=[
@@ -88,7 +91,6 @@ def __init__(self) -> None:
             name='g_r_calc',
             description='Calculated pair distribution function G(r).',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
             ),
             cif_handler=CifHandler(
@@ -101,9 +103,8 @@ def __init__(self) -> None:
             name='calc_status',
             description='Status code of the data point in calculation.',
             value_spec=AttributeSpec(
-                type_=DataTypes.STRING,
                 default='incl',
-                content_validator=MembershipValidator(allowed=['incl', 'excl']),
+                validator=MembershipValidator(allowed=['incl', 'excl']),
             ),
             cif_handler=CifHandler(
                 names=[
@@ -115,6 +116,10 @@ def __init__(self) -> None:
         self._identity.category_code = 'total_data'
         self._identity.category_entry_name = lambda: str(self.point_id.value)
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def point_id(self) -> StringDescriptor:
         return self._point_id
@@ -202,9 +207,8 @@ def _update(self, called_by_minimizer=False):
         experiment = self._parent
         experiments = experiment._parent
         project = experiments._parent
-        sample_models = project.sample_models
-        # calculator = experiment.calculator  # TODO: move from analysis
-        calculator = project.analysis.calculator
+        structures = project.structures
+        calculator = experiment.calculator
 
         initial_calc = np.zeros_like(self.x)
         calc = initial_calc
@@ -213,25 +217,25 @@ def _update(self, called_by_minimizer=False):
         #  for returning list. Warning message should be defined here,
         #  at least some of them.
         # TODO: Adapt following the _update method in bragg_sc.py
-        for linked_phase in experiment._get_valid_linked_phases(sample_models):
-            sample_model_id = linked_phase._identity.category_entry_name
-            sample_model_scale = linked_phase.scale.value
-            sample_model = sample_models[sample_model_id]
+        for linked_phase in experiment._get_valid_linked_phases(structures):
+            structure_id = linked_phase._identity.category_entry_name
+            structure_scale = linked_phase.scale.value
+            structure = structures[structure_id]
 
-            sample_model_calc = calculator.calculate_pattern(
-                sample_model,
+            structure_calc = calculator.calculate_pattern(
+                structure,
                 experiment,
                 called_by_minimizer=called_by_minimizer,
             )
 
-            sample_model_scaled_calc = sample_model_scale * sample_model_calc
-            calc += sample_model_scaled_calc
+            structure_scaled_calc = structure_scale * structure_calc
+            calc += structure_scaled_calc
 
         self._set_g_r_calc(calc)
 
-    ###################
-    # Public properties
-    ###################
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
 
     @property
     def calc_status(self) -> np.ndarray:
@@ -267,6 +271,7 @@ def intensity_bkg(self) -> np.ndarray:
         return np.zeros_like(self.intensity_calc)
 
 
+@DataFactory.register
 class TotalData(TotalDataBase):
     """Total scattering (PDF) data collection in r-space.
 
@@ -274,6 +279,16 @@ class TotalData(TotalDataBase):
     is always transformed to r-space.
     """
 
+    type_info = TypeInfo(tag='total-pd', description='Total scattering (PDF) data')
+    compatibility = Compatibility(
+        sample_form=frozenset({SampleFormEnum.POWDER}),
+        scattering_type=frozenset({ScatteringTypeEnum.TOTAL}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH, BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.PDFFIT}),
+    )
+
     def __init__(self):
         super().__init__(item_type=TotalDataPoint)
 
@@ -297,9 +312,9 @@ def _create_items_set_xcoord_and_id(self, values) -> None:
         # Set point IDs
         self._set_point_id([str(i + 1) for i in range(values.size)])
 
-    ###################
-    # Public properties
-    ###################
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
 
     @property
     def x(self) -> np.ndarray:
diff --git a/src/easydiffraction/datablocks/experiment/categories/excluded_regions/__init__.py b/src/easydiffraction/datablocks/experiment/categories/excluded_regions/__init__.py
new file mode 100644
index 00000000..3356f4cf
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/excluded_regions/__init__.py
@@ -0,0 +1,9 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.excluded_regions.default import (
+    ExcludedRegion,
+)
+from easydiffraction.datablocks.experiment.categories.excluded_regions.default import (
+    ExcludedRegions,
+)
diff --git a/src/easydiffraction/experiments/categories/excluded_regions.py b/src/easydiffraction/datablocks/experiment/categories/excluded_regions/default.py
similarity index 76%
rename from src/easydiffraction/experiments/categories/excluded_regions.py
rename to src/easydiffraction/datablocks/experiment/categories/excluded_regions/default.py
index c882fad0..2696f25c 100644
--- a/src/easydiffraction/experiments/categories/excluded_regions.py
+++ b/src/easydiffraction/datablocks/experiment/categories/excluded_regions/default.py
@@ -2,18 +2,25 @@
 # SPDX-License-Identifier: BSD-3-Clause
 """Exclude ranges of x from fitting/plotting (masked regions)."""
 
+from __future__ import annotations
+
 from typing import List
 
 import numpy as np
 
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import NumericDescriptor
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
 from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.categories.excluded_regions.factory import (
+    ExcludedRegionsFactory,
+)
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
 from easydiffraction.io.cif.handler import CifHandler
 from easydiffraction.utils.logging import console
 from easydiffraction.utils.utils import render_table
@@ -22,13 +29,7 @@
 class ExcludedRegion(CategoryItem):
     """Closed interval [start, end] to be excluded."""
 
-    def __init__(
-        self,
-        *,
-        id=None,  # TODO: rename as in the case of data points?
-        start=None,
-        end=None,
-    ):
+    def __init__(self):
         super().__init__()
 
         # TODO: Add point_id as for the background
@@ -36,56 +37,39 @@ def __init__(
             name='id',
             description='Identifier for this excluded region.',
             value_spec=AttributeSpec(
-                type_=DataTypes.STRING,
-                value=id,
                 default='0',
                 # TODO: the following pattern is valid for dict key
                 #  (keywords are not checked). CIF label is less strict.
                 #  Do we need conversion between CIF and internal label?
-                content_validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_excluded_region.id',
-                ]
+                validator=RegexValidator(pattern=r'^[A-Za-z0-9_]*$'),
             ),
+            cif_handler=CifHandler(names=['_excluded_region.id']),
         )
         self._start = NumericDescriptor(
             name='start',
             description='Start of the excluded region.',
             value_spec=AttributeSpec(
-                value=start,
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_excluded_region.start',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_excluded_region.start']),
         )
         self._end = NumericDescriptor(
             name='end',
             description='End of the excluded region.',
             value_spec=AttributeSpec(
-                value=end,
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_excluded_region.end',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_excluded_region.end']),
         )
-        # self._category_entry_attr_name = f'{start}-{end}'
-        # self._category_entry_attr_name = self.start.name
-        # self.name = self.start.value
         self._identity.category_code = 'excluded_regions'
         self._identity.category_entry_name = lambda: str(self._id.value)
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def id(self):
         return self._id
@@ -111,6 +95,7 @@ def end(self, value: float):
         self._end.value = value
 
 
+@ExcludedRegionsFactory.register
 class ExcludedRegions(CategoryCollection):
     """Collection of ExcludedRegion instances.
 
@@ -119,6 +104,14 @@ class ExcludedRegions(CategoryCollection):
     fitting and plotting.
     """
 
+    type_info = TypeInfo(
+        tag='default',
+        description='Excluded x-axis regions for fitting and plotting',
+    )
+    compatibility = Compatibility(
+        sample_form=frozenset({SampleFormEnum.POWDER}),
+    )
+
     def __init__(self):
         super().__init__(item_type=ExcludedRegion)
 
diff --git a/src/easydiffraction/datablocks/experiment/categories/excluded_regions/factory.py b/src/easydiffraction/datablocks/experiment/categories/excluded_regions/factory.py
new file mode 100644
index 00000000..789e25e7
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/excluded_regions/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Excluded-regions factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class ExcludedRegionsFactory(FactoryBase):
+    """Create excluded-regions collections by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/datablocks/experiment/categories/experiment_type/__init__.py b/src/easydiffraction/datablocks/experiment/categories/experiment_type/__init__.py
new file mode 100644
index 00000000..63e6bb0b
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/experiment_type/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.experiment_type.default import ExperimentType
diff --git a/src/easydiffraction/experiments/categories/experiment_type.py b/src/easydiffraction/datablocks/experiment/categories/experiment_type/default.py
similarity index 51%
rename from src/easydiffraction/experiments/categories/experiment_type.py
rename to src/easydiffraction/datablocks/experiment/categories/experiment_type/default.py
index babe82ee..5221e444 100644
--- a/src/easydiffraction/experiments/categories/experiment_type.py
+++ b/src/easydiffraction/datablocks/experiment/categories/experiment_type/default.py
@@ -7,18 +7,24 @@
 ``CifHandler``.
 """
 
+from __future__ import annotations
+
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import MembershipValidator
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import RadiationProbeEnum
-from easydiffraction.experiments.experiment.enums import SampleFormEnum
-from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.categories.experiment_type.factory import (
+    ExperimentTypeFactory,
+)
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 from easydiffraction.io.cif.handler import CifHandler
 
 
+@ExperimentTypeFactory.register
 class ExperimentType(CategoryItem):
     """Container of categorical attributes defining experiment flavor.
 
@@ -29,128 +35,94 @@ class ExperimentType(CategoryItem):
         scattering_type: Bragg or Total.
     """
 
-    def __init__(
-        self,
-        *,
-        sample_form=None,
-        beam_mode=None,
-        radiation_probe=None,
-        scattering_type=None,
-    ):
+    type_info = TypeInfo(
+        tag='default',
+        description='Experiment type descriptor',
+    )
+
+    def __init__(self):
         super().__init__()
 
-        self._sample_form: StringDescriptor = StringDescriptor(
+        self._sample_form = StringDescriptor(
             name='sample_form',
             description='Specifies whether the diffraction data corresponds to '
             'powder diffraction or single crystal diffraction',
             value_spec=AttributeSpec(
-                value=sample_form,
-                type_=DataTypes.STRING,
                 default=SampleFormEnum.default().value,
-                content_validator=MembershipValidator(
-                    allowed=[member.value for member in SampleFormEnum]
-                ),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_expt_type.sample_form',
-                ]
+                validator=MembershipValidator(allowed=[member.value for member in SampleFormEnum]),
             ),
+            cif_handler=CifHandler(names=['_expt_type.sample_form']),
         )
 
-        self._beam_mode: StringDescriptor = StringDescriptor(
+        self._beam_mode = StringDescriptor(
             name='beam_mode',
             description='Defines whether the measurement is performed with a '
             'constant wavelength (CW) or time-of-flight (TOF) method',
             value_spec=AttributeSpec(
-                value=beam_mode,
-                type_=DataTypes.STRING,
                 default=BeamModeEnum.default().value,
-                content_validator=MembershipValidator(
-                    allowed=[member.value for member in BeamModeEnum]
-                ),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_expt_type.beam_mode',
-                ]
+                validator=MembershipValidator(allowed=[member.value for member in BeamModeEnum]),
             ),
+            cif_handler=CifHandler(names=['_expt_type.beam_mode']),
         )
-        self._radiation_probe: StringDescriptor = StringDescriptor(
+        self._radiation_probe = StringDescriptor(
             name='radiation_probe',
             description='Specifies whether the measurement uses neutrons or X-rays',
             value_spec=AttributeSpec(
-                value=radiation_probe,
-                type_=DataTypes.STRING,
                 default=RadiationProbeEnum.default().value,
-                content_validator=MembershipValidator(
+                validator=MembershipValidator(
                     allowed=[member.value for member in RadiationProbeEnum]
                 ),
             ),
-            cif_handler=CifHandler(
-                names=[
-                    '_expt_type.radiation_probe',
-                ]
-            ),
+            cif_handler=CifHandler(names=['_expt_type.radiation_probe']),
         )
-        self._scattering_type: StringDescriptor = StringDescriptor(
+        self._scattering_type = StringDescriptor(
             name='scattering_type',
             description='Specifies whether the experiment uses Bragg scattering '
             '(for conventional structure refinement) or total scattering '
             '(for pair distribution function analysis - PDF)',
             value_spec=AttributeSpec(
-                value=scattering_type,
-                type_=DataTypes.STRING,
                 default=ScatteringTypeEnum.default().value,
-                content_validator=MembershipValidator(
+                validator=MembershipValidator(
                     allowed=[member.value for member in ScatteringTypeEnum]
                 ),
             ),
-            cif_handler=CifHandler(
-                names=[
-                    '_expt_type.scattering_type',
-                ]
-            ),
+            cif_handler=CifHandler(names=['_expt_type.scattering_type']),
         )
 
         self._identity.category_code = 'expt_type'
 
+    # ------------------------------------------------------------------
+    #  Private setters (used by factories and loaders only)
+    # ------------------------------------------------------------------
+
+    def _set_sample_form(self, value: str) -> None:
+        self._sample_form.value = value
+
+    def _set_beam_mode(self, value: str) -> None:
+        self._beam_mode.value = value
+
+    def _set_radiation_probe(self, value: str) -> None:
+        self._radiation_probe.value = value
+
+    def _set_scattering_type(self, value: str) -> None:
+        self._scattering_type.value = value
+
+    # ------------------------------------------------------------------
+    #  Public read-only properties
+    # ------------------------------------------------------------------
+
     @property
     def sample_form(self):
-        """Sample form descriptor (powder/single crystal)."""
         return self._sample_form
 
-    @sample_form.setter
-    def sample_form(self, value):
-        """Set sample form value."""
-        self._sample_form.value = value
-
     @property
     def beam_mode(self):
-        """Beam mode descriptor (CW/TOF)."""
         return self._beam_mode
 
-    @beam_mode.setter
-    def beam_mode(self, value):
-        """Set beam mode value."""
-        self._beam_mode.value = value
-
     @property
     def radiation_probe(self):
-        """Radiation probe descriptor (neutrons/X-rays)."""
         return self._radiation_probe
 
-    @radiation_probe.setter
-    def radiation_probe(self, value):
-        """Set radiation probe value."""
-        self._radiation_probe.value = value
-
     @property
     def scattering_type(self):
-        """Scattering type descriptor (Bragg/Total)."""
         return self._scattering_type
-
-    @scattering_type.setter
-    def scattering_type(self, value):
-        """Set scattering type value."""
-        self._scattering_type.value = value
diff --git a/src/easydiffraction/datablocks/experiment/categories/experiment_type/factory.py b/src/easydiffraction/datablocks/experiment/categories/experiment_type/factory.py
new file mode 100644
index 00000000..bf78fb53
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/experiment_type/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Experiment-type factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class ExperimentTypeFactory(FactoryBase):
+    """Create experiment-type descriptors by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/datablocks/experiment/categories/extinction/__init__.py b/src/easydiffraction/datablocks/experiment/categories/extinction/__init__.py
new file mode 100644
index 00000000..f3d62fad
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/extinction/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.extinction.shelx import ShelxExtinction
diff --git a/src/easydiffraction/datablocks/experiment/categories/extinction/factory.py b/src/easydiffraction/datablocks/experiment/categories/extinction/factory.py
new file mode 100644
index 00000000..fbeb32e7
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/extinction/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Extinction factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class ExtinctionFactory(FactoryBase):
+    """Create extinction correction models by tag."""
+
+    _default_rules = {
+        frozenset(): 'shelx',
+    }
diff --git a/src/easydiffraction/experiments/categories/extinction.py b/src/easydiffraction/datablocks/experiment/categories/extinction/shelx.py
similarity index 59%
rename from src/easydiffraction/experiments/categories/extinction.py
rename to src/easydiffraction/datablocks/experiment/categories/extinction/shelx.py
index 329f3ca5..67dfaf94 100644
--- a/src/easydiffraction/experiments/categories/extinction.py
+++ b/src/easydiffraction/datablocks/experiment/categories/extinction/shelx.py
@@ -1,16 +1,33 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
+"""Shelx-style isotropic extinction correction."""
+
+from __future__ import annotations
 
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import Parameter
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
+from easydiffraction.core.variable import Parameter
+from easydiffraction.datablocks.experiment.categories.extinction.factory import ExtinctionFactory
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
 from easydiffraction.io.cif.handler import CifHandler
 
 
-class Extinction(CategoryItem):
-    """Extinction correction category for single crystals."""
+@ExtinctionFactory.register
+class ShelxExtinction(CategoryItem):
+    """Shelx-style isotropic extinction correction for single
+    crystals.
+    """
+
+    type_info = TypeInfo(
+        tag='shelx',
+        description='Shelx-style isotropic extinction correction',
+    )
+    compatibility = Compatibility(
+        sample_form=frozenset({SampleFormEnum.SINGLE_CRYSTAL}),
+    )
 
     def __init__(self) -> None:
         super().__init__()
@@ -18,12 +35,11 @@ def __init__(self) -> None:
         self._mosaicity = Parameter(
             name='mosaicity',
             description='Mosaicity value for extinction correction.',
+            units='deg',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=1.0,
-                content_validator=RangeValidator(),
+                validator=RangeValidator(),
             ),
-            units='deg',
             cif_handler=CifHandler(
                 names=[
                     '_extinction.mosaicity',
@@ -33,12 +49,11 @@ def __init__(self) -> None:
         self._radius = Parameter(
             name='radius',
             description='Crystal radius for extinction correction.',
+            units='µm',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=1.0,
-                content_validator=RangeValidator(),
+                validator=RangeValidator(),
             ),
-            units='µm',
             cif_handler=CifHandler(
                 names=[
                     '_extinction.radius',
@@ -48,6 +63,10 @@ def __init__(self) -> None:
 
         self._identity.category_code = 'extinction'
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def mosaicity(self):
         return self._mosaicity
diff --git a/src/easydiffraction/datablocks/experiment/categories/instrument/__init__.py b/src/easydiffraction/datablocks/experiment/categories/instrument/__init__.py
new file mode 100644
index 00000000..e4a03696
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/instrument/__init__.py
@@ -0,0 +1,7 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.instrument.cwl import CwlPdInstrument
+from easydiffraction.datablocks.experiment.categories.instrument.cwl import CwlScInstrument
+from easydiffraction.datablocks.experiment.categories.instrument.tof import TofPdInstrument
+from easydiffraction.datablocks.experiment.categories.instrument.tof import TofScInstrument
diff --git a/src/easydiffraction/experiments/categories/instrument/base.py b/src/easydiffraction/datablocks/experiment/categories/instrument/base.py
similarity index 100%
rename from src/easydiffraction/experiments/categories/instrument/base.py
rename to src/easydiffraction/datablocks/experiment/categories/instrument/base.py
diff --git a/src/easydiffraction/experiments/categories/instrument/cwl.py b/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py
similarity index 53%
rename from src/easydiffraction/experiments/categories/instrument/cwl.py
rename to src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py
index 8c714c11..924158a0 100644
--- a/src/easydiffraction/experiments/categories/instrument/cwl.py
+++ b/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py
@@ -1,11 +1,18 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.core.parameters import Parameter
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
-from easydiffraction.experiments.categories.instrument.base import InstrumentBase
+from easydiffraction.core.variable import Parameter
+from easydiffraction.datablocks.experiment.categories.instrument.base import InstrumentBase
+from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 from easydiffraction.io.cif.handler import CifHandler
 
 
@@ -16,12 +23,11 @@ def __init__(self) -> None:
         self._setup_wavelength: Parameter = Parameter(
             name='wavelength',
             description='Incident neutron or X-ray wavelength',
+            units='Å',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=1.5406,
-                content_validator=RangeValidator(),
+                validator=RangeValidator(),
             ),
-            units='Å',
             cif_handler=CifHandler(
                 names=[
                     '_instr.wavelength',
@@ -40,24 +46,49 @@ def setup_wavelength(self, value):
         self._setup_wavelength.value = value
 
 
+@InstrumentFactory.register
 class CwlScInstrument(CwlInstrumentBase):
+    type_info = TypeInfo(tag='cwl-sc', description='CW single-crystal diffractometer')
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
+        sample_form=frozenset({SampleFormEnum.SINGLE_CRYSTAL}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY}),
+    )
+
     def __init__(self) -> None:
         super().__init__()
 
 
+@InstrumentFactory.register
 class CwlPdInstrument(CwlInstrumentBase):
+    type_info = TypeInfo(tag='cwl-pd', description='CW powder diffractometer')
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG, ScatteringTypeEnum.TOTAL}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
+        sample_form=frozenset({SampleFormEnum.POWDER}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({
+            CalculatorEnum.CRYSPY,
+            CalculatorEnum.CRYSFML,
+            CalculatorEnum.PDFFIT,
+        }),
+    )
+
     def __init__(self) -> None:
         super().__init__()
 
         self._calib_twotheta_offset: Parameter = Parameter(
             name='twotheta_offset',
             description='Instrument misalignment offset',
+            units='deg',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
+                validator=RangeValidator(),
             ),
-            units='deg',
             cif_handler=CifHandler(
                 names=[
                     '_instr.2theta_offset',
diff --git a/src/easydiffraction/datablocks/experiment/categories/instrument/factory.py b/src/easydiffraction/datablocks/experiment/categories/instrument/factory.py
new file mode 100644
index 00000000..7d4286af
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/instrument/factory.py
@@ -0,0 +1,30 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Instrument factory — delegates to ``FactoryBase``."""
+
+from easydiffraction.core.factory import FactoryBase
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+
+
+class InstrumentFactory(FactoryBase):
+    """Create instrument instances for supported modes."""
+
+    _default_rules = {
+        frozenset({
+            ('beam_mode', BeamModeEnum.CONSTANT_WAVELENGTH),
+            ('sample_form', SampleFormEnum.POWDER),
+        }): 'cwl-pd',
+        frozenset({
+            ('beam_mode', BeamModeEnum.CONSTANT_WAVELENGTH),
+            ('sample_form', SampleFormEnum.SINGLE_CRYSTAL),
+        }): 'cwl-sc',
+        frozenset({
+            ('beam_mode', BeamModeEnum.TIME_OF_FLIGHT),
+            ('sample_form', SampleFormEnum.POWDER),
+        }): 'tof-pd',
+        frozenset({
+            ('beam_mode', BeamModeEnum.TIME_OF_FLIGHT),
+            ('sample_form', SampleFormEnum.SINGLE_CRYSTAL),
+        }): 'tof-sc',
+    }
diff --git a/src/easydiffraction/experiments/categories/instrument/tof.py b/src/easydiffraction/datablocks/experiment/categories/instrument/tof.py
similarity index 56%
rename from src/easydiffraction/experiments/categories/instrument/tof.py
rename to src/easydiffraction/datablocks/experiment/categories/instrument/tof.py
index 0b3d6558..efbff40d 100644
--- a/src/easydiffraction/experiments/categories/instrument/tof.py
+++ b/src/easydiffraction/datablocks/experiment/categories/instrument/tof.py
@@ -1,145 +1,139 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.core.parameters import Parameter
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
-from easydiffraction.experiments.categories.instrument.base import InstrumentBase
+from easydiffraction.core.variable import Parameter
+from easydiffraction.datablocks.experiment.categories.instrument.base import InstrumentBase
+from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 from easydiffraction.io.cif.handler import CifHandler
 
 
+@InstrumentFactory.register
 class TofScInstrument(InstrumentBase):
+    type_info = TypeInfo(tag='tof-sc', description='TOF single-crystal diffractometer')
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.TIME_OF_FLIGHT}),
+        sample_form=frozenset({SampleFormEnum.SINGLE_CRYSTAL}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY}),
+    )
+
     def __init__(self) -> None:
         super().__init__()
 
 
+@InstrumentFactory.register
 class TofPdInstrument(InstrumentBase):
+    type_info = TypeInfo(tag='tof-pd', description='TOF powder diffractometer')
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.TIME_OF_FLIGHT}),
+        sample_form=frozenset({SampleFormEnum.POWDER}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
+    )
+
     def __init__(self) -> None:
         super().__init__()
 
         self._setup_twotheta_bank: Parameter = Parameter(
             name='twotheta_bank',
             description='Detector bank position',
+            units='deg',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=150.0,
-                content_validator=RangeValidator(),
-            ),
-            units='deg',
-            cif_handler=CifHandler(
-                names=[
-                    '_instr.2theta_bank',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_instr.2theta_bank']),
         )
         self._calib_d_to_tof_offset: Parameter = Parameter(
             name='d_to_tof_offset',
             description='TOF offset',
+            units='µs',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='µs',
-            cif_handler=CifHandler(
-                names=[
-                    '_instr.d_to_tof_offset',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_instr.d_to_tof_offset']),
         )
         self._calib_d_to_tof_linear: Parameter = Parameter(
             name='d_to_tof_linear',
             description='TOF linear conversion',
+            units='µs/Å',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=10000.0,
-                content_validator=RangeValidator(),
-            ),
-            units='µs/Å',
-            cif_handler=CifHandler(
-                names=[
-                    '_instr.d_to_tof_linear',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_instr.d_to_tof_linear']),
         )
         self._calib_d_to_tof_quad: Parameter = Parameter(
             name='d_to_tof_quad',
             description='TOF quadratic correction',
-            value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
-                default=-0.00001,
-                content_validator=RangeValidator(),
-            ),
             units='µs/Ų',
-            cif_handler=CifHandler(
-                names=[
-                    '_instr.d_to_tof_quad',
-                ]
+            value_spec=AttributeSpec(
+                default=-0.00001,  # TODO: Fix CrysPy to accept 0
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_instr.d_to_tof_quad']),
         )
         self._calib_d_to_tof_recip: Parameter = Parameter(
             name='d_to_tof_recip',
             description='TOF reciprocal velocity correction',
+            units='µs·Å',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='µs·Å',
-            cif_handler=CifHandler(
-                names=[
-                    '_instr.d_to_tof_recip',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_instr.d_to_tof_recip']),
         )
 
     @property
     def setup_twotheta_bank(self):
-        """Detector bank two-theta position (deg)."""
         return self._setup_twotheta_bank
 
     @setup_twotheta_bank.setter
     def setup_twotheta_bank(self, value):
-        """Set detector bank two-theta position (deg)."""
         self._setup_twotheta_bank.value = value
 
     @property
     def calib_d_to_tof_offset(self):
-        """TOF offset calibration parameter (µs)."""
         return self._calib_d_to_tof_offset
 
     @calib_d_to_tof_offset.setter
     def calib_d_to_tof_offset(self, value):
-        """Set TOF offset (µs)."""
         self._calib_d_to_tof_offset.value = value
 
     @property
     def calib_d_to_tof_linear(self):
-        """Linear d to TOF conversion coefficient (µs/Å)."""
         return self._calib_d_to_tof_linear
 
     @calib_d_to_tof_linear.setter
     def calib_d_to_tof_linear(self, value):
-        """Set linear d to TOF coefficient (µs/Å)."""
         self._calib_d_to_tof_linear.value = value
 
     @property
     def calib_d_to_tof_quad(self):
-        """Quadratic d to TOF correction coefficient (µs/Ų)."""
         return self._calib_d_to_tof_quad
 
     @calib_d_to_tof_quad.setter
     def calib_d_to_tof_quad(self, value):
-        """Set quadratic d to TOF correction (µs/Ų)."""
         self._calib_d_to_tof_quad.value = value
 
     @property
     def calib_d_to_tof_recip(self):
-        """Reciprocal-velocity d to TOF correction (µs·Å)."""
         return self._calib_d_to_tof_recip
 
     @calib_d_to_tof_recip.setter
     def calib_d_to_tof_recip(self, value):
-        """Set reciprocal-velocity d to TOF correction (µs·Å)."""
         self._calib_d_to_tof_recip.value = value
diff --git a/src/easydiffraction/datablocks/experiment/categories/linked_crystal/__init__.py b/src/easydiffraction/datablocks/experiment/categories/linked_crystal/__init__.py
new file mode 100644
index 00000000..1a6b0b67
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/linked_crystal/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.linked_crystal.default import LinkedCrystal
diff --git a/src/easydiffraction/experiments/categories/linked_crystal.py b/src/easydiffraction/datablocks/experiment/categories/linked_crystal/default.py
similarity index 54%
rename from src/easydiffraction/experiments/categories/linked_crystal.py
rename to src/easydiffraction/datablocks/experiment/categories/linked_crystal/default.py
index 81417de2..e1fa8b6a 100644
--- a/src/easydiffraction/experiments/categories/linked_crystal.py
+++ b/src/easydiffraction/datablocks/experiment/categories/linked_crystal/default.py
@@ -1,21 +1,38 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
+"""Default linked-crystal reference (id + scale)."""
+
+from __future__ import annotations
 
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
 from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import Parameter
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.categories.linked_crystal.factory import (
+    LinkedCrystalFactory,
+)
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
 from easydiffraction.io.cif.handler import CifHandler
 
 
+@LinkedCrystalFactory.register
 class LinkedCrystal(CategoryItem):
     """Linked crystal category for referencing from the experiment for
     single crystal diffraction.
     """
 
+    type_info = TypeInfo(
+        tag='default',
+        description='Crystal reference with id and scale factor',
+    )
+    compatibility = Compatibility(
+        sample_form=frozenset({SampleFormEnum.SINGLE_CRYSTAL}),
+    )
+
     def __init__(self) -> None:
         super().__init__()
 
@@ -23,49 +40,39 @@ def __init__(self) -> None:
             name='id',
             description='Identifier of the linked crystal.',
             value_spec=AttributeSpec(
-                type_=DataTypes.STRING,
                 default='Si',
-                content_validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_sc_crystal_block.id',
-                ]
+                validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
             ),
+            cif_handler=CifHandler(names=['_sc_crystal_block.id']),
         )
         self._scale = Parameter(
             name='scale',
             description='Scale factor of the linked crystal.',
             value_spec=AttributeSpec(
-                type_=DataTypes.NUMERIC,
                 default=1.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_sc_crystal_block.scale',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_sc_crystal_block.scale']),
         )
 
         self._identity.category_code = 'linked_crystal'
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def id(self) -> StringDescriptor:
-        """Identifier of the linked crystal."""
         return self._id
 
     @id.setter
     def id(self, value: str):
-        """Set the linked crystal identifier."""
         self._id.value = value
 
     @property
     def scale(self) -> Parameter:
-        """Scale factor parameter."""
         return self._scale
 
     @scale.setter
     def scale(self, value: float):
-        """Set scale factor value."""
         self._scale.value = value
diff --git a/src/easydiffraction/datablocks/experiment/categories/linked_crystal/factory.py b/src/easydiffraction/datablocks/experiment/categories/linked_crystal/factory.py
new file mode 100644
index 00000000..49ac1a64
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/linked_crystal/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Linked-crystal factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class LinkedCrystalFactory(FactoryBase):
+    """Create linked-crystal references by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/datablocks/experiment/categories/linked_phases/__init__.py b/src/easydiffraction/datablocks/experiment/categories/linked_phases/__init__.py
new file mode 100644
index 00000000..6dd96b94
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/linked_phases/__init__.py
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.linked_phases.default import LinkedPhase
+from easydiffraction.datablocks.experiment.categories.linked_phases.default import LinkedPhases
diff --git a/src/easydiffraction/experiments/categories/linked_phases.py b/src/easydiffraction/datablocks/experiment/categories/linked_phases/default.py
similarity index 60%
rename from src/easydiffraction/experiments/categories/linked_phases.py
rename to src/easydiffraction/datablocks/experiment/categories/linked_phases/default.py
index 497ef69c..067683a9 100644
--- a/src/easydiffraction/experiments/categories/linked_phases.py
+++ b/src/easydiffraction/datablocks/experiment/categories/linked_phases/default.py
@@ -2,86 +2,85 @@
 # SPDX-License-Identifier: BSD-3-Clause
 """Linked phases allow combining phases with scale factors."""
 
+from __future__ import annotations
+
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.core.validation import RangeValidator
 from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import Parameter
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.experiment.categories.linked_phases.factory import (
+    LinkedPhasesFactory,
+)
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
 from easydiffraction.io.cif.handler import CifHandler
 
 
 class LinkedPhase(CategoryItem):
     """Link to a phase by id with a scale factor."""
 
-    def __init__(
-        self,
-        *,
-        id=None,  # TODO: need new name instead of id
-        scale=None,
-    ):
+    def __init__(self):
         super().__init__()
 
         self._id = StringDescriptor(
             name='id',
             description='Identifier of the linked phase.',
             value_spec=AttributeSpec(
-                value=id,
-                type_=DataTypes.STRING,
                 default='Si',
-                content_validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_phase_block.id',
-                ]
+                validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
             ),
+            cif_handler=CifHandler(names=['_pd_phase_block.id']),
         )
         self._scale = Parameter(
             name='scale',
             description='Scale factor of the linked phase.',
             value_spec=AttributeSpec(
-                value=scale,
-                type_=DataTypes.NUMERIC,
                 default=1.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_pd_phase_block.scale',
-                ]
+                validator=RangeValidator(),
             ),
+            cif_handler=CifHandler(names=['_pd_phase_block.scale']),
         )
 
         self._identity.category_code = 'linked_phases'
         self._identity.category_entry_name = lambda: str(self.id.value)
 
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
     @property
     def id(self) -> StringDescriptor:
-        """Identifier of the linked phase."""
         return self._id
 
     @id.setter
     def id(self, value: str):
-        """Set the linked phase identifier."""
         self._id.value = value
 
     @property
     def scale(self) -> Parameter:
-        """Scale factor parameter."""
         return self._scale
 
     @scale.setter
     def scale(self, value: float):
-        """Set scale factor value."""
         self._scale.value = value
 
 
+@LinkedPhasesFactory.register
 class LinkedPhases(CategoryCollection):
     """Collection of LinkedPhase instances."""
 
+    type_info = TypeInfo(
+        tag='default',
+        description='Phase references with scale factors',
+    )
+    compatibility = Compatibility(
+        sample_form=frozenset({SampleFormEnum.POWDER}),
+    )
+
     def __init__(self):
         """Create an empty collection of linked phases."""
         super().__init__(item_type=LinkedPhase)
diff --git a/src/easydiffraction/datablocks/experiment/categories/linked_phases/factory.py b/src/easydiffraction/datablocks/experiment/categories/linked_phases/factory.py
new file mode 100644
index 00000000..74f16616
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/linked_phases/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Linked-phases factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class LinkedPhasesFactory(FactoryBase):
+    """Create linked-phases collections by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/__init__.py b/src/easydiffraction/datablocks/experiment/categories/peak/__init__.py
new file mode 100644
index 00000000..b335b9d3
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/peak/__init__.py
@@ -0,0 +1,10 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.peak.cwl import CwlPseudoVoigt
+from easydiffraction.datablocks.experiment.categories.peak.cwl import CwlSplitPseudoVoigt
+from easydiffraction.datablocks.experiment.categories.peak.cwl import CwlThompsonCoxHastings
+from easydiffraction.datablocks.experiment.categories.peak.tof import TofPseudoVoigt
+from easydiffraction.datablocks.experiment.categories.peak.tof import TofPseudoVoigtBackToBack
+from easydiffraction.datablocks.experiment.categories.peak.tof import TofPseudoVoigtIkedaCarpenter
+from easydiffraction.datablocks.experiment.categories.peak.total import TotalGaussianDampedSinc
diff --git a/src/easydiffraction/experiments/categories/peak/base.py b/src/easydiffraction/datablocks/experiment/categories/peak/base.py
similarity index 100%
rename from src/easydiffraction/experiments/categories/peak/base.py
rename to src/easydiffraction/datablocks/experiment/categories/peak/base.py
diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/cwl.py b/src/easydiffraction/datablocks/experiment/categories/peak/cwl.py
new file mode 100644
index 00000000..aa03c80c
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/peak/cwl.py
@@ -0,0 +1,85 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Constant-wavelength peak profile classes."""
+
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.datablocks.experiment.categories.peak.base import PeakBase
+from easydiffraction.datablocks.experiment.categories.peak.cwl_mixins import CwlBroadeningMixin
+from easydiffraction.datablocks.experiment.categories.peak.cwl_mixins import (
+    EmpiricalAsymmetryMixin,
+)
+from easydiffraction.datablocks.experiment.categories.peak.cwl_mixins import FcjAsymmetryMixin
+from easydiffraction.datablocks.experiment.categories.peak.factory import PeakFactory
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+
+
+@PeakFactory.register
+class CwlPseudoVoigt(
+    PeakBase,
+    CwlBroadeningMixin,
+):
+    """Constant-wavelength pseudo-Voigt peak shape."""
+
+    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}),
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
+
+
+@PeakFactory.register
+class CwlSplitPseudoVoigt(
+    PeakBase,
+    CwlBroadeningMixin,
+    EmpiricalAsymmetryMixin,
+):
+    """Split pseudo-Voigt (empirical asymmetry) for CWL mode."""
+
+    type_info = TypeInfo(
+        tag='split pseudo-voigt',
+        description='Split pseudo-Voigt with empirical asymmetry correction',
+    )
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
+
+
+@PeakFactory.register
+class CwlThompsonCoxHastings(
+    PeakBase,
+    CwlBroadeningMixin,
+    FcjAsymmetryMixin,
+):
+    """Thompson–Cox–Hastings with FCJ asymmetry for CWL mode."""
+
+    type_info = TypeInfo(
+        tag='thompson-cox-hastings',
+        description='Thompson-Cox-Hastings with FCJ asymmetry correction',
+    )
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py b/src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py
new file mode 100644
index 00000000..2bc9c178
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py
@@ -0,0 +1,249 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Constant-wavelength (CWL) peak-profile component classes.
+
+This module provides classes that add broadening and asymmetry
+parameters. They are composed into concrete peak classes elsewhere via
+multiple inheritance.
+"""
+
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.validation import RangeValidator
+from easydiffraction.core.variable import Parameter
+from easydiffraction.io.cif.handler import CifHandler
+
+
+class CwlBroadeningMixin:
+    """CWL Gaussian and Lorentz broadening parameters."""
+
+    def __init__(self):
+        super().__init__()
+
+        self._broad_gauss_u: Parameter = Parameter(
+            name='broad_gauss_u',
+            description='Gaussian broadening coefficient (dependent on '
+            'sample size and instrument resolution)',
+            units='deg²',
+            value_spec=AttributeSpec(
+                default=0.01,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.broad_gauss_u']),
+        )
+        self._broad_gauss_v: Parameter = Parameter(
+            name='broad_gauss_v',
+            description='Gaussian broadening coefficient (instrumental broadening contribution)',
+            units='deg²',
+            value_spec=AttributeSpec(
+                default=-0.01,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.broad_gauss_v']),
+        )
+        self._broad_gauss_w: Parameter = Parameter(
+            name='broad_gauss_w',
+            description='Gaussian broadening coefficient (instrumental broadening contribution)',
+            units='deg²',
+            value_spec=AttributeSpec(
+                default=0.02,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.broad_gauss_w']),
+        )
+        self._broad_lorentz_x: Parameter = Parameter(
+            name='broad_lorentz_x',
+            description='Lorentzian broadening coefficient (dependent on sample strain effects)',
+            units='deg',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.broad_lorentz_x']),
+        )
+        self._broad_lorentz_y: Parameter = Parameter(
+            name='broad_lorentz_y',
+            description='Lorentzian broadening coefficient (dependent on '
+            'microstructural defects and strain)',
+            units='deg',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.broad_lorentz_y']),
+        )
+
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
+    @property
+    def broad_gauss_u(self) -> Parameter:
+        return self._broad_gauss_u
+
+    @broad_gauss_u.setter
+    def broad_gauss_u(self, value):
+        self._broad_gauss_u.value = value
+
+    @property
+    def broad_gauss_v(self) -> Parameter:
+        return self._broad_gauss_v
+
+    @broad_gauss_v.setter
+    def broad_gauss_v(self, value):
+        self._broad_gauss_v.value = value
+
+    @property
+    def broad_gauss_w(self) -> Parameter:
+        return self._broad_gauss_w
+
+    @broad_gauss_w.setter
+    def broad_gauss_w(self, value):
+        self._broad_gauss_w.value = value
+
+    @property
+    def broad_lorentz_x(self) -> Parameter:
+        return self._broad_lorentz_x
+
+    @broad_lorentz_x.setter
+    def broad_lorentz_x(self, value):
+        self._broad_lorentz_x.value = value
+
+    @property
+    def broad_lorentz_y(self) -> Parameter:
+        return self._broad_lorentz_y
+
+    @broad_lorentz_y.setter
+    def broad_lorentz_y(self, value):
+        self._broad_lorentz_y.value = value
+
+
+class EmpiricalAsymmetryMixin:
+    """Empirical CWL peak asymmetry parameters."""
+
+    def __init__(self):
+        super().__init__()
+
+        self._asym_empir_1: Parameter = Parameter(
+            name='asym_empir_1',
+            description='Empirical asymmetry coefficient p1',
+            units='',
+            value_spec=AttributeSpec(
+                default=0.1,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.asym_empir_1']),
+        )
+        self._asym_empir_2: Parameter = Parameter(
+            name='asym_empir_2',
+            description='Empirical asymmetry coefficient p2',
+            units='',
+            value_spec=AttributeSpec(
+                default=0.2,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.asym_empir_2']),
+        )
+        self._asym_empir_3: Parameter = Parameter(
+            name='asym_empir_3',
+            description='Empirical asymmetry coefficient p3',
+            units='',
+            value_spec=AttributeSpec(
+                default=0.3,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.asym_empir_3']),
+        )
+        self._asym_empir_4: Parameter = Parameter(
+            name='asym_empir_4',
+            description='Empirical asymmetry coefficient p4',
+            units='',
+            value_spec=AttributeSpec(
+                default=0.4,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.asym_empir_4']),
+        )
+
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
+    @property
+    def asym_empir_1(self) -> Parameter:
+        return self._asym_empir_1
+
+    @asym_empir_1.setter
+    def asym_empir_1(self, value):
+        self._asym_empir_1.value = value
+
+    @property
+    def asym_empir_2(self) -> Parameter:
+        return self._asym_empir_2
+
+    @asym_empir_2.setter
+    def asym_empir_2(self, value):
+        self._asym_empir_2.value = value
+
+    @property
+    def asym_empir_3(self) -> Parameter:
+        return self._asym_empir_3
+
+    @asym_empir_3.setter
+    def asym_empir_3(self, value):
+        self._asym_empir_3.value = value
+
+    @property
+    def asym_empir_4(self) -> Parameter:
+        return self._asym_empir_4
+
+    @asym_empir_4.setter
+    def asym_empir_4(self, value):
+        self._asym_empir_4.value = value
+
+
+class FcjAsymmetryMixin:
+    """Finger–Cox–Jephcoat (FCJ) asymmetry parameters."""
+
+    def __init__(self):
+        super().__init__()
+
+        self._asym_fcj_1: Parameter = Parameter(
+            name='asym_fcj_1',
+            description='Finger-Cox-Jephcoat asymmetry parameter 1',
+            units='',
+            value_spec=AttributeSpec(
+                default=0.01,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.asym_fcj_1']),
+        )
+        self._asym_fcj_2: Parameter = Parameter(
+            name='asym_fcj_2',
+            description='Finger-Cox-Jephcoat asymmetry parameter 2',
+            units='',
+            value_spec=AttributeSpec(
+                default=0.02,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.asym_fcj_2']),
+        )
+
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
+    @property
+    def asym_fcj_1(self):
+        return self._asym_fcj_1
+
+    @asym_fcj_1.setter
+    def asym_fcj_1(self, value):
+        self._asym_fcj_1.value = value
+
+    @property
+    def asym_fcj_2(self):
+        return self._asym_fcj_2
+
+    @asym_fcj_2.setter
+    def asym_fcj_2(self, value):
+        self._asym_fcj_2.value = value
diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/factory.py b/src/easydiffraction/datablocks/experiment/categories/peak/factory.py
new file mode 100644
index 00000000..1992b633
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/peak/factory.py
@@ -0,0 +1,26 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Peak profile factory — delegates to ``FactoryBase``."""
+
+from easydiffraction.core.factory import FactoryBase
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import PeakProfileTypeEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+
+
+class PeakFactory(FactoryBase):
+    """Factory for creating peak profile objects."""
+
+    _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,
+    }
diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/tof.py b/src/easydiffraction/datablocks/experiment/categories/peak/tof.py
new file mode 100644
index 00000000..1c70b65b
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/peak/tof.py
@@ -0,0 +1,84 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Time-of-flight peak profile classes."""
+
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.datablocks.experiment.categories.peak.base import PeakBase
+from easydiffraction.datablocks.experiment.categories.peak.factory import PeakFactory
+from easydiffraction.datablocks.experiment.categories.peak.tof_mixins import (
+    IkedaCarpenterAsymmetryMixin,
+)
+from easydiffraction.datablocks.experiment.categories.peak.tof_mixins import TofBroadeningMixin
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+
+
+@PeakFactory.register
+class TofPseudoVoigt(
+    PeakBase,
+    TofBroadeningMixin,
+):
+    """Time-of-flight pseudo-Voigt peak shape."""
+
+    type_info = TypeInfo(tag='tof-pseudo-voigt', description='TOF pseudo-Voigt profile')
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
+
+
+@PeakFactory.register
+class TofPseudoVoigtIkedaCarpenter(
+    PeakBase,
+    TofBroadeningMixin,
+    IkedaCarpenterAsymmetryMixin,
+):
+    """TOF pseudo-Voigt with Ikeda–Carpenter asymmetry."""
+
+    type_info = TypeInfo(
+        tag='pseudo-voigt * ikeda-carpenter',
+        description='Pseudo-Voigt with Ikeda-Carpenter asymmetry correction',
+    )
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
+
+
+@PeakFactory.register
+class TofPseudoVoigtBackToBack(
+    PeakBase,
+    TofBroadeningMixin,
+    IkedaCarpenterAsymmetryMixin,
+):
+    """TOF back-to-back pseudo-Voigt with asymmetry."""
+
+    type_info = TypeInfo(
+        tag='pseudo-voigt * back-to-back',
+        description='TOF back-to-back pseudo-Voigt with asymmetry',
+    )
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        beam_mode=frozenset({BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py b/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py
new file mode 100644
index 00000000..01a10b26
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py
@@ -0,0 +1,218 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Time-of-flight (TOF) peak-profile component classes.
+
+Defines classes that add Gaussian/Lorentz broadening, mixing, and
+Ikeda–Carpenter asymmetry parameters used by TOF peak shapes. This
+module provides classes that add broadening and asymmetry parameters.
+They are composed into concrete peak classes elsewhere via multiple
+inheritance.
+"""
+
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.validation import RangeValidator
+from easydiffraction.core.variable import Parameter
+from easydiffraction.io.cif.handler import CifHandler
+
+
+class TofBroadeningMixin:
+    """TOF Gaussian/Lorentz broadening and mixing parameters."""
+
+    def __init__(self):
+        super().__init__()
+
+        self._broad_gauss_sigma_0 = Parameter(
+            name='gauss_sigma_0',
+            description='Gaussian broadening coefficient (instrumental resolution)',
+            units='µs²',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.gauss_sigma_0']),
+        )
+        self._broad_gauss_sigma_1 = Parameter(
+            name='gauss_sigma_1',
+            description='Gaussian broadening coefficient (dependent on d-spacing)',
+            units='µs/Å',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.gauss_sigma_1']),
+        )
+        self._broad_gauss_sigma_2 = Parameter(
+            name='gauss_sigma_2',
+            description='Gaussian broadening coefficient (instrument-dependent term)',
+            units='µs²/Ų',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.gauss_sigma_2']),
+        )
+        self._broad_lorentz_gamma_0 = Parameter(
+            name='lorentz_gamma_0',
+            description='Lorentzian broadening coefficient (dependent on microstrain effects)',
+            units='µs',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.lorentz_gamma_0']),
+        )
+        self._broad_lorentz_gamma_1 = Parameter(
+            name='lorentz_gamma_1',
+            description='Lorentzian broadening coefficient (dependent on d-spacing)',
+            units='µs/Å',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.lorentz_gamma_1']),
+        )
+        self._broad_lorentz_gamma_2 = Parameter(
+            name='lorentz_gamma_2',
+            description='Lorentzian broadening coefficient (instrument-dependent term)',
+            units='µs²/Ų',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.lorentz_gamma_2']),
+        )
+        self._broad_mix_beta_0 = Parameter(
+            name='mix_beta_0',
+            description='Mixing parameter. Defines the ratio of Gaussian '
+            'to Lorentzian contributions in TOF profiles',
+            units='deg',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.mix_beta_0']),
+        )
+        self._broad_mix_beta_1 = Parameter(
+            name='mix_beta_1',
+            description='Mixing parameter. Defines the ratio of Gaussian '
+            'to Lorentzian contributions in TOF profiles',
+            units='deg',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.mix_beta_1']),
+        )
+
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
+    @property
+    def broad_gauss_sigma_0(self):
+        return self._broad_gauss_sigma_0
+
+    @broad_gauss_sigma_0.setter
+    def broad_gauss_sigma_0(self, value):
+        self._broad_gauss_sigma_0.value = value
+
+    @property
+    def broad_gauss_sigma_1(self):
+        return self._broad_gauss_sigma_1
+
+    @broad_gauss_sigma_1.setter
+    def broad_gauss_sigma_1(self, value):
+        self._broad_gauss_sigma_1.value = value
+
+    @property
+    def broad_gauss_sigma_2(self):
+        return self._broad_gauss_sigma_2
+
+    @broad_gauss_sigma_2.setter
+    def broad_gauss_sigma_2(self, value):
+        """Set Gaussian sigma_2 parameter."""
+        self._broad_gauss_sigma_2.value = value
+
+    @property
+    def broad_lorentz_gamma_0(self):
+        return self._broad_lorentz_gamma_0
+
+    @broad_lorentz_gamma_0.setter
+    def broad_lorentz_gamma_0(self, value):
+        self._broad_lorentz_gamma_0.value = value
+
+    @property
+    def broad_lorentz_gamma_1(self):
+        return self._broad_lorentz_gamma_1
+
+    @broad_lorentz_gamma_1.setter
+    def broad_lorentz_gamma_1(self, value):
+        self._broad_lorentz_gamma_1.value = value
+
+    @property
+    def broad_lorentz_gamma_2(self):
+        return self._broad_lorentz_gamma_2
+
+    @broad_lorentz_gamma_2.setter
+    def broad_lorentz_gamma_2(self, value):
+        self._broad_lorentz_gamma_2.value = value
+
+    @property
+    def broad_mix_beta_0(self):
+        return self._broad_mix_beta_0
+
+    @broad_mix_beta_0.setter
+    def broad_mix_beta_0(self, value):
+        self._broad_mix_beta_0.value = value
+
+    @property
+    def broad_mix_beta_1(self):
+        return self._broad_mix_beta_1
+
+    @broad_mix_beta_1.setter
+    def broad_mix_beta_1(self, value):
+        self._broad_mix_beta_1.value = value
+
+
+class IkedaCarpenterAsymmetryMixin:
+    """Ikeda–Carpenter asymmetry parameters."""
+
+    def __init__(self):
+        super().__init__()
+
+        self._asym_alpha_0 = Parameter(
+            name='asym_alpha_0',
+            description='Ikeda-Carpenter asymmetry parameter α₀',
+            units='',  # TODO
+            value_spec=AttributeSpec(
+                default=0.01,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.asym_alpha_0']),
+        )
+        self._asym_alpha_1 = Parameter(
+            name='asym_alpha_1',
+            description='Ikeda-Carpenter asymmetry parameter α₁',
+            units='',  # TODO
+            value_spec=AttributeSpec(
+                default=0.02,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.asym_alpha_1']),
+        )
+
+    @property
+    def asym_alpha_0(self):
+        return self._asym_alpha_0
+
+    @asym_alpha_0.setter
+    def asym_alpha_0(self, value):
+        self._asym_alpha_0.value = value
+
+    @property
+    def asym_alpha_1(self):
+        return self._asym_alpha_1
+
+    @asym_alpha_1.setter
+    def asym_alpha_1(self, value):
+        self._asym_alpha_1.value = value
diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/total.py b/src/easydiffraction/datablocks/experiment/categories/peak/total.py
new file mode 100644
index 00000000..a1166c1a
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/peak/total.py
@@ -0,0 +1,36 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Total-scattering (PDF) peak profile classes."""
+
+from easydiffraction.core.metadata import CalculatorSupport
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.datablocks.experiment.categories.peak.base import PeakBase
+from easydiffraction.datablocks.experiment.categories.peak.factory import PeakFactory
+from easydiffraction.datablocks.experiment.categories.peak.total_mixins import TotalBroadeningMixin
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+
+
+@PeakFactory.register
+class TotalGaussianDampedSinc(
+    PeakBase,
+    TotalBroadeningMixin,
+):
+    """Gaussian-damped sinc peak for total scattering (PDF)."""
+
+    type_info = TypeInfo(
+        tag='gaussian-damped-sinc',
+        description='Gaussian-damped sinc for pair distribution function analysis',
+    )
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.TOTAL}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH, BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+    calculator_support = CalculatorSupport(
+        calculators=frozenset({CalculatorEnum.PDFFIT}),
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/total_mixins.py b/src/easydiffraction/datablocks/experiment/categories/peak/total_mixins.py
new file mode 100644
index 00000000..139e37dd
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/peak/total_mixins.py
@@ -0,0 +1,137 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Total scattering / pair distribution function (PDF) peak-profile
+component classes.
+
+This module provides classes that add broadening and asymmetry
+parameters. They are composed into concrete peak classes elsewhere via
+multiple inheritance.
+"""
+
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.validation import RangeValidator
+from easydiffraction.core.variable import Parameter
+from easydiffraction.io.cif.handler import CifHandler
+
+
+class TotalBroadeningMixin:
+    """PDF broadening/damping/sharpening parameters."""
+
+    def __init__(self):
+        super().__init__()
+
+        self._damp_q = Parameter(
+            name='damp_q',
+            description='Instrumental Q-resolution damping factor '
+            '(affects high-r PDF peak amplitude)',
+            units='Å⁻¹',
+            value_spec=AttributeSpec(
+                default=0.05,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.damp_q']),
+        )
+        self._broad_q = Parameter(
+            name='broad_q',
+            description='Quadratic PDF peak broadening coefficient '
+            '(thermal and model uncertainty contribution)',
+            units='Å⁻²',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.broad_q']),
+        )
+        self._cutoff_q = Parameter(
+            name='cutoff_q',
+            description='Q-value cutoff applied to model PDF for Fourier '
+            'transform (controls real-space resolution)',
+            units='Å⁻¹',
+            value_spec=AttributeSpec(
+                default=25.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.cutoff_q']),
+        )
+        self._sharp_delta_1 = Parameter(
+            name='sharp_delta_1',
+            description='PDF peak sharpening coefficient (1/r dependence)',
+            units='Å',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.sharp_delta_1']),
+        )
+        self._sharp_delta_2 = Parameter(
+            name='sharp_delta_2',
+            description='PDF peak sharpening coefficient (1/r² dependence)',
+            units='Ų',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.sharp_delta_2']),
+        )
+        self._damp_particle_diameter = Parameter(
+            name='damp_particle_diameter',
+            description='Particle diameter for spherical envelope damping correction in PDF',
+            units='Å',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_peak.damp_particle_diameter']),
+        )
+
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
+    @property
+    def damp_q(self):
+        return self._damp_q
+
+    @damp_q.setter
+    def damp_q(self, value):
+        self._damp_q.value = value
+
+    @property
+    def broad_q(self):
+        return self._broad_q
+
+    @broad_q.setter
+    def broad_q(self, value):
+        self._broad_q.value = value
+
+    @property
+    def cutoff_q(self) -> Parameter:
+        return self._cutoff_q
+
+    @cutoff_q.setter
+    def cutoff_q(self, value):
+        self._cutoff_q.value = value
+
+    @property
+    def sharp_delta_1(self) -> Parameter:
+        return self._sharp_delta_1
+
+    @sharp_delta_1.setter
+    def sharp_delta_1(self, value):
+        self._sharp_delta_1.value = value
+
+    @property
+    def sharp_delta_2(self):
+        return self._sharp_delta_2
+
+    @sharp_delta_2.setter
+    def sharp_delta_2(self, value):
+        self._sharp_delta_2.value = value
+
+    @property
+    def damp_particle_diameter(self):
+        return self._damp_particle_diameter
+
+    @damp_particle_diameter.setter
+    def damp_particle_diameter(self, value):
+        self._damp_particle_diameter.value = value
diff --git a/src/easydiffraction/datablocks/experiment/collection.py b/src/easydiffraction/datablocks/experiment/collection.py
new file mode 100644
index 00000000..854b77ad
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/collection.py
@@ -0,0 +1,125 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Collection of experiment data blocks."""
+
+from typeguard import typechecked
+
+from easydiffraction.core.datablock import DatablockCollection
+from easydiffraction.datablocks.experiment.item.base import ExperimentBase
+from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory
+from easydiffraction.utils.logging import console
+
+
+class Experiments(DatablockCollection):
+    """Collection of Experiment data blocks.
+
+    Provides convenience constructors for common creation patterns and
+    helper methods for simple presentation of collection contents.
+    """
+
+    def __init__(self) -> None:
+        super().__init__(item_type=ExperimentBase)
+
+    # ------------------------------------------------------------------
+    # Public methods
+    # ------------------------------------------------------------------
+
+    # TODO: Make abstract in DatablockCollection?
+    @typechecked
+    def create(
+        self,
+        *,
+        name: str,
+        sample_form: str | None = None,
+        beam_mode: str | None = None,
+        radiation_probe: str | None = None,
+        scattering_type: str | None = None,
+    ) -> None:
+        """Add an experiment without associating a data file.
+
+        Args:
+            name: Experiment identifier.
+            sample_form: Sample form (e.g. ``'powder'``).
+            beam_mode: Beam mode (e.g. ``'constant wavelength'``).
+            radiation_probe: Radiation probe (e.g. ``'neutron'``).
+            scattering_type: Scattering type (e.g. ``'bragg'``).
+        """
+        experiment = ExperimentFactory.from_scratch(
+            name=name,
+            sample_form=sample_form,
+            beam_mode=beam_mode,
+            radiation_probe=radiation_probe,
+            scattering_type=scattering_type,
+        )
+        self.add(experiment)
+
+    # TODO: Move to DatablockCollection?
+    @typechecked
+    def add_from_cif_str(
+        self,
+        cif_str: str,
+    ) -> None:
+        """Add an experiment from a CIF string.
+
+        Args:
+            cif_str: Full CIF document as a string.
+        """
+        experiment = ExperimentFactory.from_cif_str(cif_str)
+        self.add(experiment)
+
+    # TODO: Move to DatablockCollection?
+    @typechecked
+    def add_from_cif_path(
+        self,
+        cif_path: str,
+    ) -> None:
+        """Add an experiment from a CIF file path.
+
+        Args:
+            cif_path(str): Path to a CIF document.
+        """
+        experiment = ExperimentFactory.from_cif_path(cif_path)
+        self.add(experiment)
+
+    @typechecked
+    def add_from_data_path(
+        self,
+        *,
+        name: str,
+        data_path: str,
+        sample_form: str | None = None,
+        beam_mode: str | None = None,
+        radiation_probe: str | None = None,
+        scattering_type: str | None = None,
+    ) -> None:
+        """Add an experiment from a data file path.
+
+        Args:
+            name: Experiment identifier.
+            data_path: Path to the measured data file.
+            sample_form: Sample form (e.g. ``'powder'``).
+            beam_mode: Beam mode (e.g. ``'constant wavelength'``).
+            radiation_probe: Radiation probe (e.g. ``'neutron'``).
+            scattering_type: Scattering type (e.g. ``'bragg'``).
+        """
+        experiment = ExperimentFactory.from_data_path(
+            name=name,
+            data_path=data_path,
+            sample_form=sample_form,
+            beam_mode=beam_mode,
+            radiation_probe=radiation_probe,
+            scattering_type=scattering_type,
+        )
+        self.add(experiment)
+
+    # TODO: Move to DatablockCollection?
+    def show_names(self) -> None:
+        """List all experiment names in the collection."""
+        console.paragraph('Defined experiments' + ' 🔬')
+        console.print(self.names)
+
+    # TODO: Move to DatablockCollection?
+    def show_params(self) -> None:
+        """Show parameters of all experiments in the collection."""
+        for experiment in self.values():
+            experiment.show_params()
diff --git a/src/easydiffraction/datablocks/experiment/item/__init__.py b/src/easydiffraction/datablocks/experiment/item/__init__.py
new file mode 100644
index 00000000..ffe5775d
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/item/__init__.py
@@ -0,0 +1,9 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.item.base import ExperimentBase
+from easydiffraction.datablocks.experiment.item.base import PdExperimentBase
+from easydiffraction.datablocks.experiment.item.bragg_pd import BraggPdExperiment
+from easydiffraction.datablocks.experiment.item.bragg_sc import CwlScExperiment
+from easydiffraction.datablocks.experiment.item.bragg_sc import TofScExperiment
+from easydiffraction.datablocks.experiment.item.total_pd import TotalPdExperiment
diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py
new file mode 100644
index 00000000..24c6ee6e
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/item/base.py
@@ -0,0 +1,672 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Base classes for experiment datablock items."""
+
+from __future__ import annotations
+
+from abc import abstractmethod
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import List
+
+from easydiffraction.core.datablock import DatablockItem
+from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory
+from easydiffraction.datablocks.experiment.categories.excluded_regions.factory import (
+    ExcludedRegionsFactory,
+)
+from easydiffraction.datablocks.experiment.categories.extinction.factory import ExtinctionFactory
+from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory
+from easydiffraction.datablocks.experiment.categories.linked_crystal.factory import (
+    LinkedCrystalFactory,
+)
+from easydiffraction.datablocks.experiment.categories.linked_phases.factory import (
+    LinkedPhasesFactory,
+)
+from easydiffraction.datablocks.experiment.categories.peak.factory import PeakFactory
+from easydiffraction.io.cif.serialize import experiment_to_cif
+from easydiffraction.utils.logging import console
+from easydiffraction.utils.logging import log
+from easydiffraction.utils.utils import render_cif
+
+if TYPE_CHECKING:
+    from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
+    from easydiffraction.datablocks.structure.collection import Structures
+
+
+class ExperimentBase(DatablockItem):
+    """Base class for all experiment datablock items with only core
+    attributes.
+    """
+
+    def __init__(
+        self,
+        *,
+        name: str,
+        type: ExperimentType,
+    ):
+        super().__init__()
+        self._name = name
+        self._type = type
+        self._calculator = None
+        self._calculator_type: str | None = None
+        self._identity.datablock_entry_name = lambda: self.name
+
+    @property
+    def name(self) -> str:
+        """Human-readable name of the experiment."""
+        return self._name
+
+    @name.setter
+    def name(self, new: str) -> None:
+        """Rename the experiment.
+
+        Args:
+            new: New name for this experiment.
+        """
+        self._name = new
+
+    @property
+    def type(self):  # TODO: Consider another name
+        """Experiment type descriptor (sample form, probe, beam
+        mode).
+        """
+        return self._type
+
+    @property
+    def as_cif(self) -> str:
+        """Serialize this experiment to a CIF fragment."""
+        return experiment_to_cif(self)
+
+    def show_as_cif(self) -> None:
+        """Pretty-print the experiment as CIF text."""
+        experiment_cif = super().as_cif
+        paragraph_title: str = f"Experiment 🔬 '{self.name}' as cif"
+        console.paragraph(paragraph_title)
+        render_cif(experiment_cif)
+
+    @abstractmethod
+    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
+        """Load ASCII data from file into the experiment data category.
+
+        Args:
+            data_path: Path to the ASCII file to load.
+        """
+        raise NotImplementedError()
+
+    # ------------------------------------------------------------------
+    #  Calculator (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def calculator(self):
+        """The active calculator instance for this experiment.
+
+        Auto-resolved on first access from the experiment's data
+        category ``calculator_support`` and
+        ``CalculatorFactory._default_rules``.
+        """
+        if self._calculator is None:
+            self._resolve_calculator()
+        return self._calculator
+
+    @property
+    def calculator_type(self) -> str:
+        """Tag of the active calculator backend (e.g. ``'cryspy'``)."""
+        if self._calculator_type is None:
+            self._resolve_calculator()
+        return self._calculator_type
+
+    @calculator_type.setter
+    def calculator_type(self, tag: str) -> None:
+        """Switch to a different calculator backend.
+
+        Args:
+            tag: Calculator tag (e.g. ``'cryspy'``, ``'crysfml'``,
+                ``'pdffit'``).
+        """
+        from easydiffraction.analysis.calculators.factory import CalculatorFactory
+
+        supported = self._supported_calculator_tags()
+        if tag not in supported:
+            log.warning(
+                f"Unsupported calculator '{tag}' for experiment "
+                f"'{self.name}'. Supported: {supported}. "
+                f"For more information, use 'show_supported_calculator_types()'",
+            )
+            return
+        self._calculator = CalculatorFactory.create(tag)
+        self._calculator_type = tag
+        console.paragraph(f"Calculator for experiment '{self.name}' changed to")
+        console.print(tag)
+
+    def show_supported_calculator_types(self) -> None:
+        """Print a table of calculator backends supported by this
+        experiment.
+        """
+        from easydiffraction.analysis.calculators.factory import CalculatorFactory
+
+        supported_tags = self._supported_calculator_tags()
+        all_classes = CalculatorFactory._supported_map()
+        columns_headers = ['Type', 'Description']
+        columns_alignment = ['left', 'left']
+        columns_data = [
+            [cls.type_info.tag, cls.type_info.description]
+            for tag, cls in all_classes.items()
+            if tag in supported_tags
+        ]
+        from easydiffraction.utils.utils import render_table
+
+        console.paragraph('Supported calculator types')
+        render_table(
+            columns_headers=columns_headers,
+            columns_alignment=columns_alignment,
+            columns_data=columns_data,
+        )
+
+    def show_current_calculator_type(self) -> None:
+        """Print the name of the currently active calculator."""
+        console.paragraph('Current calculator type')
+        console.print(self.calculator_type)
+
+    def _resolve_calculator(self) -> None:
+        """Auto-resolve the default calculator from the data category's
+        ``calculator_support`` and
+        ``CalculatorFactory._default_rules``.
+        """
+        from easydiffraction.analysis.calculators.factory import CalculatorFactory
+
+        tag = CalculatorFactory.default_tag(
+            scattering_type=self.type.scattering_type.value,
+        )
+        supported = self._supported_calculator_tags()
+        if supported and tag not in supported:
+            tag = supported[0]
+        self._calculator = CalculatorFactory.create(tag)
+        self._calculator_type = tag
+
+    def _supported_calculator_tags(self) -> list[str]:
+        """Return calculator tags supported by this experiment.
+
+        Intersects the data category's ``calculator_support`` with
+        calculators whose engines are importable.
+        """
+        from easydiffraction.analysis.calculators.factory import CalculatorFactory
+
+        available = CalculatorFactory.supported_tags()
+        data = getattr(self, '_data', None)
+        if data is not None:
+            data_support = getattr(data, 'calculator_support', None)
+            if data_support and data_support.calculators:
+                return [t for t in available if t in data_support.calculators]
+        return available
+
+
+class ScExperimentBase(ExperimentBase):
+    """Base class for all single crystal experiments."""
+
+    def __init__(
+        self,
+        *,
+        name: str,
+        type: ExperimentType,
+    ) -> None:
+        super().__init__(name=name, type=type)
+
+        self._extinction_type: str = ExtinctionFactory.default_tag()
+        self._extinction = ExtinctionFactory.create(self._extinction_type)
+        self._linked_crystal_type: str = LinkedCrystalFactory.default_tag()
+        self._linked_crystal = LinkedCrystalFactory.create(self._linked_crystal_type)
+        self._instrument_type: str = InstrumentFactory.default_tag(
+            scattering_type=self.type.scattering_type.value,
+            beam_mode=self.type.beam_mode.value,
+            sample_form=self.type.sample_form.value,
+        )
+        self._instrument = InstrumentFactory.create(self._instrument_type)
+        self._data_type: str = DataFactory.default_tag(
+            sample_form=self.type.sample_form.value,
+            beam_mode=self.type.beam_mode.value,
+            scattering_type=self.type.scattering_type.value,
+        )
+        self._data = DataFactory.create(self._data_type)
+
+    @abstractmethod
+    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
+        """Load single crystal data from an ASCII file.
+
+        Args:
+            data_path: Path to data file with columns compatible with
+                the beam mode.
+        """
+        pass
+
+    # ------------------------------------------------------------------
+    #  Extinction (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def extinction(self):
+        """Active extinction correction model."""
+        return self._extinction
+
+    @property
+    def extinction_type(self) -> str:
+        """Tag of the active extinction correction model."""
+        return self._extinction_type
+
+    @extinction_type.setter
+    def extinction_type(self, new_type: str) -> None:
+        """Switch to a different extinction correction model.
+
+        Args:
+            new_type: Extinction tag (e.g. ``'shelx'``).
+        """
+        supported_tags = ExtinctionFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported extinction type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_extinction_types()'",
+            )
+            return
+
+        self._extinction = ExtinctionFactory.create(new_type)
+        self._extinction_type = new_type
+        console.paragraph(f"Extinction type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_extinction_types(self) -> None:
+        """Print a table of supported extinction correction models."""
+        ExtinctionFactory.show_supported()
+
+    def show_current_extinction_type(self) -> None:
+        """Print the currently used extinction correction model."""
+        console.paragraph('Current extinction type')
+        console.print(self.extinction_type)
+
+    # ------------------------------------------------------------------
+    #  Linked crystal (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def linked_crystal(self):
+        """Linked crystal model for this experiment."""
+        return self._linked_crystal
+
+    @property
+    def linked_crystal_type(self) -> str:
+        """Tag of the active linked-crystal reference type."""
+        return self._linked_crystal_type
+
+    @linked_crystal_type.setter
+    def linked_crystal_type(self, new_type: str) -> None:
+        """Switch to a different linked-crystal reference type.
+
+        Args:
+            new_type: Linked-crystal tag (e.g. ``'default'``).
+        """
+        supported_tags = LinkedCrystalFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported linked crystal type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_linked_crystal_types()'",
+            )
+            return
+
+        self._linked_crystal = LinkedCrystalFactory.create(new_type)
+        self._linked_crystal_type = new_type
+        console.paragraph(f"Linked crystal type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_linked_crystal_types(self) -> None:
+        """Print a table of supported linked-crystal reference types."""
+        LinkedCrystalFactory.show_supported()
+
+    def show_current_linked_crystal_type(self) -> None:
+        """Print the currently used linked-crystal reference type."""
+        console.paragraph('Current linked crystal type')
+        console.print(self.linked_crystal_type)
+
+    # ------------------------------------------------------------------
+    #  Instrument (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def instrument(self):
+        """Active instrument model for this experiment."""
+        return self._instrument
+
+    @property
+    def instrument_type(self) -> str:
+        """Tag of the active instrument type."""
+        return self._instrument_type
+
+    @instrument_type.setter
+    def instrument_type(self, new_type: str) -> None:
+        """Switch to a different instrument type.
+
+        Args:
+            new_type: Instrument tag (e.g. ``'cwl-sc'``).
+        """
+        supported = InstrumentFactory.supported_for(
+            scattering_type=self.type.scattering_type.value,
+            beam_mode=self.type.beam_mode.value,
+            sample_form=self.type.sample_form.value,
+        )
+        supported_tags = [k.type_info.tag for k in supported]
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported instrument type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_instrument_types()'",
+            )
+            return
+        self._instrument = InstrumentFactory.create(new_type)
+        self._instrument_type = new_type
+        console.paragraph(f"Instrument type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_instrument_types(self) -> None:
+        """Print a table of supported instrument types."""
+        InstrumentFactory.show_supported(
+            scattering_type=self.type.scattering_type.value,
+            beam_mode=self.type.beam_mode.value,
+            sample_form=self.type.sample_form.value,
+        )
+
+    def show_current_instrument_type(self) -> None:
+        """Print the currently used instrument type."""
+        console.paragraph('Current instrument type')
+        console.print(self.instrument_type)
+
+    # ------------------------------------------------------------------
+    #  Data (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def data(self):
+        """Data collection for this experiment."""
+        return self._data
+
+    @property
+    def data_type(self) -> str:
+        """Tag of the active data collection type."""
+        return self._data_type
+
+    @data_type.setter
+    def data_type(self, new_type: str) -> None:
+        """Switch to a different data collection type.
+
+        Args:
+            new_type: Data tag (e.g. ``'bragg-sc'``).
+        """
+        supported_tags = DataFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported data type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_data_types()'",
+            )
+            return
+        self._data = DataFactory.create(new_type)
+        self._data_type = new_type
+        console.paragraph(f"Data type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_data_types(self) -> None:
+        """Print a table of supported data collection types."""
+        DataFactory.show_supported()
+
+    def show_current_data_type(self) -> None:
+        """Print the currently used data collection type."""
+        console.paragraph('Current data type')
+        console.print(self.data_type)
+
+
+class PdExperimentBase(ExperimentBase):
+    """Base class for all powder experiments."""
+
+    def __init__(
+        self,
+        *,
+        name: str,
+        type: ExperimentType,
+    ) -> None:
+        super().__init__(name=name, type=type)
+
+        self._linked_phases_type: str = LinkedPhasesFactory.default_tag()
+        self._linked_phases = LinkedPhasesFactory.create(self._linked_phases_type)
+        self._excluded_regions_type: str = ExcludedRegionsFactory.default_tag()
+        self._excluded_regions = ExcludedRegionsFactory.create(self._excluded_regions_type)
+        self._peak_profile_type: str = PeakFactory.default_tag(
+            scattering_type=self.type.scattering_type.value,
+            beam_mode=self.type.beam_mode.value,
+        )
+        self._data_type: str = DataFactory.default_tag(
+            sample_form=self.type.sample_form.value,
+            beam_mode=self.type.beam_mode.value,
+            scattering_type=self.type.scattering_type.value,
+        )
+        self._data = DataFactory.create(self._data_type)
+        self._peak = PeakFactory.create(self._peak_profile_type)
+
+    def _get_valid_linked_phases(
+        self,
+        structures: Structures,
+    ) -> List[Any]:
+        """Get valid linked phases for this experiment.
+
+        Args:
+            structures: Collection of structures.
+
+        Returns:
+            A list of valid linked phases.
+        """
+        if not self.linked_phases:
+            print('Warning: No linked phases defined. Returning empty pattern.')
+            return []
+
+        valid_linked_phases = []
+        for linked_phase in self.linked_phases:
+            if linked_phase._identity.category_entry_name not in structures.names:
+                print(
+                    f"Warning: Linked phase '{linked_phase.id.value}' not "
+                    f'found in Structures {structures.names}. Skipping it.'
+                )
+                continue
+            valid_linked_phases.append(linked_phase)
+
+        if not valid_linked_phases:
+            print(
+                'Warning: None of the linked phases found in Structures. Returning empty pattern.'
+            )
+
+        return valid_linked_phases
+
+    @abstractmethod
+    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
+        """Load powder diffraction data from an ASCII file.
+
+        Args:
+            data_path: Path to data file with columns compatible with
+                the beam mode (e.g. 2θ/I/σ for CWL, TOF/I/σ for TOF).
+        """
+        pass
+
+    @property
+    def linked_phases(self):
+        """Collection of phases linked to this experiment."""
+        return self._linked_phases
+
+    @property
+    def linked_phases_type(self) -> str:
+        """Tag of the active linked-phases collection type."""
+        return self._linked_phases_type
+
+    @linked_phases_type.setter
+    def linked_phases_type(self, new_type: str) -> None:
+        """Switch to a different linked-phases collection type.
+
+        Args:
+            new_type: Linked-phases tag (e.g. ``'default'``).
+        """
+        supported_tags = LinkedPhasesFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported linked phases type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_linked_phases_types()'",
+            )
+            return
+
+        self._linked_phases = LinkedPhasesFactory.create(new_type)
+        self._linked_phases_type = new_type
+        console.paragraph(f"Linked phases type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_linked_phases_types(self) -> None:
+        """Print a table of supported linked-phases collection types."""
+        LinkedPhasesFactory.show_supported()
+
+    def show_current_linked_phases_type(self) -> None:
+        """Print the currently used linked-phases collection type."""
+        console.paragraph('Current linked phases type')
+        console.print(self.linked_phases_type)
+
+    @property
+    def excluded_regions(self):
+        """Collection of excluded regions for the x-grid."""
+        return self._excluded_regions
+
+    @property
+    def excluded_regions_type(self) -> str:
+        """Tag of the active excluded-regions collection type."""
+        return self._excluded_regions_type
+
+    @excluded_regions_type.setter
+    def excluded_regions_type(self, new_type: str) -> None:
+        """Switch to a different excluded-regions collection type.
+
+        Args:
+            new_type: Excluded-regions tag (e.g. ``'default'``).
+        """
+        supported_tags = ExcludedRegionsFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported excluded regions type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_excluded_regions_types()'",
+            )
+            return
+
+        self._excluded_regions = ExcludedRegionsFactory.create(new_type)
+        self._excluded_regions_type = new_type
+        console.paragraph(f"Excluded regions type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_excluded_regions_types(self) -> None:
+        """Print a table of supported excluded-regions collection
+        types.
+        """
+        ExcludedRegionsFactory.show_supported()
+
+    def show_current_excluded_regions_type(self) -> None:
+        """Print the currently used excluded-regions collection type."""
+        console.paragraph('Current excluded regions type')
+        console.print(self.excluded_regions_type)
+
+    # ------------------------------------------------------------------
+    #  Data (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def data(self):
+        """Data collection for this experiment."""
+        return self._data
+
+    @property
+    def data_type(self) -> str:
+        """Tag of the active data collection type."""
+        return self._data_type
+
+    @data_type.setter
+    def data_type(self, new_type: str) -> None:
+        """Switch to a different data collection type.
+
+        Args:
+            new_type: Data tag (e.g. ``'bragg-pd-cwl'``).
+        """
+        supported_tags = DataFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported data type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_data_types()'",
+            )
+            return
+        self._data = DataFactory.create(new_type)
+        self._data_type = new_type
+        console.paragraph(f"Data type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_data_types(self) -> None:
+        """Print a table of supported data collection types."""
+        DataFactory.show_supported()
+
+    def show_current_data_type(self) -> None:
+        """Print the currently used data collection type."""
+        console.paragraph('Current data type')
+        console.print(self.data_type)
+
+    @property
+    def peak(self):
+        """Peak category object with profile parameters and mixins."""
+        return self._peak
+
+    @property
+    def peak_profile_type(self):
+        """Currently selected peak profile type enum."""
+        return self._peak_profile_type
+
+    @peak_profile_type.setter
+    def peak_profile_type(self, new_type: str):
+        """Change the active peak profile type, if supported.
+
+        Args:
+            new_type: New profile type as tag string.
+        """
+        supported = PeakFactory.supported_for(
+            scattering_type=self.type.scattering_type.value,
+            beam_mode=self.type.beam_mode.value,
+        )
+        supported_tags = [k.type_info.tag for k in supported]
+
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported peak profile '{new_type}'. "
+                f'Supported peak profiles: {supported_tags}. '
+                f"For more information, use 'show_supported_peak_profile_types()'",
+            )
+            return
+
+        if self._peak is not None:
+            log.warning(
+                'Switching peak profile type discards existing peak parameters.',
+            )
+
+        self._peak = PeakFactory.create(new_type)
+        self._peak_profile_type = new_type
+        console.paragraph(f"Peak profile type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_peak_profile_types(self):
+        """Print available peak profile types for this experiment."""
+        PeakFactory.show_supported(
+            scattering_type=self.type.scattering_type.value,
+            beam_mode=self.type.beam_mode.value,
+        )
+
+    def show_current_peak_profile_type(self):
+        """Print the currently selected peak profile type."""
+        console.paragraph('Current peak profile type')
+        console.print(self.peak_profile_type)
diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py
new file mode 100644
index 00000000..258085c5
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py
@@ -0,0 +1,203 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory
+from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory
+from easydiffraction.datablocks.experiment.item.base import PdExperimentBase
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory
+from easydiffraction.utils.logging import console
+from easydiffraction.utils.logging import log
+
+if TYPE_CHECKING:
+    from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
+
+
+@ExperimentFactory.register
+class BraggPdExperiment(PdExperimentBase):
+    """Standard (Bragg) Powder Diffraction experiment class with
+    specific attributes.
+    """
+
+    type_info = TypeInfo(
+        tag='bragg-pd',
+        description='Bragg powder diffraction experiment',
+    )
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        sample_form=frozenset({SampleFormEnum.POWDER}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH, BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+
+    def __init__(
+        self,
+        *,
+        name: str,
+        type: ExperimentType,
+    ) -> None:
+        super().__init__(name=name, type=type)
+
+        self._instrument_type: str = InstrumentFactory.default_tag(
+            scattering_type=self.type.scattering_type.value,
+            beam_mode=self.type.beam_mode.value,
+            sample_form=self.type.sample_form.value,
+        )
+        self._instrument = InstrumentFactory.create(self._instrument_type)
+        self._background_type: str = BackgroundFactory.default_tag()
+        self._background = BackgroundFactory.create(self._background_type)
+
+    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
+        """Load (x, y, sy) data from an ASCII file into the data
+        category.
+
+        The file format is space/column separated with 2 or 3 columns:
+        ``x y [sy]``. If ``sy`` is missing, it is approximated as
+        ``sqrt(y)``.
+
+        If ``sy`` has values smaller than ``0.0001``, they are replaced
+        with ``1.0``.
+        """
+        try:
+            data = np.loadtxt(data_path)
+        except Exception as e:
+            raise IOError(f'Failed to read data from {data_path}: {e}') from e
+
+        if data.shape[1] < 2:
+            raise ValueError('Data file must have at least two columns: x and y.')
+
+        if data.shape[1] < 3:
+            print('Warning: No uncertainty (sy) column provided. Defaulting to sqrt(y).')
+
+        # Extract x, y data
+        x: np.ndarray = data[:, 0]
+        y: np.ndarray = data[:, 1]
+
+        # Round x to 4 decimal places
+        x = np.round(x, 4)
+
+        # Determine sy from column 3 if available, otherwise use sqrt(y)
+        sy: np.ndarray = data[:, 2] if data.shape[1] > 2 else np.sqrt(y)
+
+        # Replace values smaller than 0.0001 with 1.0
+        # TODO: Not used if loading from cif file?
+        sy = np.where(sy < 0.0001, 1.0, sy)
+
+        # Set the experiment data
+        self.data._create_items_set_xcoord_and_id(x)
+        self.data._set_intensity_meas(y)
+        self.data._set_intensity_meas_su(sy)
+
+        console.paragraph('Data loaded successfully')
+        console.print(f"Experiment 🔬 '{self.name}'. Number of data points: {len(x)}")
+
+    # ------------------------------------------------------------------
+    #  Instrument (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def instrument(self):
+        """Active instrument model for this experiment."""
+        return self._instrument
+
+    @property
+    def instrument_type(self) -> str:
+        """Tag of the active instrument type."""
+        return self._instrument_type
+
+    @instrument_type.setter
+    def instrument_type(self, new_type: str) -> None:
+        """Switch to a different instrument type.
+
+        Args:
+            new_type: Instrument tag (e.g. ``'cwl-pd'``).
+        """
+        supported = InstrumentFactory.supported_for(
+            scattering_type=self.type.scattering_type.value,
+            beam_mode=self.type.beam_mode.value,
+            sample_form=self.type.sample_form.value,
+        )
+        supported_tags = [k.type_info.tag for k in supported]
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported instrument type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_instrument_types()'",
+            )
+            return
+        self._instrument = InstrumentFactory.create(new_type)
+        self._instrument_type = new_type
+        console.paragraph(f"Instrument type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_instrument_types(self) -> None:
+        """Print a table of supported instrument types."""
+        InstrumentFactory.show_supported(
+            scattering_type=self.type.scattering_type.value,
+            beam_mode=self.type.beam_mode.value,
+            sample_form=self.type.sample_form.value,
+        )
+
+    def show_current_instrument_type(self) -> None:
+        """Print the currently used instrument type."""
+        console.paragraph('Current instrument type')
+        console.print(self.instrument_type)
+
+    # ------------------------------------------------------------------
+    #  Background (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def background_type(self):
+        """Current background type enum value."""
+        return self._background_type
+
+    @background_type.setter
+    def background_type(self, new_type):
+        """Set a new background type and recreate background object."""
+        if self._background_type == new_type:
+            console.paragraph(f"Background type for experiment '{self.name}' already set to")
+            console.print(new_type)
+            return
+
+        supported_tags = BackgroundFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported background type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_background_types()'",
+            )
+            return
+
+        if len(self._background) > 0:
+            log.warning(
+                f'Switching background type discards {len(self._background)} '
+                f'existing background point(s).',
+            )
+
+        self._background = BackgroundFactory.create(new_type)
+        self._background_type = new_type
+        console.paragraph(f"Background type for experiment '{self.name}' changed to")
+        console.print(new_type)
+
+    @property
+    def background(self):
+        return self._background
+
+    def show_supported_background_types(self):
+        """Print a table of supported background types."""
+        BackgroundFactory.show_supported()
+
+    def show_current_background_type(self):
+        """Print the currently used background type."""
+        console.paragraph('Current background type')
+        console.print(self.background_type)
diff --git a/src/easydiffraction/experiments/experiment/bragg_sc.py b/src/easydiffraction/datablocks/experiment/item/bragg_sc.py
similarity index 75%
rename from src/easydiffraction/experiments/experiment/bragg_sc.py
rename to src/easydiffraction/datablocks/experiment/item/bragg_sc.py
index 60ade068..e8142e0e 100644
--- a/src/easydiffraction/experiments/experiment/bragg_sc.py
+++ b/src/easydiffraction/datablocks/experiment/item/bragg_sc.py
@@ -7,19 +7,36 @@
 
 import numpy as np
 
-from easydiffraction.experiments.experiment.base import ScExperimentBase
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.datablocks.experiment.item.base import ScExperimentBase
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory
 from easydiffraction.utils.logging import console
 from easydiffraction.utils.logging import log
 
 if TYPE_CHECKING:
-    from easydiffraction.experiments.categories.experiment_type import ExperimentType
+    from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
 
 
+@ExperimentFactory.register
 class CwlScExperiment(ScExperimentBase):
     """Standard (Bragg) constant wavelength single srystal experiment
     class with specific attributes.
     """
 
+    type_info = TypeInfo(
+        tag='bragg-sc-cwl',
+        description='Bragg CWL single-crystal experiment',
+    )
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        sample_form=frozenset({SampleFormEnum.SINGLE_CRYSTAL}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
+    )
+
     def __init__(
         self,
         *,
@@ -68,11 +85,22 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None:
         console.print(f"Experiment 🔬 '{self.name}'. Number of data points: {len(indices_h)}")
 
 
+@ExperimentFactory.register
 class TofScExperiment(ScExperimentBase):
     """Standard (Bragg) time-of-flight single srystal experiment class
     with specific attributes.
     """
 
+    type_info = TypeInfo(
+        tag='bragg-sc-tof',
+        description='Bragg TOF single-crystal experiment',
+    )
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
+        sample_form=frozenset({SampleFormEnum.SINGLE_CRYSTAL}),
+        beam_mode=frozenset({BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+
     def __init__(
         self,
         *,
diff --git a/src/easydiffraction/experiments/experiment/enums.py b/src/easydiffraction/datablocks/experiment/item/enums.py
similarity index 88%
rename from src/easydiffraction/experiments/experiment/enums.py
rename to src/easydiffraction/datablocks/experiment/item/enums.py
index b15220fe..8d0f153e 100644
--- a/src/easydiffraction/experiments/experiment/enums.py
+++ b/src/easydiffraction/datablocks/experiment/item/enums.py
@@ -74,6 +74,20 @@ def description(self) -> str:
             return 'Time-of-flight (TOF) diffraction.'
 
 
+class CalculatorEnum(str, Enum):
+    """Known calculation engine identifiers."""
+
+    CRYSPY = 'cryspy'
+    CRYSFML = 'crysfml'
+    PDFFIT = 'pdffit'
+
+
+# TODO: Can, instead of hardcoding here, this info be auto-extracted
+#  from the actual peak profile classes defined in peak/cwl.py, tof.py,
+#  total.py? So that their Enum variable, string representation and
+#  description are defined in the respective classes?
+# TODO: Can supported values be defined based on the structure of peak/?
+# TODO: Can the same be reused for other enums in this file?
 class PeakProfileTypeEnum(str, Enum):
     """Available peak profile types per scattering and beam mode."""
 
diff --git a/src/easydiffraction/datablocks/experiment/item/factory.py b/src/easydiffraction/datablocks/experiment/item/factory.py
new file mode 100644
index 00000000..3e5aebb5
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/item/factory.py
@@ -0,0 +1,236 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Factory for creating experiment instances from various inputs.
+
+Provides individual class methods for each creation pathway:
+``from_cif_path``, ``from_cif_str``, ``from_data_path``, and
+``from_scratch``.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from typeguard import typechecked
+
+from easydiffraction.core.factory import FactoryBase
+from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+from easydiffraction.io.cif.parse import document_from_path
+from easydiffraction.io.cif.parse import document_from_string
+from easydiffraction.io.cif.parse import name_from_block
+from easydiffraction.io.cif.parse import pick_sole_block
+from easydiffraction.utils.logging import log
+
+if TYPE_CHECKING:
+    import gemmi
+
+    from easydiffraction.datablocks.experiment.item.base import ExperimentBase
+
+
+class ExperimentFactory(FactoryBase):
+    """Creates Experiment instances with only relevant attributes."""
+
+    _default_rules = {
+        frozenset({
+            ('scattering_type', ScatteringTypeEnum.BRAGG),
+            ('sample_form', SampleFormEnum.POWDER),
+        }): 'bragg-pd',
+        frozenset({
+            ('scattering_type', ScatteringTypeEnum.TOTAL),
+            ('sample_form', SampleFormEnum.POWDER),
+        }): 'total-pd',
+        frozenset({
+            ('scattering_type', ScatteringTypeEnum.BRAGG),
+            ('sample_form', SampleFormEnum.SINGLE_CRYSTAL),
+            ('beam_mode', BeamModeEnum.CONSTANT_WAVELENGTH),
+        }): 'bragg-sc-cwl',
+        frozenset({
+            ('scattering_type', ScatteringTypeEnum.BRAGG),
+            ('sample_form', SampleFormEnum.SINGLE_CRYSTAL),
+            ('beam_mode', BeamModeEnum.TIME_OF_FLIGHT),
+        }): 'bragg-sc-tof',
+    }
+
+    # TODO: Add to core/factory.py?
+    def __init__(self):
+        log.error(
+            'Experiment objects must be created using class methods such as '
+            '`ExperimentFactory.from_cif_str(...)`, etc.'
+        )
+
+    # ------------------------------------------------------------------
+    # Private helper methods
+    # ------------------------------------------------------------------
+
+    @classmethod
+    @typechecked
+    def _create_experiment_type(
+        cls,
+        sample_form: str | None = None,
+        beam_mode: str | None = None,
+        radiation_probe: str | None = None,
+        scattering_type: str | None = None,
+    ) -> ExperimentType:
+        """Construct an ExperimentType, using defaults for omitted
+        values.
+        """
+        # Note: validation of input values is done via Descriptor setter
+        # methods
+
+        et = ExperimentType()
+
+        if sample_form is not None:
+            et._set_sample_form(sample_form)
+        if beam_mode is not None:
+            et._set_beam_mode(beam_mode)
+        if radiation_probe is not None:
+            et._set_radiation_probe(radiation_probe)
+        if scattering_type is not None:
+            et._set_scattering_type(scattering_type)
+
+        return et
+
+    @classmethod
+    @typechecked
+    def _resolve_class(cls, expt_type: ExperimentType):
+        """Look up the experiment class from the type enums."""
+        tag = cls.default_tag(
+            scattering_type=expt_type.scattering_type.value,
+            sample_form=expt_type.sample_form.value,
+            beam_mode=expt_type.beam_mode.value,
+        )
+        return cls._supported_map()[tag]
+
+    @classmethod
+    # TODO: @typechecked fails to find gemmi?
+    def _from_gemmi_block(
+        cls,
+        block: gemmi.cif.Block,
+    ) -> ExperimentBase:
+        """Build a model instance from a single CIF block."""
+        name = name_from_block(block)
+
+        expt_type = ExperimentType()
+        for param in expt_type.parameters:
+            param.from_cif(block)
+
+        expt_class = cls._resolve_class(expt_type)
+        expt_obj = expt_class(name=name, type=expt_type)
+
+        for category in expt_obj.categories:
+            category.from_cif(block)
+
+        return expt_obj
+
+    # ------------------------------------------------------------------
+    # Public methods
+    # ------------------------------------------------------------------
+
+    @classmethod
+    @typechecked
+    def from_scratch(
+        cls,
+        *,
+        name: str,
+        sample_form: str | None = None,
+        beam_mode: str | None = None,
+        radiation_probe: str | None = None,
+        scattering_type: str | None = None,
+    ) -> ExperimentBase:
+        """Create an experiment without measured data.
+
+        Args:
+            name: Experiment identifier.
+            sample_form: Sample form (e.g. ``'powder'``).
+            beam_mode: Beam mode (e.g. ``'constant wavelength'``).
+            radiation_probe: Radiation probe (e.g. ``'neutron'``).
+            scattering_type: Scattering type (e.g. ``'bragg'``).
+
+        Returns:
+            An experiment instance with only metadata.
+        """
+        expt_type = cls._create_experiment_type(
+            sample_form=sample_form,
+            beam_mode=beam_mode,
+            radiation_probe=radiation_probe,
+            scattering_type=scattering_type,
+        )
+        expt_class = cls._resolve_class(expt_type)
+        expt_obj = expt_class(name=name, type=expt_type)
+        return expt_obj
+
+    # TODO: add minimal default configuration for missing parameters
+    @classmethod
+    @typechecked
+    def from_cif_str(
+        cls,
+        cif_str: str,
+    ) -> ExperimentBase:
+        """Create an experiment from a CIF string.
+
+        Args:
+            cif_str: Full CIF document as a string.
+
+        Returns:
+            A populated experiment instance.
+        """
+        doc = document_from_string(cif_str)
+        block = pick_sole_block(doc)
+        return cls._from_gemmi_block(block)
+
+    # TODO: Read content and call self.from_cif_str
+    @classmethod
+    @typechecked
+    def from_cif_path(
+        cls,
+        cif_path: str,
+    ) -> ExperimentBase:
+        """Create an experiment from a CIF file path.
+
+        Args:
+            cif_path: Path to a CIF file.
+
+        Returns:
+            A populated experiment instance.
+        """
+        doc = document_from_path(cif_path)
+        block = pick_sole_block(doc)
+        return cls._from_gemmi_block(block)
+
+    @classmethod
+    @typechecked
+    def from_data_path(
+        cls,
+        *,
+        name: str,
+        data_path: str,
+        sample_form: str | None = None,
+        beam_mode: str | None = None,
+        radiation_probe: str | None = None,
+        scattering_type: str | None = None,
+    ) -> ExperimentBase:
+        """Create an experiment from a raw data ASCII file.
+
+        Args:
+            name: Experiment identifier.
+            data_path: Path to the measured data file.
+            sample_form: Sample form (e.g. ``'powder'``).
+            beam_mode: Beam mode (e.g. ``'constant wavelength'``).
+            radiation_probe: Radiation probe (e.g. ``'neutron'``).
+            scattering_type: Scattering type (e.g. ``'bragg'``).
+
+        Returns:
+            An experiment instance with measured data attached.
+        """
+        expt_obj = cls.from_scratch(
+            name=name,
+            sample_form=sample_form,
+            beam_mode=beam_mode,
+            radiation_probe=radiation_probe,
+            scattering_type=scattering_type,
+        )
+        expt_obj._load_ascii_data_to_experiment(data_path)
+        return expt_obj
diff --git a/src/easydiffraction/experiments/experiment/total_pd.py b/src/easydiffraction/datablocks/experiment/item/total_pd.py
similarity index 64%
rename from src/easydiffraction/experiments/experiment/total_pd.py
rename to src/easydiffraction/datablocks/experiment/item/total_pd.py
index 6262bd62..881dd0ce 100644
--- a/src/easydiffraction/experiments/experiment/total_pd.py
+++ b/src/easydiffraction/datablocks/experiment/item/total_pd.py
@@ -7,16 +7,33 @@
 
 import numpy as np
 
-from easydiffraction.experiments.experiment.base import PdExperimentBase
+from easydiffraction.core.metadata import Compatibility
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.datablocks.experiment.item.base import PdExperimentBase
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory
 from easydiffraction.utils.logging import console
 
 if TYPE_CHECKING:
-    from easydiffraction.experiments.categories.experiment_type import ExperimentType
+    from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
 
 
+@ExperimentFactory.register
 class TotalPdExperiment(PdExperimentBase):
     """PDF experiment class with specific attributes."""
 
+    type_info = TypeInfo(
+        tag='total-pd',
+        description='Total scattering (PDF) powder experiment',
+    )
+    compatibility = Compatibility(
+        scattering_type=frozenset({ScatteringTypeEnum.TOTAL}),
+        sample_form=frozenset({SampleFormEnum.POWDER}),
+        beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH, BeamModeEnum.TIME_OF_FLIGHT}),
+    )
+
     def __init__(
         self,
         name: str,
diff --git a/src/easydiffraction/experiments/categories/instrument/__init__.py b/src/easydiffraction/datablocks/structure/__init__.py
similarity index 100%
rename from src/easydiffraction/experiments/categories/instrument/__init__.py
rename to src/easydiffraction/datablocks/structure/__init__.py
diff --git a/src/easydiffraction/experiments/categories/peak/__init__.py b/src/easydiffraction/datablocks/structure/categories/__init__.py
similarity index 100%
rename from src/easydiffraction/experiments/categories/peak/__init__.py
rename to src/easydiffraction/datablocks/structure/categories/__init__.py
diff --git a/src/easydiffraction/datablocks/structure/categories/atom_sites/__init__.py b/src/easydiffraction/datablocks/structure/categories/atom_sites/__init__.py
new file mode 100644
index 00000000..cb4c1750
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/categories/atom_sites/__init__.py
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.structure.categories.atom_sites.default import AtomSite
+from easydiffraction.datablocks.structure.categories.atom_sites.default import AtomSites
diff --git a/src/easydiffraction/datablocks/structure/categories/atom_sites/default.py b/src/easydiffraction/datablocks/structure/categories/atom_sites/default.py
new file mode 100644
index 00000000..76d890d5
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/categories/atom_sites/default.py
@@ -0,0 +1,398 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Atom site category.
+
+Defines :class:`AtomSite` items and :class:`AtomSites` collection used
+in crystallographic structures.
+"""
+
+from __future__ import annotations
+
+from cryspy.A_functions_base.database import DATABASE
+
+from easydiffraction.core.category import CategoryCollection
+from easydiffraction.core.category import CategoryItem
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.validation import MembershipValidator
+from easydiffraction.core.validation import RangeValidator
+from easydiffraction.core.validation import RegexValidator
+from easydiffraction.core.variable import Parameter
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.crystallography import crystallography as ecr
+from easydiffraction.datablocks.structure.categories.atom_sites.factory import AtomSitesFactory
+from easydiffraction.io.cif.handler import CifHandler
+
+
+class AtomSite(CategoryItem):
+    """Single atom site with fractional coordinates and ADP.
+
+    Attributes are represented by descriptors to support validation and
+    CIF serialization.
+    """
+
+    def __init__(self) -> None:
+        """Initialise the atom site with default descriptor values."""
+        super().__init__()
+
+        self._label = StringDescriptor(
+            name='label',
+            description='Unique identifier for the atom site.',
+            value_spec=AttributeSpec(
+                default='Si',
+                # TODO: the following pattern is valid for dict key
+                #  (keywords are not checked). CIF label is less strict.
+                #  Do we need conversion between CIF and internal label?
+                validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
+            ),
+            cif_handler=CifHandler(names=['_atom_site.label']),
+        )
+        self._type_symbol = StringDescriptor(
+            name='type_symbol',
+            description='Chemical symbol of the atom at this site.',
+            value_spec=AttributeSpec(
+                default='Tb',
+                validator=MembershipValidator(allowed=self._type_symbol_allowed_values),
+            ),
+            cif_handler=CifHandler(names=['_atom_site.type_symbol']),
+        )
+        self._fract_x = Parameter(
+            name='fract_x',
+            description='Fractional x-coordinate of the atom site within the unit cell.',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_atom_site.fract_x']),
+        )
+        self._fract_y = Parameter(
+            name='fract_y',
+            description='Fractional y-coordinate of the atom site within the unit cell.',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_atom_site.fract_y']),
+        )
+        self._fract_z = Parameter(
+            name='fract_z',
+            description='Fractional z-coordinate of the atom site within the unit cell.',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_atom_site.fract_z']),
+        )
+        self._wyckoff_letter = StringDescriptor(
+            name='wyckoff_letter',
+            description='Wyckoff letter indicating the symmetry of the '
+            'atom site within the space group.',
+            value_spec=AttributeSpec(
+                default=self._wyckoff_letter_default_value,
+                validator=MembershipValidator(allowed=self._wyckoff_letter_allowed_values),
+            ),
+            cif_handler=CifHandler(
+                names=[
+                    '_atom_site.Wyckoff_letter',
+                    '_atom_site.Wyckoff_symbol',
+                ]
+            ),
+        )
+        self._occupancy = Parameter(
+            name='occupancy',
+            description='Occupancy of the atom site, representing the '
+            'fraction of the site occupied by the atom type.',
+            value_spec=AttributeSpec(
+                default=1.0,
+                validator=RangeValidator(),
+            ),
+            cif_handler=CifHandler(names=['_atom_site.occupancy']),
+        )
+        self._b_iso = Parameter(
+            name='b_iso',
+            description='Isotropic atomic displacement parameter (ADP) for the atom site.',
+            units='Ų',
+            value_spec=AttributeSpec(
+                default=0.0,
+                validator=RangeValidator(ge=0.0),
+            ),
+            cif_handler=CifHandler(names=['_atom_site.B_iso_or_equiv']),
+        )
+        self._adp_type = StringDescriptor(
+            name='adp_type',
+            description='Type of atomic displacement parameter (ADP) '
+            'used (e.g., Biso, Uiso, Uani, Bani).',
+            value_spec=AttributeSpec(
+                default='Biso',
+                validator=MembershipValidator(allowed=['Biso']),
+            ),
+            cif_handler=CifHandler(names=['_atom_site.adp_type']),
+        )
+
+        self._identity.category_code = 'atom_site'
+        self._identity.category_entry_name = lambda: str(self.label.value)
+
+    # ------------------------------------------------------------------
+    #  Private helper methods
+    # ------------------------------------------------------------------
+
+    @property
+    def _type_symbol_allowed_values(self) -> list[str]:
+        """Return chemical symbols accepted by *cryspy*.
+
+        Returns:
+            list[str]: Unique element/isotope symbols from the database.
+        """
+        return list({key[1] for key in DATABASE['Isotopes']})
+
+    @property
+    def _wyckoff_letter_allowed_values(self) -> list[str]:
+        """Return allowed Wyckoff-letter symbols.
+
+        Returns:
+            list[str]: Currently a hard-coded placeholder list.
+        """
+        # TODO: Need to now current space group. How to access it? Via
+        #  parent Cell? Then letters =
+        #  list(SPACE_GROUPS[62, 'cab']['Wyckoff_positions'].keys())
+        #  Temporarily return hardcoded list:
+        return ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']
+
+    @property
+    def _wyckoff_letter_default_value(self) -> str:
+        """Return the default Wyckoff letter.
+
+        Returns:
+            str: First element of the allowed values list.
+        """
+        # TODO: What to pass as default?
+        return self._wyckoff_letter_allowed_values[0]
+
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
+    @property
+    def label(self) -> StringDescriptor:
+        """Unique label for this atom site.
+
+        Returns:
+            StringDescriptor: Descriptor holding the site label.
+        """
+        return self._label
+
+    @label.setter
+    def label(self, value: str) -> None:
+        """Set the atom-site label.
+
+        Args:
+            value (str): New label string.
+        """
+        self._label.value = value
+
+    @property
+    def type_symbol(self) -> StringDescriptor:
+        """Chemical element or isotope symbol.
+
+        Returns:
+            StringDescriptor: Descriptor holding the type symbol.
+        """
+        return self._type_symbol
+
+    @type_symbol.setter
+    def type_symbol(self, value: str) -> None:
+        """Set the chemical element or isotope symbol.
+
+        Args:
+            value (str): New type symbol (must be in the *cryspy*
+                database).
+        """
+        self._type_symbol.value = value
+
+    @property
+    def adp_type(self) -> StringDescriptor:
+        """Type of atomic displacement parameter (e.g. ``'Biso'``).
+
+        Returns:
+            StringDescriptor: Descriptor holding the ADP type.
+        """
+        return self._adp_type
+
+    @adp_type.setter
+    def adp_type(self, value: str) -> None:
+        """Set the ADP type.
+
+        Args:
+            value (str): New ADP type string.
+        """
+        self._adp_type.value = value
+
+    @property
+    def wyckoff_letter(self) -> StringDescriptor:
+        """Wyckoff letter for the symmetry site.
+
+        Returns:
+            StringDescriptor: Descriptor holding the Wyckoff letter.
+        """
+        return self._wyckoff_letter
+
+    @wyckoff_letter.setter
+    def wyckoff_letter(self, value: str) -> None:
+        """Set the Wyckoff letter.
+
+        Args:
+            value (str): New Wyckoff letter.
+        """
+        self._wyckoff_letter.value = value
+
+    @property
+    def fract_x(self) -> Parameter:
+        """Fractional *x*-coordinate within the unit cell.
+
+        Returns:
+            Parameter: Descriptor for the *x* coordinate.
+        """
+        return self._fract_x
+
+    @fract_x.setter
+    def fract_x(self, value: float) -> None:
+        """Set the fractional *x*-coordinate.
+
+        Args:
+            value (float): New *x* coordinate.
+        """
+        self._fract_x.value = value
+
+    @property
+    def fract_y(self) -> Parameter:
+        """Fractional *y*-coordinate within the unit cell.
+
+        Returns:
+            Parameter: Descriptor for the *y* coordinate.
+        """
+        return self._fract_y
+
+    @fract_y.setter
+    def fract_y(self, value: float) -> None:
+        """Set the fractional *y*-coordinate.
+
+        Args:
+            value (float): New *y* coordinate.
+        """
+        self._fract_y.value = value
+
+    @property
+    def fract_z(self) -> Parameter:
+        """Fractional *z*-coordinate within the unit cell.
+
+        Returns:
+            Parameter: Descriptor for the *z* coordinate.
+        """
+        return self._fract_z
+
+    @fract_z.setter
+    def fract_z(self, value: float) -> None:
+        """Set the fractional *z*-coordinate.
+
+        Args:
+            value (float): New *z* coordinate.
+        """
+        self._fract_z.value = value
+
+    @property
+    def occupancy(self) -> Parameter:
+        """Site occupancy fraction.
+
+        Returns:
+            Parameter: Descriptor for the occupancy (0–1).
+        """
+        return self._occupancy
+
+    @occupancy.setter
+    def occupancy(self, value: float) -> None:
+        """Set the site occupancy.
+
+        Args:
+            value (float): New occupancy fraction.
+        """
+        self._occupancy.value = value
+
+    @property
+    def b_iso(self) -> Parameter:
+        r"""Isotropic atomic displacement parameter (*B*-factor).
+
+        Returns:
+            Parameter: Descriptor for *B*\_iso (Ų).
+        """
+        return self._b_iso
+
+    @b_iso.setter
+    def b_iso(self, value: float) -> None:
+        r"""Set the isotropic displacement parameter.
+
+        Args:
+            value (float): New *B*\_iso value in Ų.
+        """
+        self._b_iso.value = value
+
+
+@AtomSitesFactory.register
+class AtomSites(CategoryCollection):
+    """Collection of :class:`AtomSite` instances."""
+
+    type_info = TypeInfo(
+        tag='default',
+        description='Atom sites collection',
+    )
+
+    def __init__(self) -> None:
+        """Initialise an empty atom-sites collection."""
+        super().__init__(item_type=AtomSite)
+
+    # ------------------------------------------------------------------
+    #  Private helper methods
+    # ------------------------------------------------------------------
+
+    def _apply_atomic_coordinates_symmetry_constraints(self) -> None:
+        """Apply symmetry rules to fractional coordinates of every site.
+
+        Uses the parent structure's space-group symbol, IT coordinate
+        system code and each atom's Wyckoff letter.  Atoms without a
+        Wyckoff letter are silently skipped.
+        """
+        structure = self._parent
+        space_group_name = structure.space_group.name_h_m.value
+        space_group_coord_code = structure.space_group.it_coordinate_system_code.value
+        for atom in self._items:
+            dummy_atom = {
+                'fract_x': atom.fract_x.value,
+                'fract_y': atom.fract_y.value,
+                'fract_z': atom.fract_z.value,
+            }
+            wl = atom.wyckoff_letter.value
+            if not wl:
+                # TODO: Decide how to handle this case
+                continue
+            ecr.apply_atom_site_symmetry_constraints(
+                atom_site=dummy_atom,
+                name_hm=space_group_name,
+                coord_code=space_group_coord_code,
+                wyckoff_letter=wl,
+            )
+            atom.fract_x.value = dummy_atom['fract_x']
+            atom.fract_y.value = dummy_atom['fract_y']
+            atom.fract_z.value = dummy_atom['fract_z']
+
+    def _update(
+        self,
+        called_by_minimizer: bool = False,
+    ) -> None:
+        """Recalculate atom sites after a change.
+
+        Args:
+            called_by_minimizer (bool): Whether the update was triggered
+                by the fitting minimizer. Currently unused.
+        """
+        del called_by_minimizer
+
+        self._apply_atomic_coordinates_symmetry_constraints()
diff --git a/src/easydiffraction/datablocks/structure/categories/atom_sites/factory.py b/src/easydiffraction/datablocks/structure/categories/atom_sites/factory.py
new file mode 100644
index 00000000..e233d0bd
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/categories/atom_sites/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Atom-sites factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class AtomSitesFactory(FactoryBase):
+    """Create atom-sites collections by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/sample_models/categories/__init__.py b/src/easydiffraction/datablocks/structure/categories/cell/__init__.py
similarity index 65%
rename from src/easydiffraction/sample_models/categories/__init__.py
rename to src/easydiffraction/datablocks/structure/categories/cell/__init__.py
index 429f2648..08773b3e 100644
--- a/src/easydiffraction/sample_models/categories/__init__.py
+++ b/src/easydiffraction/datablocks/structure/categories/cell/__init__.py
@@ -1,2 +1,4 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.structure.categories.cell.default import Cell
diff --git a/src/easydiffraction/datablocks/structure/categories/cell/default.py b/src/easydiffraction/datablocks/structure/categories/cell/default.py
new file mode 100644
index 00000000..e07e20e0
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/categories/cell/default.py
@@ -0,0 +1,255 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Unit cell parameters category for structures."""
+
+from __future__ import annotations
+
+from easydiffraction.core.category import CategoryItem
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.validation import RangeValidator
+from easydiffraction.core.variable import Parameter
+from easydiffraction.crystallography import crystallography as ecr
+from easydiffraction.datablocks.structure.categories.cell.factory import CellFactory
+from easydiffraction.io.cif.handler import CifHandler
+
+
+@CellFactory.register
+class Cell(CategoryItem):
+    """Unit cell with lengths *a*, *b*, *c* and angles *alpha*, *beta*,
+    *gamma*.
+
+    All six lattice parameters are exposed as :class:`Parameter`
+    descriptors supporting validation, fitting and CIF serialization.
+    """
+
+    type_info = TypeInfo(
+        tag='default',
+        description='Unit cell parameters',
+    )
+
+    def __init__(self) -> None:
+        """Initialise the unit cell with default parameter values."""
+        super().__init__()
+
+        self._length_a = Parameter(
+            name='length_a',
+            description='Length of the a axis of the unit cell.',
+            units='Å',
+            value_spec=AttributeSpec(
+                default=10.0,
+                validator=RangeValidator(ge=0, le=1000),
+            ),
+            cif_handler=CifHandler(names=['_cell.length_a']),
+        )
+        self._length_b = Parameter(
+            name='length_b',
+            description='Length of the b axis of the unit cell.',
+            units='Å',
+            value_spec=AttributeSpec(
+                default=10.0,
+                validator=RangeValidator(ge=0, le=1000),
+            ),
+            cif_handler=CifHandler(names=['_cell.length_b']),
+        )
+        self._length_c = Parameter(
+            name='length_c',
+            description='Length of the c axis of the unit cell.',
+            units='Å',
+            value_spec=AttributeSpec(
+                default=10.0,
+                validator=RangeValidator(ge=0, le=1000),
+            ),
+            cif_handler=CifHandler(names=['_cell.length_c']),
+        )
+        self._angle_alpha = Parameter(
+            name='angle_alpha',
+            description='Angle between edges b and c.',
+            units='deg',
+            value_spec=AttributeSpec(
+                default=90.0,
+                validator=RangeValidator(ge=0, le=180),
+            ),
+            cif_handler=CifHandler(names=['_cell.angle_alpha']),
+        )
+        self._angle_beta = Parameter(
+            name='angle_beta',
+            description='Angle between edges a and c.',
+            units='deg',
+            value_spec=AttributeSpec(
+                default=90.0,
+                validator=RangeValidator(ge=0, le=180),
+            ),
+            cif_handler=CifHandler(names=['_cell.angle_beta']),
+        )
+        self._angle_gamma = Parameter(
+            name='angle_gamma',
+            description='Angle between edges a and b.',
+            units='deg',
+            value_spec=AttributeSpec(
+                default=90.0,
+                validator=RangeValidator(ge=0, le=180),
+            ),
+            cif_handler=CifHandler(names=['_cell.angle_gamma']),
+        )
+
+        self._identity.category_code = 'cell'
+
+    # ------------------------------------------------------------------
+    #  Private helper methods
+    # ------------------------------------------------------------------
+
+    def _apply_cell_symmetry_constraints(self) -> None:
+        """Apply symmetry constraints to cell parameters in place.
+
+        Uses the parent structure's space-group symbol to determine
+        which lattice parameters are dependent and sets them
+        accordingly.
+        """
+        dummy_cell = {
+            'lattice_a': self.length_a.value,
+            'lattice_b': self.length_b.value,
+            'lattice_c': self.length_c.value,
+            'angle_alpha': self.angle_alpha.value,
+            'angle_beta': self.angle_beta.value,
+            'angle_gamma': self.angle_gamma.value,
+        }
+        space_group_name = self._parent.space_group.name_h_m.value
+
+        ecr.apply_cell_symmetry_constraints(
+            cell=dummy_cell,
+            name_hm=space_group_name,
+        )
+
+        self.length_a.value = dummy_cell['lattice_a']
+        self.length_b.value = dummy_cell['lattice_b']
+        self.length_c.value = dummy_cell['lattice_c']
+        self.angle_alpha.value = dummy_cell['angle_alpha']
+        self.angle_beta.value = dummy_cell['angle_beta']
+        self.angle_gamma.value = dummy_cell['angle_gamma']
+
+    def _update(
+        self,
+        called_by_minimizer: bool = False,
+    ) -> None:
+        """Recalculate cell parameters after a change.
+
+        Args:
+            called_by_minimizer (bool): Whether the update was triggered
+                by the fitting minimizer. Currently unused.
+        """
+        del called_by_minimizer  # TODO: ???
+
+        self._apply_cell_symmetry_constraints()
+
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
+    @property
+    def length_a(self) -> Parameter:
+        """Length of the *a* axis.
+
+        Returns:
+            Parameter: Descriptor for lattice parameter *a* (Å).
+        """
+        return self._length_a
+
+    @length_a.setter
+    def length_a(self, value: float) -> None:
+        """Set the length of the *a* axis.
+
+        Args:
+            value (float): New length in ångströms.
+        """
+        self._length_a.value = value
+
+    @property
+    def length_b(self) -> Parameter:
+        """Length of the *b* axis.
+
+        Returns:
+            Parameter: Descriptor for lattice parameter *b* (Å).
+        """
+        return self._length_b
+
+    @length_b.setter
+    def length_b(self, value: float) -> None:
+        """Set the length of the *b* axis.
+
+        Args:
+            value (float): New length in ångströms.
+        """
+        self._length_b.value = value
+
+    @property
+    def length_c(self) -> Parameter:
+        """Length of the *c* axis.
+
+        Returns:
+            Parameter: Descriptor for lattice parameter *c* (Å).
+        """
+        return self._length_c
+
+    @length_c.setter
+    def length_c(self, value: float) -> None:
+        """Set the length of the *c* axis.
+
+        Args:
+            value (float): New length in ångströms.
+        """
+        self._length_c.value = value
+
+    @property
+    def angle_alpha(self) -> Parameter:
+        """Angle between edges *b* and *c*.
+
+        Returns:
+            Parameter: Descriptor for angle *α* (degrees).
+        """
+        return self._angle_alpha
+
+    @angle_alpha.setter
+    def angle_alpha(self, value: float) -> None:
+        """Set the angle between edges *b* and *c*.
+
+        Args:
+            value (float): New angle in degrees.
+        """
+        self._angle_alpha.value = value
+
+    @property
+    def angle_beta(self) -> Parameter:
+        """Angle between edges *a* and *c*.
+
+        Returns:
+            Parameter: Descriptor for angle *β* (degrees).
+        """
+        return self._angle_beta
+
+    @angle_beta.setter
+    def angle_beta(self, value: float) -> None:
+        """Set the angle between edges *a* and *c*.
+
+        Args:
+            value (float): New angle in degrees.
+        """
+        self._angle_beta.value = value
+
+    @property
+    def angle_gamma(self) -> Parameter:
+        """Angle between edges *a* and *b*.
+
+        Returns:
+            Parameter: Descriptor for angle *γ* (degrees).
+        """
+        return self._angle_gamma
+
+    @angle_gamma.setter
+    def angle_gamma(self, value: float) -> None:
+        """Set the angle between edges *a* and *b*.
+
+        Args:
+            value (float): New angle in degrees.
+        """
+        self._angle_gamma.value = value
diff --git a/src/easydiffraction/datablocks/structure/categories/cell/factory.py b/src/easydiffraction/datablocks/structure/categories/cell/factory.py
new file mode 100644
index 00000000..c5fde941
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/categories/cell/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Cell factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class CellFactory(FactoryBase):
+    """Create unit-cell categories by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/sample_models/sample_model/__init__.py b/src/easydiffraction/datablocks/structure/categories/space_group/__init__.py
similarity index 61%
rename from src/easydiffraction/sample_models/sample_model/__init__.py
rename to src/easydiffraction/datablocks/structure/categories/space_group/__init__.py
index 429f2648..daf02947 100644
--- a/src/easydiffraction/sample_models/sample_model/__init__.py
+++ b/src/easydiffraction/datablocks/structure/categories/space_group/__init__.py
@@ -1,2 +1,4 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.structure.categories.space_group.default import SpaceGroup
diff --git a/src/easydiffraction/datablocks/structure/categories/space_group/default.py b/src/easydiffraction/datablocks/structure/categories/space_group/default.py
new file mode 100644
index 00000000..7076d272
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/categories/space_group/default.py
@@ -0,0 +1,167 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Space group category for crystallographic structures."""
+
+from __future__ import annotations
+
+from cryspy.A_functions_base.function_2_space_group import ACCESIBLE_NAME_HM_SHORT
+from cryspy.A_functions_base.function_2_space_group import (
+    get_it_coordinate_system_codes_by_it_number,
+)
+from cryspy.A_functions_base.function_2_space_group import get_it_number_by_name_hm_short
+
+from easydiffraction.core.category import CategoryItem
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.validation import MembershipValidator
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.datablocks.structure.categories.space_group.factory import SpaceGroupFactory
+from easydiffraction.io.cif.handler import CifHandler
+
+
+@SpaceGroupFactory.register
+class SpaceGroup(CategoryItem):
+    """Space group with Hermann–Mauguin symbol and IT coordinate system
+    code.
+
+    Holds the space-group symbol (``name_h_m``) and the International
+    Tables coordinate-system qualifier (``it_coordinate_system_code``).
+    Changing the symbol automatically resets the coordinate-system code
+    to the first allowed value for the new group.
+    """
+
+    type_info = TypeInfo(
+        tag='default',
+        description='Space group symmetry',
+    )
+
+    def __init__(self) -> None:
+        """Initialise the space group with default values."""
+        super().__init__()
+
+        self._name_h_m = StringDescriptor(
+            name='name_h_m',
+            description='Hermann-Mauguin symbol of the space group.',
+            value_spec=AttributeSpec(
+                default='P 1',
+                validator=MembershipValidator(
+                    allowed=lambda: self._name_h_m_allowed_values,
+                ),
+            ),
+            cif_handler=CifHandler(
+                # TODO: Keep only version with "." and automate ...
+                names=[
+                    '_space_group.name_H-M_alt',
+                    '_space_group_name_H-M_alt',
+                    '_symmetry.space_group_name_H-M',
+                    '_symmetry_space_group_name_H-M',
+                ]
+            ),
+        )
+        self._it_coordinate_system_code = StringDescriptor(
+            name='it_coordinate_system_code',
+            description='A qualifier identifying which setting in IT is used.',
+            value_spec=AttributeSpec(
+                default=lambda: self._it_coordinate_system_code_default_value,
+                validator=MembershipValidator(
+                    allowed=lambda: self._it_coordinate_system_code_allowed_values
+                ),
+            ),
+            cif_handler=CifHandler(
+                names=[
+                    '_space_group.IT_coordinate_system_code',
+                    '_space_group_IT_coordinate_system_code',
+                    '_symmetry.IT_coordinate_system_code',
+                    '_symmetry_IT_coordinate_system_code',
+                ]
+            ),
+        )
+
+        self._identity.category_code = 'space_group'
+
+    # ------------------------------------------------------------------
+    #  Private helper methods
+    # ------------------------------------------------------------------
+
+    def _reset_it_coordinate_system_code(self) -> None:
+        """Reset the IT coordinate system code to the default for the
+        current group.
+        """
+        self._it_coordinate_system_code.value = self._it_coordinate_system_code_default_value
+
+    @property
+    def _name_h_m_allowed_values(self) -> list[str]:
+        """Return the list of recognised Hermann–Mauguin short symbols.
+
+        Returns:
+            list[str]: All short H-M symbols known to *cryspy*.
+        """
+        return ACCESIBLE_NAME_HM_SHORT
+
+    @property
+    def _it_coordinate_system_code_allowed_values(self) -> list[str]:
+        """Return allowed IT coordinate system codes for the current
+        group.
+
+        Returns:
+            list[str]: Coordinate-system codes, or ``['']`` when none
+                are defined.
+        """
+        name = self.name_h_m.value
+        it_number = get_it_number_by_name_hm_short(name)
+        codes = get_it_coordinate_system_codes_by_it_number(it_number)
+        codes = [str(code) for code in codes]
+        return codes if codes else ['']
+
+    @property
+    def _it_coordinate_system_code_default_value(self) -> str:
+        """Return the default IT coordinate system code.
+
+        Returns:
+            str: First element of the allowed codes list.
+        """
+        return self._it_coordinate_system_code_allowed_values[0]
+
+    # ------------------------------------------------------------------
+    #  Public properties
+    # ------------------------------------------------------------------
+
+    @property
+    def name_h_m(self) -> StringDescriptor:
+        """Hermann–Mauguin symbol of the space group.
+
+        Returns:
+            StringDescriptor: Descriptor holding the H-M symbol.
+        """
+        return self._name_h_m
+
+    @name_h_m.setter
+    def name_h_m(self, value: str) -> None:
+        """Set the Hermann–Mauguin symbol and reset the coordinate-
+        system code.
+
+        Args:
+            value (str): New H-M symbol (must be a recognised short
+                symbol).
+        """
+        self._name_h_m.value = value
+        self._reset_it_coordinate_system_code()
+
+    @property
+    def it_coordinate_system_code(self) -> StringDescriptor:
+        """International Tables coordinate-system code.
+
+        Returns:
+            StringDescriptor: Descriptor holding the IT code.
+        """
+        return self._it_coordinate_system_code
+
+    @it_coordinate_system_code.setter
+    def it_coordinate_system_code(self, value: str) -> None:
+        """Set the IT coordinate-system code.
+
+        Args:
+            value (str): New coordinate-system code (must be allowed for
+                the current space group).
+        """
+        self._it_coordinate_system_code.value = value
diff --git a/src/easydiffraction/datablocks/structure/categories/space_group/factory.py b/src/easydiffraction/datablocks/structure/categories/space_group/factory.py
new file mode 100644
index 00000000..87807cef
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/categories/space_group/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Space-group factory — delegates entirely to ``FactoryBase``."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class SpaceGroupFactory(FactoryBase):
+    """Create space-group categories by tag."""
+
+    _default_rules = {
+        frozenset(): 'default',
+    }
diff --git a/src/easydiffraction/datablocks/structure/collection.py b/src/easydiffraction/datablocks/structure/collection.py
new file mode 100644
index 00000000..ecc8f26e
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/collection.py
@@ -0,0 +1,82 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Collection of structure data blocks."""
+
+from typeguard import typechecked
+
+from easydiffraction.core.datablock import DatablockCollection
+from easydiffraction.datablocks.structure.item.base import Structure
+from easydiffraction.datablocks.structure.item.factory import StructureFactory
+from easydiffraction.utils.logging import console
+
+
+class Structures(DatablockCollection):
+    """Ordered collection of :class:`Structure` instances.
+
+    Provides convenience ``add_from_*`` methods that mirror the
+    :class:`StructureFactory` classmethods plus a bare :meth:`add` for
+    inserting pre-built structures.
+    """
+
+    def __init__(self) -> None:
+        """Initialise an empty structures collection."""
+        super().__init__(item_type=Structure)
+
+    # ------------------------------------------------------------------
+    # Public methods
+    # ------------------------------------------------------------------
+
+    # TODO: Make abstract in DatablockCollection?
+    @typechecked
+    def create(
+        self,
+        *,
+        name: str,
+    ) -> None:
+        """Create a minimal structure and add it to the collection.
+
+        Args:
+            name (str): Identifier for the new structure.
+        """
+        structure = StructureFactory.from_scratch(name=name)
+        self.add(structure)
+
+    # TODO: Move to DatablockCollection?
+    @typechecked
+    def add_from_cif_str(
+        self,
+        cif_str: str,
+    ) -> None:
+        """Create a structure from CIF content and add it.
+
+        Args:
+            cif_str (str): CIF file content as a string.
+        """
+        structure = StructureFactory.from_cif_str(cif_str)
+        self.add(structure)
+
+    # TODO: Move to DatablockCollection?
+    @typechecked
+    def add_from_cif_path(
+        self,
+        cif_path: str,
+    ) -> None:
+        """Create a structure from a CIF file and add it.
+
+        Args:
+            cif_path (str): Filesystem path to a CIF file.
+        """
+        structure = StructureFactory.from_cif_path(cif_path)
+        self.add(structure)
+
+    # TODO: Move to DatablockCollection?
+    def show_names(self) -> None:
+        """List all structure names in the collection."""
+        console.paragraph('Defined structures' + ' 🧩')
+        console.print(self.names)
+
+    # TODO: Move to DatablockCollection?
+    def show_params(self) -> None:
+        """Show parameters of all structures in the collection."""
+        for structure in self.values():
+            structure.show_params()
diff --git a/src/easydiffraction/sample_models/__init__.py b/src/easydiffraction/datablocks/structure/item/__init__.py
similarity index 100%
rename from src/easydiffraction/sample_models/__init__.py
rename to src/easydiffraction/datablocks/structure/item/__init__.py
diff --git a/src/easydiffraction/datablocks/structure/item/base.py b/src/easydiffraction/datablocks/structure/item/base.py
new file mode 100644
index 00000000..cd517785
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/item/base.py
@@ -0,0 +1,233 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Structure datablock item."""
+
+from typeguard import typechecked
+
+from easydiffraction.core.datablock import DatablockItem
+from easydiffraction.datablocks.structure.categories.atom_sites import AtomSites
+from easydiffraction.datablocks.structure.categories.atom_sites.factory import AtomSitesFactory
+from easydiffraction.datablocks.structure.categories.cell import Cell
+from easydiffraction.datablocks.structure.categories.cell.factory import CellFactory
+from easydiffraction.datablocks.structure.categories.space_group import SpaceGroup
+from easydiffraction.datablocks.structure.categories.space_group.factory import SpaceGroupFactory
+from easydiffraction.utils.logging import console
+from easydiffraction.utils.logging import log
+from easydiffraction.utils.utils import render_cif
+
+
+class Structure(DatablockItem):
+    """Structure datablock item."""
+
+    def __init__(
+        self,
+        *,
+        name: str,
+    ) -> None:
+        super().__init__()
+        self._name = name
+        self._cell_type: str = CellFactory.default_tag()
+        self._cell = CellFactory.create(self._cell_type)
+        self._space_group_type: str = SpaceGroupFactory.default_tag()
+        self._space_group = SpaceGroupFactory.create(self._space_group_type)
+        self._atom_sites_type: str = AtomSitesFactory.default_tag()
+        self._atom_sites = AtomSitesFactory.create(self._atom_sites_type)
+        self._identity.datablock_entry_name = lambda: self.name
+
+    # ------------------------------------------------------------------
+    # Public properties
+    # ------------------------------------------------------------------
+
+    @property
+    def name(self) -> str:
+        """Name identifier for this structure.
+
+        Returns:
+            str: The structure's name.
+        """
+        return self._name
+
+    @name.setter
+    @typechecked
+    def name(self, new: str) -> None:
+        """Set the name identifier for this structure.
+
+        Args:
+            new (str): New name string.
+        """
+        self._name = new
+
+    # ------------------------------------------------------------------
+    #  Cell (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def cell(self) -> Cell:
+        """Unit-cell category for this structure."""
+        return self._cell
+
+    @cell.setter
+    @typechecked
+    def cell(self, new: Cell) -> None:
+        """Replace the unit-cell category for this structure.
+
+        Args:
+            new (Cell): New unit-cell instance.
+        """
+        self._cell = new
+
+    @property
+    def cell_type(self) -> str:
+        """Tag of the active unit-cell type."""
+        return self._cell_type
+
+    @cell_type.setter
+    def cell_type(self, new_type: str) -> None:
+        """Switch to a different unit-cell type.
+
+        Args:
+            new_type: Cell tag (e.g. ``'default'``).
+        """
+        supported_tags = CellFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported cell type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_cell_types()'",
+            )
+            return
+        self._cell = CellFactory.create(new_type)
+        self._cell_type = new_type
+        console.paragraph(f"Cell type for structure '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_cell_types(self) -> None:
+        """Print a table of supported unit-cell types."""
+        CellFactory.show_supported()
+
+    def show_current_cell_type(self) -> None:
+        """Print the currently used unit-cell type."""
+        console.paragraph('Current cell type')
+        console.print(self.cell_type)
+
+    # ------------------------------------------------------------------
+    #  Space group (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def space_group(self) -> SpaceGroup:
+        """Space-group category for this structure."""
+        return self._space_group
+
+    @space_group.setter
+    @typechecked
+    def space_group(self, new: SpaceGroup) -> None:
+        """Replace the space-group category for this structure.
+
+        Args:
+            new (SpaceGroup): New space-group instance.
+        """
+        self._space_group = new
+
+    @property
+    def space_group_type(self) -> str:
+        """Tag of the active space-group type."""
+        return self._space_group_type
+
+    @space_group_type.setter
+    def space_group_type(self, new_type: str) -> None:
+        """Switch to a different space-group type.
+
+        Args:
+            new_type: Space-group tag (e.g. ``'default'``).
+        """
+        supported_tags = SpaceGroupFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported space group type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_space_group_types()'",
+            )
+            return
+        self._space_group = SpaceGroupFactory.create(new_type)
+        self._space_group_type = new_type
+        console.paragraph(f"Space group type for structure '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_space_group_types(self) -> None:
+        """Print a table of supported space-group types."""
+        SpaceGroupFactory.show_supported()
+
+    def show_current_space_group_type(self) -> None:
+        """Print the currently used space-group type."""
+        console.paragraph('Current space group type')
+        console.print(self.space_group_type)
+
+    # ------------------------------------------------------------------
+    #  Atom sites (switchable-category pattern)
+    # ------------------------------------------------------------------
+
+    @property
+    def atom_sites(self) -> AtomSites:
+        """Atom-sites collection for this structure."""
+        return self._atom_sites
+
+    @atom_sites.setter
+    @typechecked
+    def atom_sites(self, new: AtomSites) -> None:
+        """Replace the atom-sites collection for this structure.
+
+        Args:
+            new (AtomSites): New atom-sites collection.
+        """
+        self._atom_sites = new
+
+    @property
+    def atom_sites_type(self) -> str:
+        """Tag of the active atom-sites collection type."""
+        return self._atom_sites_type
+
+    @atom_sites_type.setter
+    def atom_sites_type(self, new_type: str) -> None:
+        """Switch to a different atom-sites collection type.
+
+        Args:
+            new_type: Atom-sites tag (e.g. ``'default'``).
+        """
+        supported_tags = AtomSitesFactory.supported_tags()
+        if new_type not in supported_tags:
+            log.warning(
+                f"Unsupported atom sites type '{new_type}'. "
+                f'Supported: {supported_tags}. '
+                f"For more information, use 'show_supported_atom_sites_types()'",
+            )
+            return
+        self._atom_sites = AtomSitesFactory.create(new_type)
+        self._atom_sites_type = new_type
+        console.paragraph(f"Atom sites type for structure '{self.name}' changed to")
+        console.print(new_type)
+
+    def show_supported_atom_sites_types(self) -> None:
+        """Print a table of supported atom-sites collection types."""
+        AtomSitesFactory.show_supported()
+
+    def show_current_atom_sites_type(self) -> None:
+        """Print the currently used atom-sites collection type."""
+        console.paragraph('Current atom sites type')
+        console.print(self.atom_sites_type)
+
+    # ------------------------------------------------------------------
+    # Public methods
+    # ------------------------------------------------------------------
+
+    def show(self) -> None:
+        """Display an ASCII projection of the structure on a 2D
+        plane.
+        """
+        console.paragraph(f"Structure 🧩 '{self.name}'")
+        console.print('Not implemented yet.')
+
+    def show_as_cif(self) -> None:
+        """Render the CIF text for this structure in the terminal."""
+        console.paragraph(f"Structure 🧩 '{self.name}' as cif")
+        render_cif(self.as_cif)
diff --git a/src/easydiffraction/datablocks/structure/item/factory.py b/src/easydiffraction/datablocks/structure/item/factory.py
new file mode 100644
index 00000000..a2067dff
--- /dev/null
+++ b/src/easydiffraction/datablocks/structure/item/factory.py
@@ -0,0 +1,116 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+"""Factory for creating structure instances from various inputs.
+
+Provides individual class methods for each creation pathway:
+``from_scratch``, ``from_cif_path``, or ``from_cif_str``.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from typeguard import typechecked
+
+from easydiffraction.datablocks.structure.item.base import Structure
+from easydiffraction.io.cif.parse import document_from_path
+from easydiffraction.io.cif.parse import document_from_string
+from easydiffraction.io.cif.parse import name_from_block
+from easydiffraction.io.cif.parse import pick_sole_block
+from easydiffraction.utils.logging import log
+
+if TYPE_CHECKING:
+    import gemmi
+
+
+class StructureFactory:
+    """Create :class:`Structure` instances from supported inputs."""
+
+    def __init__(self):
+        log.error(
+            'Structure objects must be created using class methods such as '
+            '`StructureFactory.from_cif_str(...)`, etc.'
+        )
+
+    # ------------------------------------------------------------------
+    # Private helper methods
+    # ------------------------------------------------------------------
+
+    @classmethod
+    # TODO: @typechecked fails to find gemmi?
+    def _from_gemmi_block(
+        cls,
+        block: gemmi.cif.Block,
+    ) -> Structure:
+        """Build a structure from a single *gemmi* CIF block.
+
+        Args:
+            block (gemmi.cif.Block): Parsed CIF data block.
+
+        Returns:
+            Structure: A fully populated structure instance.
+        """
+        name = name_from_block(block)
+        structure = Structure(name=name)
+        for category in structure.categories:
+            category.from_cif(block)
+        return structure
+
+    # ------------------------------------------------------------------
+    # Public methods
+    # ------------------------------------------------------------------
+
+    @classmethod
+    @typechecked
+    def from_scratch(
+        cls,
+        *,
+        name: str,
+    ) -> Structure:
+        """Create a minimal default structure.
+
+        Args:
+            name (str): Identifier for the new structure.
+
+        Returns:
+            Structure: An empty structure with default categories.
+        """
+        return Structure(name=name)
+
+    # TODO: add minimal default configuration for missing parameters
+    @classmethod
+    @typechecked
+    def from_cif_str(
+        cls,
+        cif_str: str,
+    ) -> Structure:
+        """Create a structure by parsing a CIF string.
+
+        Args:
+            cif_str (str): Raw CIF content.
+
+        Returns:
+            Structure: A populated structure instance.
+        """
+        doc = document_from_string(cif_str)
+        block = pick_sole_block(doc)
+        return cls._from_gemmi_block(block)
+
+    # TODO: Read content and call self.from_cif_str
+    @classmethod
+    @typechecked
+    def from_cif_path(
+        cls,
+        cif_path: str,
+    ) -> Structure:
+        """Create a structure by reading and parsing a CIF file.
+
+        Args:
+            cif_path (str): Filesystem path to a CIF file.
+
+        Returns:
+            Structure: A populated structure instance.
+        """
+        doc = document_from_path(cif_path)
+        block = pick_sole_block(doc)
+        return cls._from_gemmi_block(block)
diff --git a/src/easydiffraction/display/base.py b/src/easydiffraction/display/base.py
index c3babcff..d97f7be9 100644
--- a/src/easydiffraction/display/base.py
+++ b/src/easydiffraction/display/base.py
@@ -12,7 +12,7 @@
 
 import pandas as pd
 
-from easydiffraction.core.singletons import SingletonBase
+from easydiffraction.core.singleton import SingletonBase
 from easydiffraction.utils.logging import console
 from easydiffraction.utils.logging import log
 
diff --git a/src/easydiffraction/display/plotters/base.py b/src/easydiffraction/display/plotters/base.py
index 85892950..7220dfeb 100644
--- a/src/easydiffraction/display/plotters/base.py
+++ b/src/easydiffraction/display/plotters/base.py
@@ -8,9 +8,9 @@
 
 import numpy as np
 
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import SampleFormEnum
-from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 
 DEFAULT_HEIGHT = 25
 DEFAULT_MIN = -np.inf
diff --git a/src/easydiffraction/experiments/categories/background/factory.py b/src/easydiffraction/experiments/categories/background/factory.py
deleted file mode 100644
index 716260f4..00000000
--- a/src/easydiffraction/experiments/categories/background/factory.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Background collection entry point (public facade).
-
-End users should import Background classes from this module. Internals
-live under the package
-`easydiffraction.experiments.category_collections.background_types`
-and are re-exported here for a stable and readable API.
-"""
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from typing import Optional
-
-from easydiffraction.experiments.categories.background.enums import BackgroundTypeEnum
-
-if TYPE_CHECKING:
-    from easydiffraction.experiments.categories.background import BackgroundBase
-
-
-class BackgroundFactory:
-    """Create background collections by type."""
-
-    BT = BackgroundTypeEnum
-
-    @classmethod
-    def _supported_map(cls) -> dict:
-        """Return mapping of enum values to concrete background
-        classes.
-        """
-        # Lazy import to avoid circulars
-        from easydiffraction.experiments.categories.background.chebyshev import (
-            ChebyshevPolynomialBackground,
-        )
-        from easydiffraction.experiments.categories.background.line_segment import (
-            LineSegmentBackground,
-        )
-
-        return {
-            cls.BT.LINE_SEGMENT: LineSegmentBackground,
-            cls.BT.CHEBYSHEV: ChebyshevPolynomialBackground,
-        }
-
-    @classmethod
-    def create(
-        cls,
-        background_type: Optional[BackgroundTypeEnum] = None,
-    ) -> BackgroundBase:
-        """Instantiate a background collection of requested type.
-
-        If type is None, the default enum value is used.
-        """
-        if background_type is None:
-            background_type = BackgroundTypeEnum.default()
-
-        supported = cls._supported_map()
-        if background_type not in supported:
-            supported_types = list(supported.keys())
-            raise ValueError(
-                f"Unsupported background type: '{background_type}'. "
-                f'Supported background types: {[bt.value for bt in supported_types]}'
-            )
-
-        background_class = supported[background_type]
-        return background_class()
diff --git a/src/easydiffraction/experiments/categories/data/factory.py b/src/easydiffraction/experiments/categories/data/factory.py
deleted file mode 100644
index a7d4df0a..00000000
--- a/src/easydiffraction/experiments/categories/data/factory.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from typing import Optional
-
-from easydiffraction.experiments.categories.data.bragg_pd import PdCwlData
-from easydiffraction.experiments.categories.data.bragg_pd import PdTofData
-from easydiffraction.experiments.categories.data.bragg_sc import ReflnData
-from easydiffraction.experiments.categories.data.total_pd import TotalData
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import SampleFormEnum
-from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-
-if TYPE_CHECKING:
-    from easydiffraction.core.category import CategoryCollection
-
-
-class DataFactory:
-    """Factory for creating diffraction data collections."""
-
-    _supported = {
-        SampleFormEnum.POWDER: {
-            ScatteringTypeEnum.BRAGG: {
-                BeamModeEnum.CONSTANT_WAVELENGTH: PdCwlData,
-                BeamModeEnum.TIME_OF_FLIGHT: PdTofData,
-            },
-            ScatteringTypeEnum.TOTAL: {
-                BeamModeEnum.CONSTANT_WAVELENGTH: TotalData,
-                BeamModeEnum.TIME_OF_FLIGHT: TotalData,
-            },
-        },
-        SampleFormEnum.SINGLE_CRYSTAL: {
-            ScatteringTypeEnum.BRAGG: {
-                BeamModeEnum.CONSTANT_WAVELENGTH: ReflnData,
-                BeamModeEnum.TIME_OF_FLIGHT: ReflnData,
-            },
-        },
-    }
-
-    @classmethod
-    def create(
-        cls,
-        *,
-        sample_form: Optional[SampleFormEnum] = None,
-        beam_mode: Optional[BeamModeEnum] = None,
-        scattering_type: Optional[ScatteringTypeEnum] = None,
-    ) -> CategoryCollection:
-        """Create a data collection for the given configuration."""
-        if sample_form is None:
-            sample_form = SampleFormEnum.default()
-        if beam_mode is None:
-            beam_mode = BeamModeEnum.default()
-        if scattering_type is None:
-            scattering_type = ScatteringTypeEnum.default()
-
-        supported_sample_forms = list(cls._supported.keys())
-        if sample_form not in supported_sample_forms:
-            raise ValueError(
-                f"Unsupported sample form: '{sample_form}'.\n"
-                f'Supported sample forms: {supported_sample_forms}'
-            )
-
-        supported_scattering_types = list(cls._supported[sample_form].keys())
-        if scattering_type not in supported_scattering_types:
-            raise ValueError(
-                f"Unsupported scattering type: '{scattering_type}' for sample form: "
-                f"'{sample_form}'.\n Supported scattering types: '{supported_scattering_types}'"
-            )
-        supported_beam_modes = list(cls._supported[sample_form][scattering_type].keys())
-        if beam_mode not in supported_beam_modes:
-            raise ValueError(
-                f"Unsupported beam mode: '{beam_mode}' for sample form: "
-                f"'{sample_form}' and scattering type '{scattering_type}'.\n"
-                f"Supported beam modes: '{supported_beam_modes}'"
-            )
-
-        data_class = cls._supported[sample_form][scattering_type][beam_mode]
-        data_obj = data_class()
-
-        return data_obj
diff --git a/src/easydiffraction/experiments/categories/instrument/factory.py b/src/easydiffraction/experiments/categories/instrument/factory.py
deleted file mode 100644
index d1d5982c..00000000
--- a/src/easydiffraction/experiments/categories/instrument/factory.py
+++ /dev/null
@@ -1,95 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Factory for instrument category items.
-
-Provides a stable entry point for creating instrument objects from the
-experiment's scattering type and beam mode.
-"""
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from typing import Optional
-from typing import Type
-
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import SampleFormEnum
-from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-
-if TYPE_CHECKING:
-    from easydiffraction.experiments.categories.instrument.base import InstrumentBase
-
-
-class InstrumentFactory:
-    """Create instrument instances for supported modes.
-
-    The factory hides implementation details and lazy-loads concrete
-    instrument classes to avoid circular imports.
-    """
-
-    ST = ScatteringTypeEnum
-    BM = BeamModeEnum
-    SF = SampleFormEnum
-
-    @classmethod
-    def _supported_map(cls) -> dict:
-        # Lazy import to avoid circulars
-        from easydiffraction.experiments.categories.instrument.cwl import CwlPdInstrument
-        from easydiffraction.experiments.categories.instrument.cwl import CwlScInstrument
-        from easydiffraction.experiments.categories.instrument.tof import TofPdInstrument
-        from easydiffraction.experiments.categories.instrument.tof import TofScInstrument
-
-        return {
-            cls.ST.BRAGG: {
-                cls.BM.CONSTANT_WAVELENGTH: {
-                    cls.SF.POWDER: CwlPdInstrument,
-                    cls.SF.SINGLE_CRYSTAL: CwlScInstrument,
-                },
-                cls.BM.TIME_OF_FLIGHT: {
-                    cls.SF.POWDER: TofPdInstrument,
-                    cls.SF.SINGLE_CRYSTAL: TofScInstrument,
-                },
-            }
-        }
-
-    @classmethod
-    def create(
-        cls,
-        scattering_type: Optional[ScatteringTypeEnum] = None,
-        beam_mode: Optional[BeamModeEnum] = None,
-        sample_form: Optional[SampleFormEnum] = None,
-    ) -> InstrumentBase:
-        if beam_mode is None:
-            beam_mode = BeamModeEnum.default()
-        if scattering_type is None:
-            scattering_type = ScatteringTypeEnum.default()
-        if sample_form is None:
-            sample_form = SampleFormEnum.default()
-
-        supported = cls._supported_map()
-
-        supported_scattering_types = list(supported.keys())
-        if scattering_type not in supported_scattering_types:
-            raise ValueError(
-                f"Unsupported scattering type: '{scattering_type}'.\n "
-                f'Supported scattering types: {supported_scattering_types}'
-            )
-
-        supported_beam_modes = list(supported[scattering_type].keys())
-        if beam_mode not in supported_beam_modes:
-            raise ValueError(
-                f"Unsupported beam mode: '{beam_mode}' for scattering type: "
-                f"'{scattering_type}'.\n "
-                f'Supported beam modes: {supported_beam_modes}'
-            )
-
-        supported_sample_forms = list(supported[scattering_type][beam_mode].keys())
-        if sample_form not in supported_sample_forms:
-            raise ValueError(
-                f"Unsupported sample form: '{sample_form}' for scattering type: "
-                f"'{scattering_type}' and beam mode: '{beam_mode}'.\n "
-                f'Supported sample forms: {supported_sample_forms}'
-            )
-
-        instrument_class: Type[InstrumentBase] = supported[scattering_type][beam_mode][sample_form]
-        return instrument_class()
diff --git a/src/easydiffraction/experiments/categories/peak/cwl.py b/src/easydiffraction/experiments/categories/peak/cwl.py
deleted file mode 100644
index 76777a44..00000000
--- a/src/easydiffraction/experiments/categories/peak/cwl.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Constant-wavelength peak profile classes."""
-
-from easydiffraction.experiments.categories.peak.base import PeakBase
-from easydiffraction.experiments.categories.peak.cwl_mixins import CwlBroadeningMixin
-from easydiffraction.experiments.categories.peak.cwl_mixins import EmpiricalAsymmetryMixin
-from easydiffraction.experiments.categories.peak.cwl_mixins import FcjAsymmetryMixin
-
-
-class CwlPseudoVoigt(
-    PeakBase,
-    CwlBroadeningMixin,
-):
-    """Constant-wavelength pseudo-Voigt peak shape."""
-
-    def __init__(self) -> None:
-        super().__init__()
-        self._add_constant_wavelength_broadening()
-
-
-class CwlSplitPseudoVoigt(
-    PeakBase,
-    CwlBroadeningMixin,
-    EmpiricalAsymmetryMixin,
-):
-    """Split pseudo-Voigt (empirical asymmetry) for CWL mode."""
-
-    def __init__(self) -> None:
-        super().__init__()
-        self._add_constant_wavelength_broadening()
-        self._add_empirical_asymmetry()
-
-
-class CwlThompsonCoxHastings(
-    PeakBase,
-    CwlBroadeningMixin,
-    FcjAsymmetryMixin,
-):
-    """Thompson–Cox–Hastings with FCJ asymmetry for CWL mode."""
-
-    def __init__(self) -> None:
-        super().__init__()
-        self._add_constant_wavelength_broadening()
-        self._add_fcj_asymmetry()
diff --git a/src/easydiffraction/experiments/categories/peak/cwl_mixins.py b/src/easydiffraction/experiments/categories/peak/cwl_mixins.py
deleted file mode 100644
index 47d48636..00000000
--- a/src/easydiffraction/experiments/categories/peak/cwl_mixins.py
+++ /dev/null
@@ -1,332 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Constant-wavelength (CWL) peak-profile mixins.
-
-This module provides mixins that add broadening and asymmetry parameters
-for constant-wavelength powder diffraction peak profiles. They are
-composed into concrete peak classes elsewhere.
-"""
-
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
-from easydiffraction.core.validation import RangeValidator
-from easydiffraction.io.cif.handler import CifHandler
-
-
-class CwlBroadeningMixin:
-    """Mixin that adds CWL Gaussian and Lorentz broadening
-    parameters.
-    """
-
-    # TODO: Rename to cwl. Check other mixins for naming consistency.
-    def _add_constant_wavelength_broadening(self) -> None:
-        """Create CWL broadening parameters and attach them to the
-        class.
-
-        Defines Gaussian (U, V, W) and Lorentz (X, Y) terms
-        often used in the TCH formulation. Values are stored as
-        ``Parameter`` objects.
-        """
-        self._broad_gauss_u: Parameter = Parameter(
-            name='broad_gauss_u',
-            description='Gaussian broadening coefficient (dependent on '
-            'sample size and instrument resolution)',
-            value_spec=AttributeSpec(
-                value=0.01,
-                type_=DataTypes.NUMERIC,
-                default=0.01,
-                content_validator=RangeValidator(),
-            ),
-            units='deg²',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.broad_gauss_u',
-                ]
-            ),
-        )
-        self._broad_gauss_v: Parameter = Parameter(
-            name='broad_gauss_v',
-            description='Gaussian broadening coefficient (instrumental broadening contribution)',
-            value_spec=AttributeSpec(
-                value=-0.01,
-                type_=DataTypes.NUMERIC,
-                default=-0.01,
-                content_validator=RangeValidator(),
-            ),
-            units='deg²',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.broad_gauss_v',
-                ]
-            ),
-        )
-        self._broad_gauss_w: Parameter = Parameter(
-            name='broad_gauss_w',
-            description='Gaussian broadening coefficient (instrumental broadening contribution)',
-            value_spec=AttributeSpec(
-                value=0.02,
-                type_=DataTypes.NUMERIC,
-                default=0.02,
-                content_validator=RangeValidator(),
-            ),
-            units='deg²',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.broad_gauss_w',
-                ]
-            ),
-        )
-        self._broad_lorentz_x: Parameter = Parameter(
-            name='broad_lorentz_x',
-            description='Lorentzian broadening coefficient (dependent on sample strain effects)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='deg',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.broad_lorentz_x',
-                ]
-            ),
-        )
-        self._broad_lorentz_y: Parameter = Parameter(
-            name='broad_lorentz_y',
-            description='Lorentzian broadening coefficient (dependent on '
-            'microstructural defects and strain)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='deg',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.broad_lorentz_y',
-                ]
-            ),
-        )
-
-    @property
-    def broad_gauss_u(self) -> Parameter:
-        """Get Gaussian U broadening parameter."""
-        return self._broad_gauss_u
-
-    @broad_gauss_u.setter
-    def broad_gauss_u(self, value: float) -> None:
-        """Set Gaussian U broadening parameter."""
-        self._broad_gauss_u.value = value
-
-    @property
-    def broad_gauss_v(self) -> Parameter:
-        """Get Gaussian V broadening parameter."""
-        return self._broad_gauss_v
-
-    @broad_gauss_v.setter
-    def broad_gauss_v(self, value: float) -> None:
-        """Set Gaussian V broadening parameter."""
-        self._broad_gauss_v.value = value
-
-    @property
-    def broad_gauss_w(self) -> Parameter:
-        """Get Gaussian W broadening parameter."""
-        return self._broad_gauss_w
-
-    @broad_gauss_w.setter
-    def broad_gauss_w(self, value: float) -> None:
-        """Set Gaussian W broadening parameter."""
-        self._broad_gauss_w.value = value
-
-    @property
-    def broad_lorentz_x(self) -> Parameter:
-        """Get Lorentz X broadening parameter."""
-        return self._broad_lorentz_x
-
-    @broad_lorentz_x.setter
-    def broad_lorentz_x(self, value: float) -> None:
-        """Set Lorentz X broadening parameter."""
-        self._broad_lorentz_x.value = value
-
-    @property
-    def broad_lorentz_y(self) -> Parameter:
-        """Get Lorentz Y broadening parameter."""
-        return self._broad_lorentz_y
-
-    @broad_lorentz_y.setter
-    def broad_lorentz_y(self, value: float) -> None:
-        """Set Lorentz Y broadening parameter."""
-        self._broad_lorentz_y.value = value
-
-
-class EmpiricalAsymmetryMixin:
-    """Mixin that adds empirical CWL peak asymmetry parameters."""
-
-    def _add_empirical_asymmetry(self) -> None:
-        """Create empirical asymmetry parameters p1..p4."""
-        self._asym_empir_1: Parameter = Parameter(
-            name='asym_empir_1',
-            description='Empirical asymmetry coefficient p1',
-            value_spec=AttributeSpec(
-                value=0.1,
-                type_=DataTypes.NUMERIC,
-                default=0.1,
-                content_validator=RangeValidator(),
-            ),
-            units='',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.asym_empir_1',
-                ]
-            ),
-        )
-        self._asym_empir_2: Parameter = Parameter(
-            name='asym_empir_2',
-            description='Empirical asymmetry coefficient p2',
-            value_spec=AttributeSpec(
-                value=0.2,
-                type_=DataTypes.NUMERIC,
-                default=0.2,
-                content_validator=RangeValidator(),
-            ),
-            units='',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.asym_empir_2',
-                ]
-            ),
-        )
-        self._asym_empir_3: Parameter = Parameter(
-            name='asym_empir_3',
-            description='Empirical asymmetry coefficient p3',
-            value_spec=AttributeSpec(
-                value=0.3,
-                type_=DataTypes.NUMERIC,
-                default=0.3,
-                content_validator=RangeValidator(),
-            ),
-            units='',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.asym_empir_3',
-                ]
-            ),
-        )
-        self._asym_empir_4: Parameter = Parameter(
-            name='asym_empir_4',
-            description='Empirical asymmetry coefficient p4',
-            value_spec=AttributeSpec(
-                value=0.4,
-                type_=DataTypes.NUMERIC,
-                default=0.4,
-                content_validator=RangeValidator(),
-            ),
-            units='',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.asym_empir_4',
-                ]
-            ),
-        )
-
-    @property
-    def asym_empir_1(self) -> Parameter:
-        """Get empirical asymmetry coefficient p1."""
-        return self._asym_empir_1
-
-    @asym_empir_1.setter
-    def asym_empir_1(self, value: float) -> None:
-        """Set empirical asymmetry coefficient p1."""
-        self._asym_empir_1.value = value
-
-    @property
-    def asym_empir_2(self) -> Parameter:
-        """Get empirical asymmetry coefficient p2."""
-        return self._asym_empir_2
-
-    @asym_empir_2.setter
-    def asym_empir_2(self, value: float) -> None:
-        """Set empirical asymmetry coefficient p2."""
-        self._asym_empir_2.value = value
-
-    @property
-    def asym_empir_3(self) -> Parameter:
-        """Get empirical asymmetry coefficient p3."""
-        return self._asym_empir_3
-
-    @asym_empir_3.setter
-    def asym_empir_3(self, value: float) -> None:
-        """Set empirical asymmetry coefficient p3."""
-        self._asym_empir_3.value = value
-
-    @property
-    def asym_empir_4(self) -> Parameter:
-        """Get empirical asymmetry coefficient p4."""
-        return self._asym_empir_4
-
-    @asym_empir_4.setter
-    def asym_empir_4(self, value: float) -> None:
-        """Set empirical asymmetry coefficient p4."""
-        self._asym_empir_4.value = value
-
-
-class FcjAsymmetryMixin:
-    """Mixin that adds Finger–Cox–Jephcoat (FCJ) asymmetry params."""
-
-    def _add_fcj_asymmetry(self) -> None:
-        """Create FCJ asymmetry parameters."""
-        self._asym_fcj_1: Parameter = Parameter(
-            name='asym_fcj_1',
-            description='Finger-Cox-Jephcoat asymmetry parameter 1',
-            value_spec=AttributeSpec(
-                value=0.01,
-                type_=DataTypes.NUMERIC,
-                default=0.01,
-                content_validator=RangeValidator(),
-            ),
-            units='',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.asym_fcj_1',
-                ]
-            ),
-        )
-        self._asym_fcj_2: Parameter = Parameter(
-            name='asym_fcj_2',
-            description='Finger-Cox-Jephcoat asymmetry parameter 2',
-            value_spec=AttributeSpec(
-                value=0.02,
-                type_=DataTypes.NUMERIC,
-                default=0.02,
-                content_validator=RangeValidator(),
-            ),
-            units='',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.asym_fcj_2',
-                ]
-            ),
-        )
-
-    @property
-    def asym_fcj_1(self) -> Parameter:
-        """Get FCJ asymmetry parameter 1."""
-        return self._asym_fcj_1
-
-    @asym_fcj_1.setter
-    def asym_fcj_1(self, value: float) -> None:
-        """Set FCJ asymmetry parameter 1."""
-        self._asym_fcj_1.value = value
-
-    @property
-    def asym_fcj_2(self) -> Parameter:
-        """Get FCJ asymmetry parameter 2."""
-        return self._asym_fcj_2
-
-    @asym_fcj_2.setter
-    def asym_fcj_2(self, value: float) -> None:
-        """Set FCJ asymmetry parameter 2."""
-        self._asym_fcj_2.value = value
diff --git a/src/easydiffraction/experiments/categories/peak/factory.py b/src/easydiffraction/experiments/categories/peak/factory.py
deleted file mode 100644
index a0aabf10..00000000
--- a/src/easydiffraction/experiments/categories/peak/factory.py
+++ /dev/null
@@ -1,130 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from typing import Optional
-
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import PeakProfileTypeEnum
-from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-
-
-# TODO: Consider inheriting from FactoryBase
-class PeakFactory:
-    """Factory for creating peak profile objects.
-
-    Lazily imports implementations to avoid circular dependencies and
-    selects the appropriate class based on scattering type, beam mode
-    and requested profile type.
-    """
-
-    ST = ScatteringTypeEnum
-    BM = BeamModeEnum
-    PPT = PeakProfileTypeEnum
-    _supported = None  # type: ignore[var-annotated]
-
-    @classmethod
-    def _supported_map(cls):
-        """Return nested mapping of supported profile classes.
-
-        Structure:
-            ``{ScatteringType: {BeamMode: {ProfileType: Class}}}``.
-        """
-        # Lazy import to avoid circular imports between
-        # base and cw/tof/pdf modules
-        if cls._supported is None:
-            from easydiffraction.experiments.categories.peak.cwl import CwlPseudoVoigt as CwPv
-            from easydiffraction.experiments.categories.peak.cwl import (
-                CwlSplitPseudoVoigt as CwSpv,
-            )
-            from easydiffraction.experiments.categories.peak.cwl import (
-                CwlThompsonCoxHastings as CwTch,
-            )
-            from easydiffraction.experiments.categories.peak.tof import TofPseudoVoigt as TofPv
-            from easydiffraction.experiments.categories.peak.tof import (
-                TofPseudoVoigtBackToBack as TofBtb,
-            )
-            from easydiffraction.experiments.categories.peak.tof import (
-                TofPseudoVoigtIkedaCarpenter as TofIc,
-            )
-            from easydiffraction.experiments.categories.peak.total import (
-                TotalGaussianDampedSinc as PdfGds,
-            )
-
-            cls._supported = {
-                cls.ST.BRAGG: {
-                    cls.BM.CONSTANT_WAVELENGTH: {
-                        cls.PPT.PSEUDO_VOIGT: CwPv,
-                        cls.PPT.SPLIT_PSEUDO_VOIGT: CwSpv,
-                        cls.PPT.THOMPSON_COX_HASTINGS: CwTch,
-                    },
-                    cls.BM.TIME_OF_FLIGHT: {
-                        cls.PPT.PSEUDO_VOIGT: TofPv,
-                        cls.PPT.PSEUDO_VOIGT_IKEDA_CARPENTER: TofIc,
-                        cls.PPT.PSEUDO_VOIGT_BACK_TO_BACK: TofBtb,
-                    },
-                },
-                cls.ST.TOTAL: {
-                    cls.BM.CONSTANT_WAVELENGTH: {
-                        cls.PPT.GAUSSIAN_DAMPED_SINC: PdfGds,
-                    },
-                    cls.BM.TIME_OF_FLIGHT: {
-                        cls.PPT.GAUSSIAN_DAMPED_SINC: PdfGds,
-                    },
-                },
-            }
-        return cls._supported
-
-    @classmethod
-    def create(
-        cls,
-        scattering_type: Optional[ScatteringTypeEnum] = None,
-        beam_mode: Optional[BeamModeEnum] = None,
-        profile_type: Optional[PeakProfileTypeEnum] = None,
-    ):
-        """Instantiate a peak profile for the given configuration.
-
-        Args:
-            scattering_type: Bragg or Total. Defaults to library
-                default.
-            beam_mode: CW or TOF. Defaults to library default.
-            profile_type: Concrete profile within the mode. If omitted,
-                a sensible default is chosen based on the other args.
-
-        Returns:
-            A newly created peak profile object.
-
-        Raises:
-            ValueError: If a requested option is not supported.
-        """
-        if beam_mode is None:
-            beam_mode = BeamModeEnum.default()
-        if scattering_type is None:
-            scattering_type = ScatteringTypeEnum.default()
-        if profile_type is None:
-            profile_type = PeakProfileTypeEnum.default(scattering_type, beam_mode)
-        supported = cls._supported_map()
-        supported_scattering_types = list(supported.keys())
-        if scattering_type not in supported_scattering_types:
-            raise ValueError(
-                f"Unsupported scattering type: '{scattering_type}'.\n"
-                f'Supported scattering types: {supported_scattering_types}'
-            )
-
-        supported_beam_modes = list(supported[scattering_type].keys())
-        if beam_mode not in supported_beam_modes:
-            raise ValueError(
-                f"Unsupported beam mode: '{beam_mode}' for scattering type: "
-                f"'{scattering_type}'.\n Supported beam modes: '{supported_beam_modes}'"
-            )
-
-        supported_profile_types = list(supported[scattering_type][beam_mode].keys())
-        if profile_type not in supported_profile_types:
-            raise ValueError(
-                f"Unsupported profile type '{profile_type}' for beam mode '{beam_mode}'.\n"
-                f'Supported profile types: {supported_profile_types}'
-            )
-
-        peak_class = supported[scattering_type][beam_mode][profile_type]
-        peak_obj = peak_class()
-
-        return peak_obj
diff --git a/src/easydiffraction/experiments/categories/peak/tof.py b/src/easydiffraction/experiments/categories/peak/tof.py
deleted file mode 100644
index b38c4548..00000000
--- a/src/easydiffraction/experiments/categories/peak/tof.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Time-of-flight peak profile classes."""
-
-from easydiffraction.experiments.categories.peak.base import PeakBase
-from easydiffraction.experiments.categories.peak.tof_mixins import IkedaCarpenterAsymmetryMixin
-from easydiffraction.experiments.categories.peak.tof_mixins import TofBroadeningMixin
-
-
-class TofPseudoVoigt(
-    PeakBase,
-    TofBroadeningMixin,
-):
-    """Time-of-flight pseudo-Voigt peak shape."""
-
-    def __init__(self) -> None:
-        super().__init__()
-        self._add_time_of_flight_broadening()
-
-
-class TofPseudoVoigtIkedaCarpenter(
-    PeakBase,
-    TofBroadeningMixin,
-    IkedaCarpenterAsymmetryMixin,
-):
-    """TOF pseudo-Voigt with Ikeda–Carpenter asymmetry."""
-
-    def __init__(self) -> None:
-        super().__init__()
-        self._add_time_of_flight_broadening()
-        self._add_ikeda_carpenter_asymmetry()
-
-
-class TofPseudoVoigtBackToBack(
-    PeakBase,
-    TofBroadeningMixin,
-    IkedaCarpenterAsymmetryMixin,
-):
-    """TOF back-to-back pseudo-Voigt with asymmetry."""
-
-    def __init__(self) -> None:
-        super().__init__()
-        self._add_time_of_flight_broadening()
-        self._add_ikeda_carpenter_asymmetry()
diff --git a/src/easydiffraction/experiments/categories/peak/tof_mixins.py b/src/easydiffraction/experiments/categories/peak/tof_mixins.py
deleted file mode 100644
index ca459754..00000000
--- a/src/easydiffraction/experiments/categories/peak/tof_mixins.py
+++ /dev/null
@@ -1,293 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Time-of-flight (TOF) peak-profile mixins.
-
-Defines mixins that add Gaussian/Lorentz broadening, mixing, and
-Ikeda–Carpenter asymmetry parameters used by TOF peak shapes.
-"""
-
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
-from easydiffraction.core.validation import RangeValidator
-from easydiffraction.io.cif.handler import CifHandler
-
-
-class TofBroadeningMixin:
-    """Mixin that adds TOF Gaussian/Lorentz broadening and mixing
-    terms.
-    """
-
-    def _add_time_of_flight_broadening(self) -> None:
-        """Create TOF broadening and mixing parameters."""
-        self._broad_gauss_sigma_0: Parameter = Parameter(
-            name='gauss_sigma_0',
-            description='Gaussian broadening coefficient (instrumental resolution)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='µs²',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.gauss_sigma_0',
-                ]
-            ),
-        )
-        self._broad_gauss_sigma_1: Parameter = Parameter(
-            name='gauss_sigma_1',
-            description='Gaussian broadening coefficient (dependent on d-spacing)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='µs/Å',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.gauss_sigma_1',
-                ]
-            ),
-        )
-        self._broad_gauss_sigma_2: Parameter = Parameter(
-            name='gauss_sigma_2',
-            description='Gaussian broadening coefficient (instrument-dependent term)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='µs²/Ų',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.gauss_sigma_2',
-                ]
-            ),
-        )
-        self._broad_lorentz_gamma_0: Parameter = Parameter(
-            name='lorentz_gamma_0',
-            description='Lorentzian broadening coefficient (dependent on microstrain effects)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='µs',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.lorentz_gamma_0',
-                ]
-            ),
-        )
-        self._broad_lorentz_gamma_1: Parameter = Parameter(
-            name='lorentz_gamma_1',
-            description='Lorentzian broadening coefficient (dependent on d-spacing)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='µs/Å',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.lorentz_gamma_1',
-                ]
-            ),
-        )
-        self._broad_lorentz_gamma_2: Parameter = Parameter(
-            name='lorentz_gamma_2',
-            description='Lorentzian broadening coefficient (instrument-dependent term)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='µs²/Ų',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.lorentz_gamma_2',
-                ]
-            ),
-        )
-        self._broad_mix_beta_0: Parameter = Parameter(
-            name='mix_beta_0',
-            description='Mixing parameter. Defines the ratio of Gaussian '
-            'to Lorentzian contributions in TOF profiles',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='deg',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.mix_beta_0',
-                ]
-            ),
-        )
-        self._broad_mix_beta_1: Parameter = Parameter(
-            name='mix_beta_1',
-            description='Mixing parameter. Defines the ratio of Gaussian '
-            'to Lorentzian contributions in TOF profiles',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='deg',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.mix_beta_1',
-                ]
-            ),
-        )
-
-    @property
-    def broad_gauss_sigma_0(self) -> Parameter:
-        """Get Gaussian sigma_0 parameter."""
-        return self._broad_gauss_sigma_0
-
-    @broad_gauss_sigma_0.setter
-    def broad_gauss_sigma_0(self, value: float) -> None:
-        """Set Gaussian sigma_0 parameter."""
-        self._broad_gauss_sigma_0.value = value
-
-    @property
-    def broad_gauss_sigma_1(self) -> Parameter:
-        """Get Gaussian sigma_1 parameter."""
-        return self._broad_gauss_sigma_1
-
-    @broad_gauss_sigma_1.setter
-    def broad_gauss_sigma_1(self, value: float) -> None:
-        """Set Gaussian sigma_1 parameter."""
-        self._broad_gauss_sigma_1.value = value
-
-    @property
-    def broad_gauss_sigma_2(self) -> Parameter:
-        """Get Gaussian sigma_2 parameter."""
-        return self._broad_gauss_sigma_2
-
-    @broad_gauss_sigma_2.setter
-    def broad_gauss_sigma_2(self, value: float) -> None:
-        """Set Gaussian sigma_2 parameter."""
-        self._broad_gauss_sigma_2.value = value
-
-    @property
-    def broad_lorentz_gamma_0(self) -> Parameter:
-        """Get Lorentz gamma_0 parameter."""
-        return self._broad_lorentz_gamma_0
-
-    @broad_lorentz_gamma_0.setter
-    def broad_lorentz_gamma_0(self, value: float) -> None:
-        """Set Lorentz gamma_0 parameter."""
-        self._broad_lorentz_gamma_0.value = value
-
-    @property
-    def broad_lorentz_gamma_1(self) -> Parameter:
-        """Get Lorentz gamma_1 parameter."""
-        return self._broad_lorentz_gamma_1
-
-    @broad_lorentz_gamma_1.setter
-    def broad_lorentz_gamma_1(self, value: float) -> None:
-        """Set Lorentz gamma_1 parameter."""
-        self._broad_lorentz_gamma_1.value = value
-
-    @property
-    def broad_lorentz_gamma_2(self) -> Parameter:
-        """Get Lorentz gamma_2 parameter."""
-        return self._broad_lorentz_gamma_2
-
-    @broad_lorentz_gamma_2.setter
-    def broad_lorentz_gamma_2(self, value: float) -> None:
-        """Set Lorentz gamma_2 parameter."""
-        self._broad_lorentz_gamma_2.value = value
-
-    @property
-    def broad_mix_beta_0(self) -> Parameter:
-        """Get mixing parameter beta_0."""
-        return self._broad_mix_beta_0
-
-    @broad_mix_beta_0.setter
-    def broad_mix_beta_0(self, value: float) -> None:
-        """Set mixing parameter beta_0."""
-        self._broad_mix_beta_0.value = value
-
-    @property
-    def broad_mix_beta_1(self) -> Parameter:
-        """Get mixing parameter beta_1."""
-        return self._broad_mix_beta_1
-
-    @broad_mix_beta_1.setter
-    def broad_mix_beta_1(self, value: float) -> None:
-        """Set mixing parameter beta_1."""
-        self._broad_mix_beta_1.value = value
-
-
-class IkedaCarpenterAsymmetryMixin:
-    """Mixin that adds Ikeda–Carpenter asymmetry parameters."""
-
-    def _add_ikeda_carpenter_asymmetry(self) -> None:
-        """Create Ikeda–Carpenter asymmetry parameters alpha_0 and
-        alpha_1.
-        """
-        self._asym_alpha_0: Parameter = Parameter(
-            name='asym_alpha_0',
-            description='Ikeda-Carpenter asymmetry parameter α₀',
-            value_spec=AttributeSpec(
-                value=0.01,
-                type_=DataTypes.NUMERIC,
-                default=0.01,
-                content_validator=RangeValidator(),
-            ),
-            units='',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.asym_alpha_0',
-                ]
-            ),
-        )
-        self._asym_alpha_1: Parameter = Parameter(
-            name='asym_alpha_1',
-            description='Ikeda-Carpenter asymmetry parameter α₁',
-            value_spec=AttributeSpec(
-                value=0.02,
-                type_=DataTypes.NUMERIC,
-                default=0.02,
-                content_validator=RangeValidator(),
-            ),
-            units='',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.asym_alpha_1',
-                ]
-            ),
-        )
-
-    @property
-    def asym_alpha_0(self) -> Parameter:
-        """Get Ikeda–Carpenter asymmetry alpha_0."""
-        return self._asym_alpha_0
-
-    @asym_alpha_0.setter
-    def asym_alpha_0(self, value: float) -> None:
-        """Set Ikeda–Carpenter asymmetry alpha_0."""
-        self._asym_alpha_0.value = value
-
-    @property
-    def asym_alpha_1(self) -> Parameter:
-        """Get Ikeda–Carpenter asymmetry alpha_1."""
-        return self._asym_alpha_1
-
-    @asym_alpha_1.setter
-    def asym_alpha_1(self, value: float) -> None:
-        """Set Ikeda–Carpenter asymmetry alpha_1."""
-        self._asym_alpha_1.value = value
diff --git a/src/easydiffraction/experiments/categories/peak/total.py b/src/easydiffraction/experiments/categories/peak/total.py
deleted file mode 100644
index e1f7c28c..00000000
--- a/src/easydiffraction/experiments/categories/peak/total.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Total-scattering (PDF) peak profile classes."""
-
-from easydiffraction.experiments.categories.peak.base import PeakBase
-from easydiffraction.experiments.categories.peak.total_mixins import TotalBroadeningMixin
-
-
-class TotalGaussianDampedSinc(
-    PeakBase,
-    TotalBroadeningMixin,
-):
-    """Gaussian-damped sinc peak for total scattering (PDF)."""
-
-    def __init__(self) -> None:
-        super().__init__()
-        self._add_pair_distribution_function_broadening()
diff --git a/src/easydiffraction/experiments/categories/peak/total_mixins.py b/src/easydiffraction/experiments/categories/peak/total_mixins.py
deleted file mode 100644
index 03907ffa..00000000
--- a/src/easydiffraction/experiments/categories/peak/total_mixins.py
+++ /dev/null
@@ -1,181 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Total scattering/PDF peak-profile mixins.
-
-Adds damping, broadening, sharpening and envelope parameters used in
-pair distribution function (PDF) modeling.
-"""
-
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
-from easydiffraction.core.validation import RangeValidator
-from easydiffraction.io.cif.handler import CifHandler
-
-
-class TotalBroadeningMixin:
-    """Mixin adding PDF broadening/damping/sharpening parameters."""
-
-    def _add_pair_distribution_function_broadening(self):
-        """Create PDF parameters: damp_q, broad_q, cutoff_q,
-        sharp deltas, and particle diameter envelope.
-        """
-        self._damp_q: Parameter = Parameter(
-            name='damp_q',
-            description='Instrumental Q-resolution damping factor '
-            '(affects high-r PDF peak amplitude)',
-            value_spec=AttributeSpec(
-                value=0.05,
-                type_=DataTypes.NUMERIC,
-                default=0.05,
-                content_validator=RangeValidator(),
-            ),
-            units='Å⁻¹',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.damp_q',
-                ]
-            ),
-        )
-        self._broad_q: Parameter = Parameter(
-            name='broad_q',
-            description='Quadratic PDF peak broadening coefficient '
-            '(thermal and model uncertainty contribution)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='Å⁻²',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.broad_q',
-                ]
-            ),
-        )
-        self._cutoff_q: Parameter = Parameter(
-            name='cutoff_q',
-            description='Q-value cutoff applied to model PDF for Fourier '
-            'transform (controls real-space resolution)',
-            value_spec=AttributeSpec(
-                value=25.0,
-                type_=DataTypes.NUMERIC,
-                default=25.0,
-                content_validator=RangeValidator(),
-            ),
-            units='Å⁻¹',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.cutoff_q',
-                ]
-            ),
-        )
-        self._sharp_delta_1: Parameter = Parameter(
-            name='sharp_delta_1',
-            description='PDF peak sharpening coefficient (1/r dependence)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='Å',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.sharp_delta_1',
-                ]
-            ),
-        )
-        self._sharp_delta_2: Parameter = Parameter(
-            name='sharp_delta_2',
-            description='PDF peak sharpening coefficient (1/r² dependence)',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='Ų',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.sharp_delta_2',
-                ]
-            ),
-        )
-        self._damp_particle_diameter: Parameter = Parameter(
-            name='damp_particle_diameter',
-            description='Particle diameter for spherical envelope damping correction in PDF',
-            value_spec=AttributeSpec(
-                value=0.0,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            units='Å',
-            cif_handler=CifHandler(
-                names=[
-                    '_peak.damp_particle_diameter',
-                ]
-            ),
-        )
-
-    @property
-    def damp_q(self) -> Parameter:
-        """Get Q-resolution damping factor."""
-        return self._damp_q
-
-    @damp_q.setter
-    def damp_q(self, value: float) -> None:
-        """Set Q-resolution damping factor."""
-        self._damp_q.value = value
-
-    @property
-    def broad_q(self) -> Parameter:
-        """Get quadratic PDF broadening coefficient."""
-        return self._broad_q
-
-    @broad_q.setter
-    def broad_q(self, value: float) -> None:
-        """Set quadratic PDF broadening coefficient."""
-        self._broad_q.value = value
-
-    @property
-    def cutoff_q(self) -> Parameter:
-        """Get Q cutoff used for Fourier transform."""
-        return self._cutoff_q
-
-    @cutoff_q.setter
-    def cutoff_q(self, value: float) -> None:
-        """Set Q cutoff used for Fourier transform."""
-        self._cutoff_q.value = value
-
-    @property
-    def sharp_delta_1(self) -> Parameter:
-        """Get sharpening coefficient with 1/r dependence."""
-        return self._sharp_delta_1
-
-    @sharp_delta_1.setter
-    def sharp_delta_1(self, value: float) -> None:
-        """Set sharpening coefficient with 1/r dependence."""
-        self._sharp_delta_1.value = value
-
-    @property
-    def sharp_delta_2(self) -> Parameter:
-        """Get sharpening coefficient with 1/r^2 dependence."""
-        return self._sharp_delta_2
-
-    @sharp_delta_2.setter
-    def sharp_delta_2(self, value: float) -> None:
-        """Set sharpening coefficient with 1/r^2 dependence."""
-        self._sharp_delta_2.value = value
-
-    @property
-    def damp_particle_diameter(self) -> Parameter:
-        """Get particle diameter for spherical envelope damping."""
-        return self._damp_particle_diameter
-
-    @damp_particle_diameter.setter
-    def damp_particle_diameter(self, value: float) -> None:
-        """Set particle diameter for spherical envelope damping."""
-        self._damp_particle_diameter.value = value
diff --git a/src/easydiffraction/experiments/experiment/__init__.py b/src/easydiffraction/experiments/experiment/__init__.py
deleted file mode 100644
index dee12f85..00000000
--- a/src/easydiffraction/experiments/experiment/__init__.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from easydiffraction.experiments.experiment.base import ExperimentBase
-from easydiffraction.experiments.experiment.base import PdExperimentBase
-from easydiffraction.experiments.experiment.bragg_pd import BraggPdExperiment
-from easydiffraction.experiments.experiment.bragg_sc import CwlScExperiment
-from easydiffraction.experiments.experiment.bragg_sc import TofScExperiment
-from easydiffraction.experiments.experiment.total_pd import TotalPdExperiment
-
-__all__ = [
-    'ExperimentBase',
-    'PdExperimentBase',
-    'BraggPdExperiment',
-    'TotalPdExperiment',
-    'CwlScExperiment',
-    'TofScExperiment',
-]
diff --git a/src/easydiffraction/experiments/experiment/base.py b/src/easydiffraction/experiments/experiment/base.py
deleted file mode 100644
index 27a75c37..00000000
--- a/src/easydiffraction/experiments/experiment/base.py
+++ /dev/null
@@ -1,317 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from abc import abstractmethod
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import List
-
-from easydiffraction.core.datablock import DatablockItem
-from easydiffraction.experiments.categories.data.factory import DataFactory
-from easydiffraction.experiments.categories.excluded_regions import ExcludedRegions
-from easydiffraction.experiments.categories.extinction import Extinction
-from easydiffraction.experiments.categories.instrument.factory import InstrumentFactory
-from easydiffraction.experiments.categories.linked_crystal import LinkedCrystal
-from easydiffraction.experiments.categories.linked_phases import LinkedPhases
-from easydiffraction.experiments.categories.peak.factory import PeakFactory
-from easydiffraction.experiments.categories.peak.factory import PeakProfileTypeEnum
-from easydiffraction.io.cif.serialize import experiment_to_cif
-from easydiffraction.utils.logging import console
-from easydiffraction.utils.logging import log
-from easydiffraction.utils.utils import render_cif
-from easydiffraction.utils.utils import render_table
-
-if TYPE_CHECKING:
-    from easydiffraction.experiments.categories.experiment_type import ExperimentType
-    from easydiffraction.sample_models.sample_models import SampleModels
-
-
-class ExperimentBase(DatablockItem):
-    """Base class for all experiments with only core attributes.
-
-    Wraps experiment type and instrument.
-    """
-
-    def __init__(
-        self,
-        *,
-        name: str,
-        type: ExperimentType,
-    ):
-        super().__init__()
-        self._name = name
-        self._type = type
-        # TODO: Should return default calculator based on experiment
-        #  type
-        from easydiffraction.analysis.calculators.factory import CalculatorFactory
-
-        self._calculator = CalculatorFactory.create_calculator('cryspy')
-        self._identity.datablock_entry_name = lambda: self.name
-
-    @property
-    def name(self) -> str:
-        """Human-readable name of the experiment."""
-        return self._name
-
-    @name.setter
-    def name(self, new: str) -> None:
-        """Rename the experiment.
-
-        Args:
-            new: New name for this experiment.
-        """
-        self._name = new
-
-    @property
-    def type(self):  # TODO: Consider another name
-        """Experiment type descriptor (sample form, probe, beam
-        mode).
-        """
-        return self._type
-
-    @property
-    def calculator(self):
-        """Calculator engine used for pattern calculations."""
-        return self._calculator
-
-    @property
-    def as_cif(self) -> str:
-        """Serialize this experiment to a CIF fragment."""
-        return experiment_to_cif(self)
-
-    def show_as_cif(self) -> None:
-        """Pretty-print the experiment as CIF text."""
-        experiment_cif = super().as_cif
-        paragraph_title: str = f"Experiment 🔬 '{self.name}' as cif"
-        console.paragraph(paragraph_title)
-        render_cif(experiment_cif)
-
-    @abstractmethod
-    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
-        """Load ASCII data from file into the experiment data category.
-
-        Args:
-            data_path: Path to the ASCII file to load.
-        """
-        raise NotImplementedError()
-
-
-class ScExperimentBase(ExperimentBase):
-    """Base class for all single crystal experiments."""
-
-    def __init__(
-        self,
-        *,
-        name: str,
-        type: ExperimentType,
-    ) -> None:
-        super().__init__(name=name, type=type)
-
-        self._linked_crystal: LinkedCrystal = LinkedCrystal()
-        self._extinction: Extinction = Extinction()
-        self._instrument = InstrumentFactory.create(
-            scattering_type=self.type.scattering_type.value,
-            beam_mode=self.type.beam_mode.value,
-            sample_form=self.type.sample_form.value,
-        )
-        self._data = DataFactory.create(
-            sample_form=self.type.sample_form.value,
-            beam_mode=self.type.beam_mode.value,
-            scattering_type=self.type.scattering_type.value,
-        )
-
-    @abstractmethod
-    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
-        """Load single crystal data from an ASCII file.
-
-        Args:
-            data_path: Path to data file with columns compatible with
-                the beam mode.
-        """
-        pass
-
-    @property
-    def linked_crystal(self):
-        """Linked crystal model for this experiment."""
-        return self._linked_crystal
-
-    @property
-    def extinction(self):
-        return self._extinction
-
-    @property
-    def instrument(self):
-        return self._instrument
-
-    @property
-    def data(self):
-        return self._data
-
-
-class PdExperimentBase(ExperimentBase):
-    """Base class for all powder experiments."""
-
-    def __init__(
-        self,
-        *,
-        name: str,
-        type: ExperimentType,
-    ) -> None:
-        super().__init__(name=name, type=type)
-
-        self._linked_phases: LinkedPhases = LinkedPhases()
-        self._excluded_regions: ExcludedRegions = ExcludedRegions()
-        self._peak_profile_type: PeakProfileTypeEnum = PeakProfileTypeEnum.default(
-            self.type.scattering_type.value,
-            self.type.beam_mode.value,
-        )
-        self._data = DataFactory.create(
-            sample_form=self.type.sample_form.value,
-            beam_mode=self.type.beam_mode.value,
-            scattering_type=self.type.scattering_type.value,
-        )
-        self._peak = PeakFactory.create(
-            scattering_type=self.type.scattering_type.value,
-            beam_mode=self.type.beam_mode.value,
-            profile_type=self._peak_profile_type,
-        )
-
-    def _get_valid_linked_phases(
-        self,
-        sample_models: SampleModels,
-    ) -> List[Any]:
-        """Get valid linked phases for this experiment.
-
-        Args:
-            sample_models: Collection of sample models.
-
-        Returns:
-            A list of valid linked phases.
-        """
-        if not self.linked_phases:
-            print('Warning: No linked phases defined. Returning empty pattern.')
-            return []
-
-        valid_linked_phases = []
-        for linked_phase in self.linked_phases:
-            if linked_phase._identity.category_entry_name not in sample_models.names:
-                print(
-                    f"Warning: Linked phase '{linked_phase.id.value}' not "
-                    f'found in Sample Models {sample_models.names}. Skipping it.'
-                )
-                continue
-            valid_linked_phases.append(linked_phase)
-
-        if not valid_linked_phases:
-            print(
-                'Warning: None of the linked phases found in Sample '
-                'Models. Returning empty pattern.'
-            )
-
-        return valid_linked_phases
-
-    @abstractmethod
-    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
-        """Load powder diffraction data from an ASCII file.
-
-        Args:
-            data_path: Path to data file with columns compatible with
-                the beam mode (e.g. 2θ/I/σ for CWL, TOF/I/σ for TOF).
-        """
-        pass
-
-    @property
-    def linked_phases(self):
-        """Collection of phases linked to this experiment."""
-        return self._linked_phases
-
-    @property
-    def excluded_regions(self):
-        """Collection of excluded regions for the x-grid."""
-        return self._excluded_regions
-
-    @property
-    def data(self):
-        return self._data
-
-    @property
-    def peak(self) -> str:
-        """Peak category object with profile parameters and mixins."""
-        return self._peak
-
-    @peak.setter
-    def peak(self, value):
-        """Replace the peak model used for this powder experiment.
-
-        Args:
-            value: New peak object created by the `PeakFactory`.
-        """
-        self._peak = value
-
-    @property
-    def peak_profile_type(self):
-        """Currently selected peak profile type enum."""
-        return self._peak_profile_type
-
-    @peak_profile_type.setter
-    def peak_profile_type(self, new_type: str | PeakProfileTypeEnum):
-        """Change the active peak profile type, if supported.
-
-        Args:
-            new_type: New profile type as enum or its string value.
-        """
-        if isinstance(new_type, str):
-            try:
-                new_type = PeakProfileTypeEnum(new_type)
-            except ValueError:
-                log.warning(f"Unknown peak profile type '{new_type}'")
-                return
-
-        supported_types = list(
-            PeakFactory._supported[self.type.scattering_type.value][
-                self.type.beam_mode.value
-            ].keys()
-        )
-
-        if new_type not in supported_types:
-            log.warning(
-                f"Unsupported peak profile '{new_type.value}', "
-                f'Supported peak profiles: {supported_types}',
-                "For more information, use 'show_supported_peak_profile_types()'",
-            )
-            return
-
-        self._peak = PeakFactory.create(
-            scattering_type=self.type.scattering_type.value,
-            beam_mode=self.type.beam_mode.value,
-            profile_type=new_type,
-        )
-        self._peak_profile_type = new_type
-        console.paragraph(f"Peak profile type for experiment '{self.name}' changed to")
-        console.print(new_type.value)
-
-    def show_supported_peak_profile_types(self):
-        """Print available peak profile types for this experiment."""
-        columns_headers = ['Peak profile type', 'Description']
-        columns_alignment = ['left', 'left']
-        columns_data = []
-
-        scattering_type = self.type.scattering_type.value
-        beam_mode = self.type.beam_mode.value
-
-        for profile_type in PeakFactory._supported[scattering_type][beam_mode]:
-            columns_data.append([profile_type.value, profile_type.description()])
-
-        console.paragraph('Supported peak profile types')
-        render_table(
-            columns_headers=columns_headers,
-            columns_alignment=columns_alignment,
-            columns_data=columns_data,
-        )
-
-    def show_current_peak_profile_type(self):
-        """Print the currently selected peak profile type."""
-        console.paragraph('Current peak profile type')
-        console.print(self.peak_profile_type)
diff --git a/src/easydiffraction/experiments/experiment/bragg_pd.py b/src/easydiffraction/experiments/experiment/bragg_pd.py
deleted file mode 100644
index b6d5466c..00000000
--- a/src/easydiffraction/experiments/experiment/bragg_pd.py
+++ /dev/null
@@ -1,142 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-import numpy as np
-
-from easydiffraction.experiments.categories.background.enums import BackgroundTypeEnum
-from easydiffraction.experiments.categories.background.factory import BackgroundFactory
-from easydiffraction.experiments.categories.instrument.factory import InstrumentFactory
-from easydiffraction.experiments.experiment.base import PdExperimentBase
-from easydiffraction.utils.logging import console
-from easydiffraction.utils.logging import log
-from easydiffraction.utils.utils import render_table
-
-if TYPE_CHECKING:
-    from easydiffraction.experiments.categories.experiment_type import ExperimentType
-
-
-class BraggPdExperiment(PdExperimentBase):
-    """Standard (Bragg) Powder Diffraction experiment class with
-    specific attributes.
-    """
-
-    def __init__(
-        self,
-        *,
-        name: str,
-        type: ExperimentType,
-    ) -> None:
-        super().__init__(name=name, type=type)
-
-        self._instrument = InstrumentFactory.create(
-            scattering_type=self.type.scattering_type.value,
-            beam_mode=self.type.beam_mode.value,
-            sample_form=self.type.sample_form.value,
-        )
-        self._background_type: BackgroundTypeEnum = BackgroundTypeEnum.default()
-        self._background = BackgroundFactory.create(background_type=self.background_type)
-
-    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
-        """Load (x, y, sy) data from an ASCII file into the data
-        category.
-
-        The file format is space/column separated with 2 or 3 columns:
-        ``x y [sy]``. If ``sy`` is missing, it is approximated as
-        ``sqrt(y)``.
-
-        If ``sy`` has values smaller than ``0.0001``, they are replaced
-        with ``1.0``.
-        """
-        try:
-            data = np.loadtxt(data_path)
-        except Exception as e:
-            raise IOError(f'Failed to read data from {data_path}: {e}') from e
-
-        if data.shape[1] < 2:
-            raise ValueError('Data file must have at least two columns: x and y.')
-
-        if data.shape[1] < 3:
-            print('Warning: No uncertainty (sy) column provided. Defaulting to sqrt(y).')
-
-        # Extract x, y data
-        x: np.ndarray = data[:, 0]
-        y: np.ndarray = data[:, 1]
-
-        # Round x to 4 decimal places
-        x = np.round(x, 4)
-
-        # Determine sy from column 3 if available, otherwise use sqrt(y)
-        sy: np.ndarray = data[:, 2] if data.shape[1] > 2 else np.sqrt(y)
-
-        # Replace values smaller than 0.0001 with 1.0
-        # TODO: Not used if loading from cif file?
-        sy = np.where(sy < 0.0001, 1.0, sy)
-
-        # Set the experiment data
-        self.data._create_items_set_xcoord_and_id(x)
-        self.data._set_intensity_meas(y)
-        self.data._set_intensity_meas_su(sy)
-
-        console.paragraph('Data loaded successfully')
-        console.print(f"Experiment 🔬 '{self.name}'. Number of data points: {len(x)}")
-
-    @property
-    def instrument(self):
-        return self._instrument
-
-    @property
-    def background_type(self):
-        """Current background type enum value."""
-        return self._background_type
-
-    @background_type.setter
-    def background_type(self, new_type):
-        """Set and apply a new background type.
-
-        Falls back to printing supported types if the new value is not
-        supported.
-        """
-        if new_type not in BackgroundFactory._supported_map():
-            supported_types = list(BackgroundFactory._supported_map().keys())
-            log.warning(
-                f"Unknown background type '{new_type}'. "
-                f'Supported background types: {[bt.value for bt in supported_types]}. '
-                f"For more information, use 'show_supported_background_types()'"
-            )
-            return
-        self.background = BackgroundFactory.create(new_type)
-        self._background_type = new_type
-        console.paragraph(f"Background type for experiment '{self.name}' changed to")
-        console.print(new_type)
-
-    @property
-    def background(self):
-        return self._background
-
-    @background.setter
-    def background(self, value):
-        self._background = value
-
-    def show_supported_background_types(self):
-        """Print a table of supported background types."""
-        columns_headers = ['Background type', 'Description']
-        columns_alignment = ['left', 'left']
-        columns_data = []
-        for bt in BackgroundFactory._supported_map():
-            columns_data.append([bt.value, bt.description()])
-
-        console.paragraph('Supported background types')
-        render_table(
-            columns_headers=columns_headers,
-            columns_alignment=columns_alignment,
-            columns_data=columns_data,
-        )
-
-    def show_current_background_type(self):
-        """Print the currently used background type."""
-        console.paragraph('Current background type')
-        console.print(self.background_type)
diff --git a/src/easydiffraction/experiments/experiment/factory.py b/src/easydiffraction/experiments/experiment/factory.py
deleted file mode 100644
index b1edc64e..00000000
--- a/src/easydiffraction/experiments/experiment/factory.py
+++ /dev/null
@@ -1,213 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-from easydiffraction.core.factory import FactoryBase
-from easydiffraction.experiments.categories.experiment_type import ExperimentType
-from easydiffraction.experiments.experiment import BraggPdExperiment
-from easydiffraction.experiments.experiment import CwlScExperiment
-from easydiffraction.experiments.experiment import TofScExperiment
-from easydiffraction.experiments.experiment import TotalPdExperiment
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import RadiationProbeEnum
-from easydiffraction.experiments.experiment.enums import SampleFormEnum
-from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-from easydiffraction.io.cif.parse import document_from_path
-from easydiffraction.io.cif.parse import document_from_string
-from easydiffraction.io.cif.parse import name_from_block
-from easydiffraction.io.cif.parse import pick_sole_block
-
-if TYPE_CHECKING:
-    import gemmi
-
-    from easydiffraction.experiments.experiment.base import ExperimentBase
-
-
-class ExperimentFactory(FactoryBase):
-    """Creates Experiment instances with only relevant attributes."""
-
-    _ALLOWED_ARG_SPECS = [
-        {
-            'required': ['cif_path'],
-            'optional': [],
-        },
-        {
-            'required': ['cif_str'],
-            'optional': [],
-        },
-        {
-            'required': [
-                'name',
-                'data_path',
-            ],
-            'optional': [
-                'sample_form',
-                'beam_mode',
-                'radiation_probe',
-                'scattering_type',
-            ],
-        },
-        {
-            'required': ['name'],
-            'optional': [
-                'sample_form',
-                'beam_mode',
-                'radiation_probe',
-                'scattering_type',
-            ],
-        },
-    ]
-
-    _SUPPORTED = {
-        ScatteringTypeEnum.BRAGG: {
-            SampleFormEnum.POWDER: {
-                BeamModeEnum.CONSTANT_WAVELENGTH: BraggPdExperiment,
-                BeamModeEnum.TIME_OF_FLIGHT: BraggPdExperiment,
-            },
-            SampleFormEnum.SINGLE_CRYSTAL: {
-                BeamModeEnum.CONSTANT_WAVELENGTH: CwlScExperiment,
-                BeamModeEnum.TIME_OF_FLIGHT: TofScExperiment,
-            },
-        },
-        ScatteringTypeEnum.TOTAL: {
-            SampleFormEnum.POWDER: {
-                BeamModeEnum.CONSTANT_WAVELENGTH: TotalPdExperiment,
-                BeamModeEnum.TIME_OF_FLIGHT: TotalPdExperiment,
-            },
-        },
-    }
-
-    @classmethod
-    def _make_experiment_type(cls, kwargs):
-        """Helper to construct an ExperimentType from keyword arguments,
-        using defaults as needed.
-        """
-        # TODO: Defaults are already in the experiment type...
-        # TODO: Merging with experiment_type_from_block from
-        #  io.cif.parse
-        return ExperimentType(
-            sample_form=kwargs.get('sample_form', SampleFormEnum.default().value),
-            beam_mode=kwargs.get('beam_mode', BeamModeEnum.default().value),
-            radiation_probe=kwargs.get('radiation_probe', RadiationProbeEnum.default().value),
-            scattering_type=kwargs.get('scattering_type', ScatteringTypeEnum.default().value),
-        )
-
-    # TODO: Move to a common CIF utility module? io.cif.parse?
-    @classmethod
-    def _create_from_gemmi_block(
-        cls,
-        block: gemmi.cif.Block,
-    ) -> ExperimentBase:
-        """Build a model instance from a single CIF block."""
-        name = name_from_block(block)
-
-        # TODO: move to io.cif.parse?
-        expt_type = ExperimentType()
-        for param in expt_type.parameters:
-            param.from_cif(block)
-
-        # Create experiment instance of appropriate class
-        # TODO: make helper method to create experiment from type
-        scattering_type = expt_type.scattering_type.value
-        sample_form = expt_type.sample_form.value
-        beam_mode = expt_type.beam_mode.value
-        expt_class = cls._SUPPORTED[scattering_type][sample_form][beam_mode]
-        expt_obj = expt_class(name=name, type=expt_type)
-
-        # Read all categories from CIF block
-        # TODO: move to io.cif.parse?
-        for category in expt_obj.categories:
-            category.from_cif(block)
-
-        return expt_obj
-
-    @classmethod
-    def _create_from_cif_path(
-        cls,
-        cif_path: str,
-    ) -> ExperimentBase:
-        """Create an experiment from a CIF file path."""
-        doc = document_from_path(cif_path)
-        block = pick_sole_block(doc)
-        return cls._create_from_gemmi_block(block)
-
-    @classmethod
-    def _create_from_cif_str(
-        cls,
-        cif_str: str,
-    ) -> ExperimentBase:
-        """Create an experiment from a CIF string."""
-        doc = document_from_string(cif_str)
-        block = pick_sole_block(doc)
-        return cls._create_from_gemmi_block(block)
-
-    @classmethod
-    def _create_from_data_path(cls, kwargs):
-        """Create an experiment from a raw data ASCII file.
-
-        Loads the experiment and attaches measured data from the
-        specified file.
-        """
-        expt_type = cls._make_experiment_type(kwargs)
-        scattering_type = expt_type.scattering_type.value
-        sample_form = expt_type.sample_form.value
-        beam_mode = expt_type.beam_mode.value
-        expt_class = cls._SUPPORTED[scattering_type][sample_form][beam_mode]
-        expt_name = kwargs['name']
-        expt_obj = expt_class(name=expt_name, type=expt_type)
-        data_path = kwargs['data_path']
-        expt_obj._load_ascii_data_to_experiment(data_path)
-        return expt_obj
-
-    @classmethod
-    def _create_without_data(cls, kwargs):
-        """Create an experiment without measured data.
-
-        Returns an experiment instance with only metadata and
-        configuration.
-        """
-        expt_type = cls._make_experiment_type(kwargs)
-        scattering_type = expt_type.scattering_type.value
-        sample_form = expt_type.sample_form.value
-        beam_mode = expt_type.beam_mode.value
-        expt_class = cls._SUPPORTED[scattering_type][sample_form][beam_mode]
-        expt_name = kwargs['name']
-        expt_obj = expt_class(name=expt_name, type=expt_type)
-        return expt_obj
-
-    @classmethod
-    def create(cls, **kwargs):
-        """Create an `ExperimentBase` using a validated argument
-        combination.
-        """
-        # TODO: move to FactoryBase
-        # Check for valid argument combinations
-        user_args = {k for k, v in kwargs.items() if v is not None}
-        cls._validate_args(
-            present=user_args,
-            allowed_specs=cls._ALLOWED_ARG_SPECS,
-            factory_name=cls.__name__,  # TODO: move to FactoryBase
-        )
-
-        # Validate enum arguments if provided
-        if 'sample_form' in kwargs:
-            SampleFormEnum(kwargs['sample_form'])
-        if 'beam_mode' in kwargs:
-            BeamModeEnum(kwargs['beam_mode'])
-        if 'radiation_probe' in kwargs:
-            RadiationProbeEnum(kwargs['radiation_probe'])
-        if 'scattering_type' in kwargs:
-            ScatteringTypeEnum(kwargs['scattering_type'])
-
-        # Dispatch to the appropriate creation method
-        if 'cif_path' in kwargs:
-            return cls._create_from_cif_path(kwargs['cif_path'])
-        elif 'cif_str' in kwargs:
-            return cls._create_from_cif_str(kwargs['cif_str'])
-        elif 'data_path' in kwargs:
-            return cls._create_from_data_path(kwargs)
-        elif 'name' in kwargs:
-            return cls._create_without_data(kwargs)
diff --git a/src/easydiffraction/experiments/experiments.py b/src/easydiffraction/experiments/experiments.py
deleted file mode 100644
index 9f58a3b7..00000000
--- a/src/easydiffraction/experiments/experiments.py
+++ /dev/null
@@ -1,134 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from typeguard import typechecked
-
-from easydiffraction.core.datablock import DatablockCollection
-from easydiffraction.experiments.experiment.base import ExperimentBase
-from easydiffraction.experiments.experiment.factory import ExperimentFactory
-from easydiffraction.utils.logging import console
-
-
-class Experiments(DatablockCollection):
-    """Collection of Experiment data blocks.
-
-    Provides convenience constructors for common creation patterns and
-    helper methods for simple presentation of collection contents.
-    """
-
-    def __init__(self) -> None:
-        super().__init__(item_type=ExperimentBase)
-
-    # --------------------
-    # Add / Remove methods
-    # --------------------
-
-    # TODO: Move to DatablockCollection?
-    # TODO: Disallow args and only allow kwargs?
-    def add(self, **kwargs):
-        experiment = kwargs.pop('experiment', None)
-
-        if experiment is None:
-            experiment = ExperimentFactory.create(**kwargs)
-
-        self._add(experiment)
-
-    # @typechecked
-    # def add_from_cif_path(self, cif_path: str):
-    #    """Add an experiment from a CIF file path.
-    #
-    #    Args:
-    #        cif_path: Path to a CIF document.
-    #    """
-    #    experiment = ExperimentFactory.create(cif_path=cif_path)
-    #    self.add(experiment)
-
-    # @typechecked
-    # def add_from_cif_str(self, cif_str: str):
-    #    """Add an experiment from a CIF string.
-    #
-    #    Args:
-    #        cif_str: Full CIF document as a string.
-    #    """
-    #    experiment = ExperimentFactory.create(cif_str=cif_str)
-    #    self.add(experiment)
-
-    # @typechecked
-    # def add_from_data_path(
-    #    self,
-    #    name: str,
-    #    data_path: str,
-    #    sample_form: str = SampleFormEnum.default().value,
-    #    beam_mode: str = BeamModeEnum.default().value,
-    #    radiation_probe: str = RadiationProbeEnum.default().value,
-    #    scattering_type: str = ScatteringTypeEnum.default().value,
-    # ):
-    #    """Add an experiment from a data file path.
-    #
-    #    Args:
-    #        name: Experiment identifier.
-    #        data_path: Path to the measured data file.
-    #        sample_form: Sample form (powder or single crystal).
-    #        beam_mode: Beam mode (constant wavelength or TOF).
-    #        radiation_probe: Radiation probe (neutron or xray).
-    #        scattering_type: Scattering type (bragg or total).
-    #    """
-    #    experiment = ExperimentFactory.create(
-    #        name=name,
-    #        data_path=data_path,
-    #        sample_form=sample_form,
-    #        beam_mode=beam_mode,
-    #        radiation_probe=radiation_probe,
-    #        scattering_type=scattering_type,
-    #    )
-    #    self.add(experiment)
-
-    # @typechecked
-    # def add_without_data(
-    #    self,
-    #    name: str,
-    #    sample_form: str = SampleFormEnum.default().value,
-    #    beam_mode: str = BeamModeEnum.default().value,
-    #    radiation_probe: str = RadiationProbeEnum.default().value,
-    #    scattering_type: str = ScatteringTypeEnum.default().value,
-    # ):
-    #    """Add an experiment without associating a data file.
-    #
-    #    Args:
-    #        name: Experiment identifier.
-    #        sample_form: Sample form (powder or single crystal).
-    #        beam_mode: Beam mode (constant wavelength or TOF).
-    #        radiation_probe: Radiation probe (neutron or xray).
-    #        scattering_type: Scattering type (bragg or total).
-    #    """
-    #    experiment = ExperimentFactory.create(
-    #        name=name,
-    #        sample_form=sample_form,
-    #        beam_mode=beam_mode,
-    #        radiation_probe=radiation_probe,
-    #        scattering_type=scattering_type,
-    #    )
-    #    self.add(experiment)
-
-    # TODO: Move to DatablockCollection?
-    @typechecked
-    def remove(self, name: str) -> None:
-        """Remove an experiment by name if it exists."""
-        if name in self:
-            del self[name]
-
-    # ------------
-    # Show methods
-    # ------------
-
-    # TODO: Move to DatablockCollection?
-    def show_names(self) -> None:
-        """Print the list of experiment names."""
-        console.paragraph('Defined experiments' + ' 🔬')
-        console.print(self.names)
-
-    # TODO: Move to DatablockCollection?
-    def show_params(self) -> None:
-        """Print parameters for each experiment in the collection."""
-        for exp in self.values():
-            exp.show_params()
diff --git a/src/easydiffraction/io/cif/serialize.py b/src/easydiffraction/io/cif/serialize.py
index b6c9dae5..971b08c4 100644
--- a/src/easydiffraction/io/cif/serialize.py
+++ b/src/easydiffraction/io/cif/serialize.py
@@ -19,7 +19,7 @@
 
     from easydiffraction.core.category import CategoryCollection
     from easydiffraction.core.category import CategoryItem
-    from easydiffraction.core.parameters import GenericDescriptorBase
+    from easydiffraction.core.variable import GenericDescriptorBase
 
 
 def format_value(value) -> str:
@@ -189,8 +189,8 @@ def project_to_cif(project) -> str:
     parts: list[str] = []
     if hasattr(project, 'info'):
         parts.append(project.info.as_cif)
-    if getattr(project, 'sample_models', None):
-        parts.append(project.sample_models.as_cif)
+    if getattr(project, 'structures', None):
+        parts.append(project.structures.as_cif)
     if getattr(project, 'experiments', None):
         parts.append(project.experiments.as_cif)
     if getattr(project, 'analysis', None):
@@ -209,13 +209,16 @@ def analysis_to_cif(analysis) -> str:
     """Render analysis metadata, aliases, and constraints to CIF."""
     cur_min = format_value(analysis.current_minimizer)
     lines: list[str] = []
-    lines.append(f'_analysis.calculator_engine  {format_value(analysis.current_calculator)}')
     lines.append(f'_analysis.fitting_engine  {cur_min}')
-    lines.append(f'_analysis.fit_mode  {format_value(analysis.fit_mode)}')
+    lines.append(analysis.fit_mode.as_cif)
     lines.append('')
     lines.append(analysis.aliases.as_cif)
     lines.append('')
     lines.append(analysis.constraints.as_cif)
+    jfe_cif = analysis.joint_fit_experiments.as_cif
+    if jfe_cif:
+        lines.append('')
+        lines.append(jfe_cif)
     return '\n'.join(lines)
 
 
diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py
index 5d0a53ca..f7a3f487 100644
--- a/src/easydiffraction/project/project.py
+++ b/src/easydiffraction/project/project.py
@@ -10,12 +10,12 @@
 
 from easydiffraction.analysis.analysis import Analysis
 from easydiffraction.core.guard import GuardedBase
+from easydiffraction.datablocks.experiment.collection import Experiments
+from easydiffraction.datablocks.structure.collection import Structures
 from easydiffraction.display.plotting import Plotter
 from easydiffraction.display.tables import TableRenderer
-from easydiffraction.experiments.experiments import Experiments
 from easydiffraction.io.cif.serialize import project_to_cif
 from easydiffraction.project.project_info import ProjectInfo
-from easydiffraction.sample_models.sample_models import SampleModels
 from easydiffraction.summary.summary import Summary
 from easydiffraction.utils.logging import console
 from easydiffraction.utils.logging import log
@@ -24,8 +24,7 @@
 class Project(GuardedBase):
     """Central API for managing a diffraction data analysis project.
 
-    Provides access to sample models, experiments, analysis, and
-    summary.
+    Provides access to structures, experiments, analysis, and summary.
     """
 
     # ------------------------------------------------------------------
@@ -40,7 +39,7 @@ def __init__(
         super().__init__()
 
         self._info: ProjectInfo = ProjectInfo(name, title, description)
-        self._sample_models = SampleModels()
+        self._structures = Structures()
         self._experiments = Experiments()
         self._tabler = TableRenderer.get()
         self._plotter = Plotter()
@@ -56,11 +55,11 @@ def __str__(self) -> str:
         """Human-readable representation."""
         class_name = self.__class__.__name__
         project_name = self.name
-        sample_models_count = len(self.sample_models)
+        structures_count = len(self.structures)
         experiments_count = len(self.experiments)
         return (
             f"{class_name} '{project_name}' "
-            f'({sample_models_count} sample models, '
+            f'({structures_count} structures, '
             f'{experiments_count} experiments)'
         )
 
@@ -85,14 +84,14 @@ def full_name(self) -> str:
         return self.name
 
     @property
-    def sample_models(self) -> SampleModels:
-        """Collection of sample models in the project."""
-        return self._sample_models
+    def structures(self) -> Structures:
+        """Collection of structures in the project."""
+        return self._structures
 
-    @sample_models.setter
+    @structures.setter
     @typechecked
-    def sample_models(self, sample_models: SampleModels) -> None:
-        self._sample_models = sample_models
+    def structures(self, structures: Structures) -> None:
+        self._structures = structures
 
     @property
     def experiments(self):
@@ -126,9 +125,8 @@ def summary(self):
 
     @property
     def parameters(self):
-        """Return parameters from all components (TBD)."""
-        # To be implemented: return all parameters in the project
-        return []
+        """Return parameters from all structures and experiments."""
+        return self.structures.parameters + self.experiments.parameters
 
     @property
     def as_cif(self):
@@ -143,14 +141,10 @@ def as_cif(self):
     def load(self, dir_path: str) -> None:
         """Load a project from a given directory.
 
-        Loads project info, sample models, experiments, etc.
+        Loads project info, structures, experiments, etc.
         """
-        console.paragraph('Loading project 📦 from')
-        console.print(dir_path)
-        self._info.path = dir_path
         # TODO: load project components from files inside dir_path
-        console.print('Loading project is not implemented yet.')
-        self._saved = True
+        raise NotImplementedError('Project.load() is not implemented yet.')
 
     def save(self) -> None:
         """Save the project into the existing project directory."""
@@ -169,17 +163,17 @@ def save(self) -> None:
             f.write(self._info.as_cif())
             console.print('├── 📄 project.cif')
 
-        # Save sample models
-        sm_dir = self._info.path / 'sample_models'
+        # Save structures
+        sm_dir = self._info.path / 'structures'
         sm_dir.mkdir(parents=True, exist_ok=True)
-        # Iterate over sample model objects (MutableMapping iter gives
+        # Iterate over structure objects (MutableMapping iter gives
         # keys)
-        for model in self.sample_models.values():
-            file_name: str = f'{model.name}.cif'
+        for structure in self.structures.values():
+            file_name: str = f'{structure.name}.cif'
             file_path = sm_dir / file_name
-            console.print('├── 📁 sample_models')
+            console.print('├── 📁 structures')
             with file_path.open('w') as f:
-                f.write(model.as_cif)
+                f.write(structure.as_cif)
                 console.print(f'│   └── 📄 {file_name}')
 
         # Save experiments
@@ -223,8 +217,8 @@ def save_as(
     # ------------------------------------------
 
     def _update_categories(self, expt_name) -> None:
-        for sample_model in self.sample_models:
-            sample_model._update_categories()
+        for structure in self.structures:
+            structure._update_categories()
         self.analysis._update_categories()
         experiment = self.experiments[expt_name]
         experiment._update_categories()
diff --git a/src/easydiffraction/sample_models/categories/atom_sites.py b/src/easydiffraction/sample_models/categories/atom_sites.py
deleted file mode 100644
index 955c62df..00000000
--- a/src/easydiffraction/sample_models/categories/atom_sites.py
+++ /dev/null
@@ -1,334 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Atom site category.
-
-Defines AtomSite items and AtomSites collection used in sample models.
-Only documentation was added; behavior remains unchanged.
-"""
-
-from cryspy.A_functions_base.database import DATABASE
-
-from easydiffraction.core.category import CategoryCollection
-from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.parameters import StringDescriptor
-from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
-from easydiffraction.core.validation import MembershipValidator
-from easydiffraction.core.validation import RangeValidator
-from easydiffraction.core.validation import RegexValidator
-from easydiffraction.crystallography import crystallography as ecr
-from easydiffraction.io.cif.handler import CifHandler
-
-
-class AtomSite(CategoryItem):
-    """Single atom site with fractional coordinates and ADP.
-
-    Attributes are represented by descriptors to support validation and
-    CIF serialization.
-    """
-
-    def __init__(
-        self,
-        *,
-        label=None,
-        type_symbol=None,
-        fract_x=None,
-        fract_y=None,
-        fract_z=None,
-        wyckoff_letter=None,
-        occupancy=None,
-        b_iso=None,
-        adp_type=None,
-    ) -> None:
-        super().__init__()
-
-        self._label: StringDescriptor = StringDescriptor(
-            name='label',
-            description='Unique identifier for the atom site.',
-            value_spec=AttributeSpec(
-                value=label,
-                type_=DataTypes.STRING,
-                default='Si',
-                # TODO: the following pattern is valid for dict key
-                #  (keywords are not checked). CIF label is less strict.
-                #  Do we need conversion between CIF and internal label?
-                content_validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_atom_site.label',
-                ]
-            ),
-        )
-        self._type_symbol: StringDescriptor = StringDescriptor(
-            name='type_symbol',
-            description='Chemical symbol of the atom at this site.',
-            value_spec=AttributeSpec(
-                value=type_symbol,
-                type_=DataTypes.STRING,
-                default='Tb',
-                content_validator=MembershipValidator(allowed=self._type_symbol_allowed_values),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_atom_site.type_symbol',
-                ]
-            ),
-        )
-        self._fract_x: Parameter = Parameter(
-            name='fract_x',
-            description='Fractional x-coordinate of the atom site within the unit cell.',
-            value_spec=AttributeSpec(
-                value=fract_x,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_atom_site.fract_x',
-                ]
-            ),
-        )
-        self._fract_y: Parameter = Parameter(
-            name='fract_y',
-            description='Fractional y-coordinate of the atom site within the unit cell.',
-            value_spec=AttributeSpec(
-                value=fract_y,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_atom_site.fract_y',
-                ]
-            ),
-        )
-        self._fract_z: Parameter = Parameter(
-            name='fract_z',
-            description='Fractional z-coordinate of the atom site within the unit cell.',
-            value_spec=AttributeSpec(
-                value=fract_z,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_atom_site.fract_z',
-                ]
-            ),
-        )
-        self._wyckoff_letter: StringDescriptor = StringDescriptor(
-            name='wyckoff_letter',
-            description='Wyckoff letter indicating the symmetry of the '
-            'atom site within the space group.',
-            value_spec=AttributeSpec(
-                value=wyckoff_letter,
-                type_=DataTypes.STRING,
-                default=self._wyckoff_letter_default_value,
-                content_validator=MembershipValidator(allowed=self._wyckoff_letter_allowed_values),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_atom_site.Wyckoff_letter',
-                    '_atom_site.Wyckoff_symbol',
-                ]
-            ),
-        )
-        self._occupancy: Parameter = Parameter(
-            name='occupancy',
-            description='Occupancy of the atom site, representing the '
-            'fraction of the site occupied by the atom type.',
-            value_spec=AttributeSpec(
-                value=occupancy,
-                type_=DataTypes.NUMERIC,
-                default=1.0,
-                content_validator=RangeValidator(),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_atom_site.occupancy',
-                ]
-            ),
-        )
-        self._b_iso: Parameter = Parameter(
-            name='b_iso',
-            description='Isotropic atomic displacement parameter (ADP) for the atom site.',
-            value_spec=AttributeSpec(
-                value=b_iso,
-                type_=DataTypes.NUMERIC,
-                default=0.0,
-                content_validator=RangeValidator(ge=0.0),
-            ),
-            units='Ų',
-            cif_handler=CifHandler(
-                names=[
-                    '_atom_site.B_iso_or_equiv',
-                ]
-            ),
-        )
-        self._adp_type: StringDescriptor = StringDescriptor(
-            name='adp_type',
-            description='Type of atomic displacement parameter (ADP) '
-            'used (e.g., Biso, Uiso, Uani, Bani).',
-            value_spec=AttributeSpec(
-                value=adp_type,
-                type_=DataTypes.STRING,
-                default='Biso',
-                content_validator=MembershipValidator(allowed=['Biso']),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_atom_site.adp_type',
-                ]
-            ),
-        )
-
-        self._identity.category_code = 'atom_site'
-        self._identity.category_entry_name = lambda: str(self.label.value)
-
-    @property
-    def _type_symbol_allowed_values(self):
-        return list({key[1] for key in DATABASE['Isotopes']})
-
-    @property
-    def _wyckoff_letter_allowed_values(self):
-        # TODO: Need to now current space group. How to access it? Via
-        #  parent Cell? Then letters =
-        #  list(SPACE_GROUPS[62, 'cab']['Wyckoff_positions'].keys())
-        #  Temporarily return hardcoded list:
-        return ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']
-
-    @property
-    def _wyckoff_letter_default_value(self):
-        # TODO: What to pass as default?
-        return self._wyckoff_letter_allowed_values[0]
-
-    @property
-    def label(self):
-        """Label descriptor for the site (unique key)."""
-        return self._label
-
-    @label.setter
-    def label(self, value):
-        self._label.value = value
-
-    @property
-    def type_symbol(self):
-        """Chemical symbol descriptor (e.g. 'Si')."""
-        return self._type_symbol
-
-    @type_symbol.setter
-    def type_symbol(self, value):
-        self._type_symbol.value = value
-
-    @property
-    def adp_type(self):
-        """ADP type descriptor (e.g. 'Biso')."""
-        return self._adp_type
-
-    @adp_type.setter
-    def adp_type(self, value):
-        self._adp_type.value = value
-
-    @property
-    def wyckoff_letter(self):
-        """Wyckoff letter descriptor (space-group position)."""
-        return self._wyckoff_letter
-
-    @wyckoff_letter.setter
-    def wyckoff_letter(self, value):
-        self._wyckoff_letter.value = value
-
-    @property
-    def fract_x(self):
-        """Fractional x coordinate descriptor."""
-        return self._fract_x
-
-    @fract_x.setter
-    def fract_x(self, value):
-        self._fract_x.value = value
-
-    @property
-    def fract_y(self):
-        """Fractional y coordinate descriptor."""
-        return self._fract_y
-
-    @fract_y.setter
-    def fract_y(self, value):
-        self._fract_y.value = value
-
-    @property
-    def fract_z(self):
-        """Fractional z coordinate descriptor."""
-        return self._fract_z
-
-    @fract_z.setter
-    def fract_z(self, value):
-        self._fract_z.value = value
-
-    @property
-    def occupancy(self):
-        """Occupancy descriptor (0..1)."""
-        return self._occupancy
-
-    @occupancy.setter
-    def occupancy(self, value):
-        self._occupancy.value = value
-
-    @property
-    def b_iso(self):
-        """Isotropic ADP descriptor in Ų."""
-        return self._b_iso
-
-    @b_iso.setter
-    def b_iso(self, value):
-        self._b_iso.value = value
-
-
-class AtomSites(CategoryCollection):
-    """Collection of AtomSite instances."""
-
-    def __init__(self):
-        super().__init__(item_type=AtomSite)
-
-    def _apply_atomic_coordinates_symmetry_constraints(self):
-        """Apply symmetry rules to fractional coordinates of atom
-        sites.
-        """
-        sample_model = self._parent
-        space_group_name = sample_model.space_group.name_h_m.value
-        space_group_coord_code = sample_model.space_group.it_coordinate_system_code.value
-        for atom in self._items:
-            dummy_atom = {
-                'fract_x': atom.fract_x.value,
-                'fract_y': atom.fract_y.value,
-                'fract_z': atom.fract_z.value,
-            }
-            wl = atom.wyckoff_letter.value
-            if not wl:
-                # TODO: Decide how to handle this case
-                #  For now, we just skip applying constraints if wyckoff
-                #  letter is not set. Alternatively, could raise an
-                #  error or warning
-                #  print(f"Warning: Wyckoff letter is not ...")
-                #  raise ValueError("Wyckoff letter is not ...")
-                continue
-            ecr.apply_atom_site_symmetry_constraints(
-                atom_site=dummy_atom,
-                name_hm=space_group_name,
-                coord_code=space_group_coord_code,
-                wyckoff_letter=wl,
-            )
-            atom.fract_x.value = dummy_atom['fract_x']
-            atom.fract_y.value = dummy_atom['fract_y']
-            atom.fract_z.value = dummy_atom['fract_z']
-
-    def _update(self, called_by_minimizer=False):
-        """Update atom sites by applying symmetry constraints."""
-        del called_by_minimizer
-
-        self._apply_atomic_coordinates_symmetry_constraints()
diff --git a/src/easydiffraction/sample_models/categories/cell.py b/src/easydiffraction/sample_models/categories/cell.py
deleted file mode 100644
index 80c0b144..00000000
--- a/src/easydiffraction/sample_models/categories/cell.py
+++ /dev/null
@@ -1,188 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Unit cell parameters category for sample models."""
-
-from typing import Optional
-
-from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import Parameter
-from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
-from easydiffraction.core.validation import RangeValidator
-from easydiffraction.crystallography import crystallography as ecr
-from easydiffraction.io.cif.handler import CifHandler
-
-
-class Cell(CategoryItem):
-    """Unit cell with lengths a, b, c and angles alpha, beta, gamma."""
-
-    def __init__(
-        self,
-        *,
-        length_a: Optional[int | float] = None,
-        length_b: Optional[int | float] = None,
-        length_c: Optional[int | float] = None,
-        angle_alpha: Optional[int | float] = None,
-        angle_beta: Optional[int | float] = None,
-        angle_gamma: Optional[int | float] = None,
-    ) -> None:
-        super().__init__()
-
-        self._length_a: Parameter = Parameter(
-            name='length_a',
-            description='Length of the a axis of the unit cell.',
-            value_spec=AttributeSpec(
-                value=length_a,
-                type_=DataTypes.NUMERIC,
-                default=10.0,
-                content_validator=RangeValidator(ge=0, le=1000),
-            ),
-            units='Å',
-            cif_handler=CifHandler(names=['_cell.length_a']),
-        )
-        self._length_b: Parameter = Parameter(
-            name='length_b',
-            description='Length of the b axis of the unit cell.',
-            value_spec=AttributeSpec(
-                value=length_b,
-                type_=DataTypes.NUMERIC,
-                default=10.0,
-                content_validator=RangeValidator(ge=0, le=1000),
-            ),
-            units='Å',
-            cif_handler=CifHandler(names=['_cell.length_b']),
-        )
-        self._length_c: Parameter = Parameter(
-            name='length_c',
-            description='Length of the c axis of the unit cell.',
-            value_spec=AttributeSpec(
-                value=length_c,
-                type_=DataTypes.NUMERIC,
-                default=10.0,
-                content_validator=RangeValidator(ge=0, le=1000),
-            ),
-            units='Å',
-            cif_handler=CifHandler(names=['_cell.length_c']),
-        )
-        self._angle_alpha: Parameter = Parameter(
-            name='angle_alpha',
-            description='Angle between edges b and c.',
-            value_spec=AttributeSpec(
-                value=angle_alpha,
-                type_=DataTypes.NUMERIC,
-                default=90.0,
-                content_validator=RangeValidator(ge=0, le=180),
-            ),
-            units='deg',
-            cif_handler=CifHandler(names=['_cell.angle_alpha']),
-        )
-        self._angle_beta: Parameter = Parameter(
-            name='angle_beta',
-            description='Angle between edges a and c.',
-            value_spec=AttributeSpec(
-                value=angle_beta,
-                type_=DataTypes.NUMERIC,
-                default=90.0,
-                content_validator=RangeValidator(ge=0, le=180),
-            ),
-            units='deg',
-            cif_handler=CifHandler(names=['_cell.angle_beta']),
-        )
-        self._angle_gamma: Parameter = Parameter(
-            name='angle_gamma',
-            description='Angle between edges a and b.',
-            value_spec=AttributeSpec(
-                value=angle_gamma,
-                type_=DataTypes.NUMERIC,
-                default=90.0,
-                content_validator=RangeValidator(ge=0, le=180),
-            ),
-            units='deg',
-            cif_handler=CifHandler(names=['_cell.angle_gamma']),
-        )
-
-        self._identity.category_code = 'cell'
-
-    @property
-    def length_a(self):
-        """Descriptor for a-axis length in Å."""
-        return self._length_a
-
-    @length_a.setter
-    def length_a(self, value):
-        self._length_a.value = value
-
-    @property
-    def length_b(self):
-        """Descriptor for b-axis length in Å."""
-        return self._length_b
-
-    @length_b.setter
-    def length_b(self, value):
-        self._length_b.value = value
-
-    @property
-    def length_c(self):
-        """Descriptor for c-axis length in Å."""
-        return self._length_c
-
-    @length_c.setter
-    def length_c(self, value):
-        self._length_c.value = value
-
-    @property
-    def angle_alpha(self):
-        """Descriptor for angle alpha in degrees."""
-        return self._angle_alpha
-
-    @angle_alpha.setter
-    def angle_alpha(self, value):
-        self._angle_alpha.value = value
-
-    @property
-    def angle_beta(self):
-        """Descriptor for angle beta in degrees."""
-        return self._angle_beta
-
-    @angle_beta.setter
-    def angle_beta(self, value):
-        self._angle_beta.value = value
-
-    @property
-    def angle_gamma(self):
-        """Descriptor for angle gamma in degrees."""
-        return self._angle_gamma
-
-    @angle_gamma.setter
-    def angle_gamma(self, value):
-        self._angle_gamma.value = value
-
-    def _apply_cell_symmetry_constraints(self):
-        """Apply symmetry constraints to cell parameters."""
-        dummy_cell = {
-            'lattice_a': self.length_a.value,
-            'lattice_b': self.length_b.value,
-            'lattice_c': self.length_c.value,
-            'angle_alpha': self.angle_alpha.value,
-            'angle_beta': self.angle_beta.value,
-            'angle_gamma': self.angle_gamma.value,
-        }
-        space_group_name = self._parent.space_group.name_h_m.value
-
-        ecr.apply_cell_symmetry_constraints(
-            cell=dummy_cell,
-            name_hm=space_group_name,
-        )
-
-        self.length_a.value = dummy_cell['lattice_a']
-        self.length_b.value = dummy_cell['lattice_b']
-        self.length_c.value = dummy_cell['lattice_c']
-        self.angle_alpha.value = dummy_cell['angle_alpha']
-        self.angle_beta.value = dummy_cell['angle_beta']
-        self.angle_gamma.value = dummy_cell['angle_gamma']
-
-    def _update(self, called_by_minimizer=False):
-        """Update cell parameters by applying symmetry constraints."""
-        del called_by_minimizer
-
-        self._apply_cell_symmetry_constraints()
diff --git a/src/easydiffraction/sample_models/categories/space_group.py b/src/easydiffraction/sample_models/categories/space_group.py
deleted file mode 100644
index 3e726b17..00000000
--- a/src/easydiffraction/sample_models/categories/space_group.py
+++ /dev/null
@@ -1,107 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Space group category for crystallographic sample models."""
-
-from cryspy.A_functions_base.function_2_space_group import ACCESIBLE_NAME_HM_SHORT
-from cryspy.A_functions_base.function_2_space_group import (
-    get_it_coordinate_system_codes_by_it_number,
-)
-from cryspy.A_functions_base.function_2_space_group import get_it_number_by_name_hm_short
-
-from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import StringDescriptor
-from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
-from easydiffraction.core.validation import MembershipValidator
-from easydiffraction.io.cif.handler import CifHandler
-
-
-class SpaceGroup(CategoryItem):
-    """Space group with Hermann–Mauguin symbol and IT code."""
-
-    def __init__(
-        self,
-        *,
-        name_h_m: str = None,
-        it_coordinate_system_code: str = None,
-    ) -> None:
-        super().__init__()
-        self._name_h_m: StringDescriptor = StringDescriptor(
-            name='name_h_m',
-            description='Hermann-Mauguin symbol of the space group.',
-            value_spec=AttributeSpec(
-                value=name_h_m,
-                type_=DataTypes.STRING,
-                default='P 1',
-                content_validator=MembershipValidator(
-                    allowed=lambda: self._name_h_m_allowed_values
-                ),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_space_group.name_H-M_alt',
-                    '_space_group_name_H-M_alt',
-                    '_symmetry.space_group_name_H-M',
-                    '_symmetry_space_group_name_H-M',
-                ]
-            ),
-        )
-        self._it_coordinate_system_code: StringDescriptor = StringDescriptor(
-            name='it_coordinate_system_code',
-            description='A qualifier identifying which setting in IT is used.',
-            value_spec=AttributeSpec(
-                value=it_coordinate_system_code,
-                type_=DataTypes.STRING,
-                default=lambda: self._it_coordinate_system_code_default_value,
-                content_validator=MembershipValidator(
-                    allowed=lambda: self._it_coordinate_system_code_allowed_values
-                ),
-            ),
-            cif_handler=CifHandler(
-                names=[
-                    '_space_group.IT_coordinate_system_code',
-                    '_space_group_IT_coordinate_system_code',
-                    '_symmetry.IT_coordinate_system_code',
-                    '_symmetry_IT_coordinate_system_code',
-                ]
-            ),
-        )
-        self._identity.category_code = 'space_group'
-
-    def _reset_it_coordinate_system_code(self):
-        self._it_coordinate_system_code.value = self._it_coordinate_system_code_default_value
-
-    @property
-    def _name_h_m_allowed_values(self):
-        return ACCESIBLE_NAME_HM_SHORT
-
-    @property
-    def _it_coordinate_system_code_allowed_values(self):
-        name = self.name_h_m.value
-        it_number = get_it_number_by_name_hm_short(name)
-        codes = get_it_coordinate_system_codes_by_it_number(it_number)
-        codes = [str(code) for code in codes]
-        return codes if codes else ['']
-
-    @property
-    def _it_coordinate_system_code_default_value(self):
-        return self._it_coordinate_system_code_allowed_values[0]
-
-    @property
-    def name_h_m(self):
-        """Descriptor for Hermann–Mauguin symbol."""
-        return self._name_h_m
-
-    @name_h_m.setter
-    def name_h_m(self, value):
-        self._name_h_m.value = value
-        self._reset_it_coordinate_system_code()
-
-    @property
-    def it_coordinate_system_code(self):
-        """Descriptor for IT coordinate system code."""
-        return self._it_coordinate_system_code
-
-    @it_coordinate_system_code.setter
-    def it_coordinate_system_code(self, value):
-        self._it_coordinate_system_code.value = value
diff --git a/src/easydiffraction/sample_models/sample_model/base.py b/src/easydiffraction/sample_models/sample_model/base.py
deleted file mode 100644
index 7ae135de..00000000
--- a/src/easydiffraction/sample_models/sample_model/base.py
+++ /dev/null
@@ -1,183 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from easydiffraction.core.datablock import DatablockItem
-from easydiffraction.crystallography import crystallography as ecr
-from easydiffraction.sample_models.categories.atom_sites import AtomSites
-from easydiffraction.sample_models.categories.cell import Cell
-from easydiffraction.sample_models.categories.space_group import SpaceGroup
-from easydiffraction.utils.logging import console
-from easydiffraction.utils.utils import render_cif
-
-
-class SampleModelBase(DatablockItem):
-    """Base sample model and container for structural information.
-
-    Holds space group, unit cell and atom-site categories. The
-    factory is responsible for creating rich instances from CIF;
-    this base accepts just the ``name`` and exposes helpers for
-    applying symmetry.
-    """
-
-    def __init__(
-        self,
-        *,
-        name,
-    ) -> None:
-        super().__init__()
-        self._name = name
-        self._cell: Cell = Cell()
-        self._space_group: SpaceGroup = SpaceGroup()
-        self._atom_sites: AtomSites = AtomSites()
-        self._identity.datablock_entry_name = lambda: self.name
-
-    def __str__(self) -> str:
-        """Human-readable representation of this component."""
-        name = self._log_name
-        items = ', '.join(
-            f'{k}={v}'
-            for k, v in {
-                'cell': self.cell,
-                'space_group': self.space_group,
-                'atom_sites': self.atom_sites,
-            }.items()
-        )
-        return f'<{name} ({items})>'
-
-    @property
-    def name(self) -> str:
-        """Model name.
-
-        Returns:
-            The user-facing identifier for this model.
-        """
-        return self._name
-
-    @name.setter
-    def name(self, new: str) -> None:
-        """Update model name."""
-        self._name = new
-
-    @property
-    def cell(self) -> Cell:
-        """Unit-cell category object."""
-        return self._cell
-
-    @cell.setter
-    def cell(self, new: Cell) -> None:
-        """Replace the unit-cell category object."""
-        self._cell = new
-
-    @property
-    def space_group(self) -> SpaceGroup:
-        """Space-group category object."""
-        return self._space_group
-
-    @space_group.setter
-    def space_group(self, new: SpaceGroup) -> None:
-        """Replace the space-group category object."""
-        self._space_group = new
-
-    @property
-    def atom_sites(self) -> AtomSites:
-        """Atom-sites collection for this model."""
-        return self._atom_sites
-
-    @atom_sites.setter
-    def atom_sites(self, new: AtomSites) -> None:
-        """Replace the atom-sites collection."""
-        self._atom_sites = new
-
-    # --------------------
-    # Symmetry constraints
-    # --------------------
-
-    def _apply_cell_symmetry_constraints(self):
-        """Apply symmetry rules to unit-cell parameters in place."""
-        dummy_cell = {
-            'lattice_a': self.cell.length_a.value,
-            'lattice_b': self.cell.length_b.value,
-            'lattice_c': self.cell.length_c.value,
-            'angle_alpha': self.cell.angle_alpha.value,
-            'angle_beta': self.cell.angle_beta.value,
-            'angle_gamma': self.cell.angle_gamma.value,
-        }
-        space_group_name = self.space_group.name_h_m.value
-        ecr.apply_cell_symmetry_constraints(cell=dummy_cell, name_hm=space_group_name)
-        self.cell.length_a.value = dummy_cell['lattice_a']
-        self.cell.length_b.value = dummy_cell['lattice_b']
-        self.cell.length_c.value = dummy_cell['lattice_c']
-        self.cell.angle_alpha.value = dummy_cell['angle_alpha']
-        self.cell.angle_beta.value = dummy_cell['angle_beta']
-        self.cell.angle_gamma.value = dummy_cell['angle_gamma']
-
-    def _apply_atomic_coordinates_symmetry_constraints(self):
-        """Apply symmetry rules to fractional coordinates of atom
-        sites.
-        """
-        space_group_name = self.space_group.name_h_m.value
-        space_group_coord_code = self.space_group.it_coordinate_system_code.value
-        for atom in self.atom_sites:
-            dummy_atom = {
-                'fract_x': atom.fract_x.value,
-                'fract_y': atom.fract_y.value,
-                'fract_z': atom.fract_z.value,
-            }
-            wl = atom.wyckoff_letter.value
-            if not wl:
-                # TODO: Decide how to handle this case
-                #  For now, we just skip applying constraints if wyckoff
-                #  letter is not set. Alternatively, could raise an
-                #  error or warning
-                #  print(f"Warning: Wyckoff letter is not ...")
-                #  raise ValueError("Wyckoff letter is not ...")
-                continue
-            ecr.apply_atom_site_symmetry_constraints(
-                atom_site=dummy_atom,
-                name_hm=space_group_name,
-                coord_code=space_group_coord_code,
-                wyckoff_letter=wl,
-            )
-            atom.fract_x.value = dummy_atom['fract_x']
-            atom.fract_y.value = dummy_atom['fract_y']
-            atom.fract_z.value = dummy_atom['fract_z']
-
-    def _apply_atomic_displacement_symmetry_constraints(self):
-        """Placeholder for ADP symmetry constraints (not
-        implemented).
-        """
-        pass
-
-    def _apply_symmetry_constraints(self):
-        """Apply all available symmetry constraints to this model."""
-        self._apply_cell_symmetry_constraints()
-        self._apply_atomic_coordinates_symmetry_constraints()
-        self._apply_atomic_displacement_symmetry_constraints()
-
-    # ------------
-    # Show methods
-    # ------------
-
-    def show_structure(self):
-        """Show an ASCII projection of the structure on a 2D plane."""
-        console.paragraph(f"Sample model 🧩 '{self.name}' structure view")
-        console.print('Not implemented yet.')
-
-    def show_params(self):
-        """Display structural parameters (space group, cell, atom
-        sites).
-        """
-        console.print(f'\nSampleModel ID: {self.name}')
-        console.print(f'Space group: {self.space_group.name_h_m}')
-        console.print(f'Cell parameters: {self.cell.as_dict}')
-        console.print('Atom sites:')
-        self.atom_sites.show()
-
-    def show_as_cif(self) -> None:
-        """Render the CIF text for this model in a terminal-friendly
-        view.
-        """
-        cif_text: str = self.as_cif
-        paragraph_title: str = f"Sample model 🧩 '{self.name}' as cif"
-        console.paragraph(paragraph_title)
-        render_cif(cif_text)
diff --git a/src/easydiffraction/sample_models/sample_model/factory.py b/src/easydiffraction/sample_models/sample_model/factory.py
deleted file mode 100644
index 7a03b0f0..00000000
--- a/src/easydiffraction/sample_models/sample_model/factory.py
+++ /dev/null
@@ -1,103 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-"""Factory for creating sample models from simple inputs or CIF.
-
-Supports three argument combinations: ``name``, ``cif_path``, or
-``cif_str``. Returns a minimal ``SampleModelBase`` populated from CIF
-when provided, or an empty model with the given name.
-"""
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-from easydiffraction.core.factory import FactoryBase
-from easydiffraction.io.cif.parse import document_from_path
-from easydiffraction.io.cif.parse import document_from_string
-from easydiffraction.io.cif.parse import name_from_block
-from easydiffraction.io.cif.parse import pick_sole_block
-from easydiffraction.sample_models.sample_model.base import SampleModelBase
-
-if TYPE_CHECKING:
-    import gemmi
-
-
-class SampleModelFactory(FactoryBase):
-    """Create ``SampleModelBase`` instances from supported inputs."""
-
-    _ALLOWED_ARG_SPECS = [
-        {'required': ['name'], 'optional': []},
-        {'required': ['cif_path'], 'optional': []},
-        {'required': ['cif_str'], 'optional': []},
-    ]
-
-    @classmethod
-    def _create_from_gemmi_block(
-        cls,
-        block: gemmi.cif.Block,
-    ) -> SampleModelBase:
-        """Build a model instance from a single CIF block."""
-        name = name_from_block(block)
-        sample_model = SampleModelBase(name=name)
-        for category in sample_model.categories:
-            category.from_cif(block)
-        return sample_model
-
-    @classmethod
-    def _create_from_cif_path(
-        cls,
-        cif_path: str,
-    ) -> SampleModelBase:
-        """Create a model by reading and parsing a CIF file."""
-        doc = document_from_path(cif_path)
-        block = pick_sole_block(doc)
-        return cls._create_from_gemmi_block(block)
-
-    @classmethod
-    def _create_from_cif_str(
-        cls,
-        cif_str: str,
-    ) -> SampleModelBase:
-        """Create a model by parsing a CIF string."""
-        doc = document_from_string(cif_str)
-        block = pick_sole_block(doc)
-        return cls._create_from_gemmi_block(block)
-
-    @classmethod
-    def _create_minimal(
-        cls,
-        name: str,
-    ) -> SampleModelBase:
-        """Create a minimal default model with just a name."""
-        return SampleModelBase(name=name)
-
-    @classmethod
-    def create(cls, **kwargs):
-        """Create a model based on a validated argument combination.
-
-        Keyword Args:
-            name: Name of the sample model to create.
-            cif_path: Path to a CIF file to parse.
-            cif_str: Raw CIF string to parse.
-            **kwargs: Extra args are ignored if None; only the above
-                three keys are supported.
-
-        Returns:
-            SampleModelBase: A populated or empty model instance.
-        """
-        # TODO: move to FactoryBase
-        # Check for valid argument combinations
-        user_args = {k for k, v in kwargs.items() if v is not None}
-        cls._validate_args(
-            present=user_args,
-            allowed_specs=cls._ALLOWED_ARG_SPECS,
-            factory_name=cls.__name__,  # TODO: move to FactoryBase
-        )
-
-        # Dispatch to the appropriate creation method
-        if 'cif_path' in kwargs:
-            return cls._create_from_cif_path(kwargs['cif_path'])
-        elif 'cif_str' in kwargs:
-            return cls._create_from_cif_str(kwargs['cif_str'])
-        elif 'name' in kwargs:
-            return cls._create_minimal(kwargs['name'])
diff --git a/src/easydiffraction/sample_models/sample_models.py b/src/easydiffraction/sample_models/sample_models.py
deleted file mode 100644
index bb85d2e9..00000000
--- a/src/easydiffraction/sample_models/sample_models.py
+++ /dev/null
@@ -1,87 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from typeguard import typechecked
-
-from easydiffraction.core.datablock import DatablockCollection
-from easydiffraction.sample_models.sample_model.base import SampleModelBase
-from easydiffraction.sample_models.sample_model.factory import SampleModelFactory
-from easydiffraction.utils.logging import console
-
-
-class SampleModels(DatablockCollection):
-    """Collection manager for multiple SampleModel instances."""
-
-    def __init__(self) -> None:
-        super().__init__(item_type=SampleModelBase)
-
-    # --------------------
-    # Add / Remove methods
-    # --------------------
-
-    # TODO: Move to DatablockCollection?
-    # TODO: Disallow args and only allow kwargs?
-    def add(self, **kwargs):
-        sample_model = kwargs.pop('sample_model', None)
-
-        if sample_model is None:
-            sample_model = SampleModelFactory.create(**kwargs)
-
-        self._add(sample_model)
-
-    # @typechecked
-    # def add_from_cif_path(self, cif_path: str) -> None:
-    #    """Create and add a model from a CIF file path.#
-    #
-    #    Args:
-    #        cif_path: Path to a CIF file.
-    #    """
-    #    sample_model = SampleModelFactory.create(cif_path=cif_path)
-    #    self.add(sample_model)
-
-    # @typechecked
-    # def add_from_cif_str(self, cif_str: str) -> None:
-    #    """Create and add a model from CIF content (string).
-    #
-    #    Args:
-    #        cif_str: CIF file content.
-    #    """
-    #    sample_model = SampleModelFactory.create(cif_str=cif_str)
-    #    self.add(sample_model)
-
-    # @typechecked
-    # def add_minimal(self, name: str) -> None:
-    #    """Create and add a minimal model (defaults, no atoms).
-    #
-    #    Args:
-    #        name: Identifier to assign to the new model.
-    #    """
-    #    sample_model = SampleModelFactory.create(name=name)
-    #    self.add(sample_model)
-
-    # TODO: Move to DatablockCollection?
-    @typechecked
-    def remove(self, name: str) -> None:
-        """Remove a sample model by its ID.
-
-        Args:
-            name: ID of the model to remove.
-        """
-        if name in self:
-            del self[name]
-
-    # ------------
-    # Show methods
-    # ------------
-
-    # TODO: Move to DatablockCollection?
-    def show_names(self) -> None:
-        """List all model names in the collection."""
-        console.paragraph('Defined sample models' + ' 🧩')
-        console.print(self.names)
-
-    # TODO: Move to DatablockCollection?
-    def show_params(self) -> None:
-        """Show parameters of all sample models in the collection."""
-        for model in self.values():
-            model.show_params()
diff --git a/src/easydiffraction/summary/summary.py b/src/easydiffraction/summary/summary.py
index 7d28c875..7e72d825 100644
--- a/src/easydiffraction/summary/summary.py
+++ b/src/easydiffraction/summary/summary.py
@@ -57,7 +57,7 @@ def show_crystallographic_data(self) -> None:
         """
         console.section('Crystallographic data')
 
-        for model in self.project.sample_models.values():
+        for model in self.project.structures.values():
             console.paragraph('Phase datablock')
             console.print(f'🧩 {model.name}')
 
@@ -180,7 +180,8 @@ def show_fitting_details(self) -> None:
         console.section('Fitting')
 
         console.paragraph('Calculation engine')
-        console.print(self.project.analysis.current_calculator)
+        for expt in self.project.experiments.values():
+            console.print(f'  {expt.name}: {expt.calculator_type}')
 
         console.paragraph('Minimization engine')
         console.print(self.project.analysis.current_minimizer)
diff --git a/src/easydiffraction/utils/__init__.py b/src/easydiffraction/utils/__init__.py
index e40e0816..d193bf2f 100644
--- a/src/easydiffraction/utils/__init__.py
+++ b/src/easydiffraction/utils/__init__.py
@@ -3,8 +3,3 @@
 
 from easydiffraction.utils.utils import _is_dev_version
 from easydiffraction.utils.utils import stripped_package_version
-
-__all__ = [
-    '_is_dev_version',
-    'stripped_package_version',
-]
diff --git a/tests/integration/fitting/test_multi.py b/tests/integration/fitting/test_multi.py
new file mode 100644
index 00000000..60e78f0a
--- /dev/null
+++ b/tests/integration/fitting/test_multi.py
@@ -0,0 +1,236 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+import tempfile
+
+from numpy.testing import assert_almost_equal
+
+import easydiffraction as ed
+from easydiffraction import ExperimentFactory
+from easydiffraction import Project
+from easydiffraction import StructureFactory
+from easydiffraction import download_data
+
+TEMP_DIR = tempfile.gettempdir()
+
+
+def test_single_fit_neutron_pd_tof_mcstas_lbco_si() -> None:
+    # Set structures
+    model_1 = StructureFactory.from_scratch(name='lbco')
+    model_1.space_group.name_h_m = 'P m -3 m'
+    model_1.space_group.it_coordinate_system_code = '1'
+    model_1.cell.length_a = 3.8909
+    model_1.atom_sites.create(
+        label='La',
+        type_symbol='La',
+        fract_x=0,
+        fract_y=0,
+        fract_z=0,
+        wyckoff_letter='a',
+        b_iso=0.2,
+        occupancy=0.5,
+    )
+    model_1.atom_sites.create(
+        label='Ba',
+        type_symbol='Ba',
+        fract_x=0,
+        fract_y=0,
+        fract_z=0,
+        wyckoff_letter='a',
+        b_iso=0.2,
+        occupancy=0.5,
+    )
+    model_1.atom_sites.create(
+        label='Co',
+        type_symbol='Co',
+        fract_x=0.5,
+        fract_y=0.5,
+        fract_z=0.5,
+        wyckoff_letter='b',
+        b_iso=0.2567,
+    )
+    model_1.atom_sites.create(
+        label='O',
+        type_symbol='O',
+        fract_x=0,
+        fract_y=0.5,
+        fract_z=0.5,
+        wyckoff_letter='c',
+        b_iso=1.4041,
+    )
+
+    model_2 = StructureFactory.from_scratch(name='si')
+    model_2.space_group.name_h_m = 'F d -3 m'
+    model_2.space_group.it_coordinate_system_code = '2'
+    model_2.cell.length_a = 5.43146
+    model_2.atom_sites.create(
+        label='Si',
+        type_symbol='Si',
+        fract_x=0.0,
+        fract_y=0.0,
+        fract_z=0.0,
+        wyckoff_letter='a',
+        b_iso=0.0,
+    )
+
+    # Set experiment
+    data_path = download_data(id=8, destination=TEMP_DIR)
+    expt = ExperimentFactory.from_data_path(
+        name='mcstas',
+        data_path=data_path,
+        beam_mode='time-of-flight',
+    )
+    expt.instrument.setup_twotheta_bank = 94.90931761529106
+    expt.instrument.calib_d_to_tof_offset = 0.0
+    expt.instrument.calib_d_to_tof_linear = 58724.76869981215
+    expt.instrument.calib_d_to_tof_quad = -0.00001
+    expt.peak_profile_type = 'pseudo-voigt * ikeda-carpenter'
+    expt.peak.broad_gauss_sigma_0 = 45137
+    expt.peak.broad_gauss_sigma_1 = -52394
+    expt.peak.broad_gauss_sigma_2 = 22998
+    expt.peak.broad_mix_beta_0 = 0.0055
+    expt.peak.broad_mix_beta_1 = 0.0041
+    expt.peak.asym_alpha_0 = 0.0
+    expt.peak.asym_alpha_1 = 0.0097
+    expt.linked_phases.create(id='lbco', scale=4.0)
+    expt.linked_phases.create(id='si', scale=0.2)
+    for x in range(45000, 115000, 5000):
+        expt.background.create(id=str(x), x=x, y=0.2)
+
+    # Create project
+    project = Project()
+    project.structures.add(model_1)
+    project.structures.add(model_2)
+    project.experiments.add(expt)
+
+    # Exclude regions from fitting
+    project.experiments['mcstas'].excluded_regions.create(start=108000, end=200000)
+
+    # Prepare for fitting
+    project.analysis.current_minimizer = 'lmfit'
+
+    # Select fitting parameters
+    model_1.cell.length_a.free = True
+    model_1.atom_sites['La'].b_iso.free = True
+    model_1.atom_sites['Ba'].b_iso.free = True
+    model_1.atom_sites['Co'].b_iso.free = True
+    model_1.atom_sites['O'].b_iso.free = True
+    model_2.cell.length_a.free = True
+    model_2.atom_sites['Si'].b_iso.free = True
+    expt.linked_phases['lbco'].scale.free = True
+    expt.linked_phases['si'].scale.free = True
+    expt.peak.broad_gauss_sigma_0.free = True
+    expt.peak.broad_gauss_sigma_1.free = True
+    expt.peak.broad_gauss_sigma_2.free = True
+    expt.peak.asym_alpha_1.free = True
+    expt.peak.broad_mix_beta_0.free = True
+    expt.peak.broad_mix_beta_1.free = True
+    for point in expt.background:
+        point.y.free = True
+
+    # Perform fit
+    project.analysis.fit()
+
+    # Compare fit quality
+    assert_almost_equal(
+        project.analysis.fit_results.reduced_chi_square,
+        desired=2.87,
+        decimal=1,
+    )
+
+
+def _test_joint_fit_bragg_pdf_neutron_pd_tof_si() -> None:
+    # Set structure (shared between Bragg and PDF experiments)
+    model = StructureFactory.from_scratch(name='si')
+    model.space_group.name_h_m = 'F d -3 m'
+    model.space_group.it_coordinate_system_code = '2'
+    model.cell.length_a = 5.431
+    model.atom_sites.create(
+        label='Si',
+        type_symbol='Si',
+        fract_x=0.125,
+        fract_y=0.125,
+        fract_z=0.125,
+        b_iso=0.5,
+    )
+
+    # Set Bragg experiment (SEPD, TOF)
+    bragg_data_path = download_data(id=7, destination=TEMP_DIR)
+    bragg_expt = ExperimentFactory.from_data_path(
+        name='sepd',
+        data_path=bragg_data_path,
+        beam_mode='time-of-flight',
+    )
+    bragg_expt.instrument.setup_twotheta_bank = 144.845
+    bragg_expt.instrument.calib_d_to_tof_offset = 0.0
+    bragg_expt.instrument.calib_d_to_tof_linear = 7476.91
+    bragg_expt.instrument.calib_d_to_tof_quad = -1.54
+    bragg_expt.peak_profile_type = 'pseudo-voigt * ikeda-carpenter'
+    bragg_expt.peak.broad_gauss_sigma_0 = 3.0
+    bragg_expt.peak.broad_gauss_sigma_1 = 40.0
+    bragg_expt.peak.broad_gauss_sigma_2 = 2.0
+    bragg_expt.peak.broad_mix_beta_0 = 0.04221
+    bragg_expt.peak.broad_mix_beta_1 = 0.00946
+    bragg_expt.peak.asym_alpha_0 = 0.0
+    bragg_expt.peak.asym_alpha_1 = 0.5971
+    bragg_expt.linked_phases.create(id='si', scale=10.0)
+    for x in range(0, 35000, 5000):
+        bragg_expt.background.create(id=str(x), x=x, y=200)
+
+    # Set PDF experiment (NOMAD, TOF)
+    pdf_data_path = ed.download_data(id=5, destination=TEMP_DIR)
+    pdf_expt = ExperimentFactory.from_data_path(
+        name='nomad',
+        data_path=pdf_data_path,
+        beam_mode='time-of-flight',
+        scattering_type='total',
+    )
+    pdf_expt.peak.damp_q = 0.02
+    pdf_expt.peak.broad_q = 0.03
+    pdf_expt.peak.cutoff_q = 35.0
+    pdf_expt.peak.sharp_delta_1 = 0.0
+    pdf_expt.peak.sharp_delta_2 = 4.0
+    pdf_expt.peak.damp_particle_diameter = 0
+    pdf_expt.linked_phases.create(id='si', scale=1.0)
+
+    # Create project
+    project = Project()
+    project.structures.add(model)
+    project.experiments.add(bragg_expt)
+    project.experiments.add(pdf_expt)
+
+    # Prepare for fitting
+    project.analysis.fit_mode.mode = 'joint'
+    project.analysis.current_minimizer = 'lmfit'
+
+    # Select fitting parameters — shared structure
+    model.cell.length_a.free = True
+    model.atom_sites['Si'].b_iso.free = True
+
+    # Select fitting parameters — Bragg experiment
+    bragg_expt.linked_phases['si'].scale.free = True
+    bragg_expt.instrument.calib_d_to_tof_offset.free = True
+    for point in bragg_expt.background:
+        point.y.free = True
+
+    # Select fitting parameters — PDF experiment
+    pdf_expt.linked_phases['si'].scale.free = True
+    pdf_expt.peak.damp_q.free = True
+    pdf_expt.peak.broad_q.free = True
+    pdf_expt.peak.sharp_delta_1.free = True
+    pdf_expt.peak.sharp_delta_2.free = True
+
+    # Perform fit
+    project.analysis.fit()
+
+    # Compare fit quality
+    assert_almost_equal(
+        project.analysis.fit_results.reduced_chi_square,
+        desired=8978.39,
+        decimal=-2,
+    )
+
+
+if __name__ == '__main__':
+    test_single_fit_neutron_pd_tof_mcstas_lbco_si()
+    # test_joint_fit_bragg_pdf_neutron_pd_tof_si()
diff --git a/tests/integration/fitting/test_pair-distribution-function.py b/tests/integration/fitting/test_pair-distribution-function.py
index 6af36e52..823fd420 100644
--- a/tests/integration/fitting/test_pair-distribution-function.py
+++ b/tests/integration/fitting/test_pair-distribution-function.py
@@ -14,13 +14,13 @@
 def test_single_fit_pdf_xray_pd_cw_nacl() -> None:
     project = ed.Project()
 
-    # Set sample model
-    project.sample_models.add(name='nacl')
-    sample_model = project.sample_models['nacl']
-    sample_model.space_group.name_h_m = 'F m -3 m'
-    sample_model.space_group.it_coordinate_system_code = '1'
-    sample_model.cell.length_a = 5.6018
-    sample_model.atom_sites.add(
+    # Set structure
+    project.structures.create(name='nacl')
+    structure = project.structures['nacl']
+    structure.space_group.name_h_m = 'F m -3 m'
+    structure.space_group.it_coordinate_system_code = '1'
+    structure.cell.length_a = 5.6018
+    structure.atom_sites.create(
         label='Na',
         type_symbol='Na',
         fract_x=0,
@@ -29,7 +29,7 @@ def test_single_fit_pdf_xray_pd_cw_nacl() -> None:
         wyckoff_letter='a',
         b_iso=1.1053,
     )
-    sample_model.atom_sites.add(
+    structure.atom_sites.create(
         label='Cl',
         type_symbol='Cl',
         fract_x=0.5,
@@ -41,7 +41,7 @@ def test_single_fit_pdf_xray_pd_cw_nacl() -> None:
 
     # Set experiment
     data_path = ed.download_data(id=4, destination=TEMP_DIR)
-    project.experiments.add(
+    project.experiments.add_from_data_path(
         name='xray_pdf',
         data_path=data_path,
         sample_form='powder',
@@ -57,18 +57,17 @@ def test_single_fit_pdf_xray_pd_cw_nacl() -> None:
     experiment.peak.sharp_delta_1 = 0
     experiment.peak.sharp_delta_2 = 3.5041
     experiment.peak.damp_particle_diameter = 0
-    experiment.linked_phases.add(id='nacl', scale=0.4254)
+    experiment.linked_phases.create(id='nacl', scale=0.4254)
 
     # Select fitting parameters
-    sample_model.cell.length_a.free = True
-    sample_model.atom_sites['Na'].b_iso.free = True
-    sample_model.atom_sites['Cl'].b_iso.free = True
+    structure.cell.length_a.free = True
+    structure.atom_sites['Na'].b_iso.free = True
+    structure.atom_sites['Cl'].b_iso.free = True
     experiment.linked_phases['nacl'].scale.free = True
     experiment.peak.damp_q.free = True
     experiment.peak.sharp_delta_2.free = True
 
     # Perform fit
-    project.analysis.current_calculator = 'pdffit'
     project.analysis.fit()
 
     # Compare fit quality
@@ -80,13 +79,13 @@ def test_single_fit_pdf_xray_pd_cw_nacl() -> None:
 def test_single_fit_pdf_neutron_pd_cw_ni():
     project = ed.Project()
 
-    # Set sample model
-    project.sample_models.add(name='ni')
-    sample_model = project.sample_models['ni']
-    sample_model.space_group.name_h_m.value = 'F m -3 m'
-    sample_model.space_group.it_coordinate_system_code = '1'
-    sample_model.cell.length_a = 3.526
-    sample_model.atom_sites.add(
+    # Set structure
+    project.structures.create(name='ni')
+    structure = project.structures['ni']
+    structure.space_group.name_h_m.value = 'F m -3 m'
+    structure.space_group.it_coordinate_system_code = '1'
+    structure.cell.length_a = 3.526
+    structure.atom_sites.create(
         label='Ni',
         type_symbol='Ni',
         fract_x=0,
@@ -98,7 +97,7 @@ def test_single_fit_pdf_neutron_pd_cw_ni():
 
     # Set experiment
     data_path = ed.download_data(id=6, destination=TEMP_DIR)
-    project.experiments.add(
+    project.experiments.add_from_data_path(
         name='pdf',
         data_path=data_path,
         sample_form='powder',
@@ -113,17 +112,16 @@ def test_single_fit_pdf_neutron_pd_cw_ni():
     experiment.peak.sharp_delta_1 = 0
     experiment.peak.sharp_delta_2 = 2.5587
     experiment.peak.damp_particle_diameter = 0
-    experiment.linked_phases.add(id='ni', scale=0.9892)
+    experiment.linked_phases.create(id='ni', scale=0.9892)
 
     # Select fitting parameters
-    sample_model.cell.length_a.free = True
-    sample_model.atom_sites['Ni'].b_iso.free = True
+    structure.cell.length_a.free = True
+    structure.atom_sites['Ni'].b_iso.free = True
     experiment.linked_phases['ni'].scale.free = True
     experiment.peak.broad_q.free = True
     experiment.peak.sharp_delta_2.free = True
 
     # Perform fit
-    project.analysis.current_calculator = 'pdffit'
     project.analysis.fit()
 
     # Compare fit quality
@@ -134,13 +132,13 @@ def test_single_fit_pdf_neutron_pd_cw_ni():
 def test_single_fit_pdf_neutron_pd_tof_si():
     project = ed.Project()
 
-    # Set sample model
-    project.sample_models.add(name='si')
-    sample_model = project.sample_models['si']
-    sample_model.space_group.name_h_m.value = 'F d -3 m'
-    sample_model.space_group.it_coordinate_system_code = '1'
-    sample_model.cell.length_a = 5.4306
-    sample_model.atom_sites.add(
+    # Set structure
+    project.structures.create(name='si')
+    structure = project.structures['si']
+    structure.space_group.name_h_m.value = 'F d -3 m'
+    structure.space_group.it_coordinate_system_code = '1'
+    structure.cell.length_a = 5.4306
+    structure.atom_sites.create(
         label='Si',
         type_symbol='Si',
         fract_x=0,
@@ -152,7 +150,7 @@ def test_single_fit_pdf_neutron_pd_tof_si():
 
     # Set experiment
     data_path = ed.download_data(id=5, destination=TEMP_DIR)
-    project.experiments.add(
+    project.experiments.add_from_data_path(
         name='nomad',
         data_path=data_path,
         sample_form='powder',
@@ -167,11 +165,11 @@ def test_single_fit_pdf_neutron_pd_tof_si():
     experiment.peak.sharp_delta_1 = 2.54
     experiment.peak.sharp_delta_2 = -1.7525
     experiment.peak.damp_particle_diameter = 0
-    experiment.linked_phases.add(id='si', scale=1.2728)
+    experiment.linked_phases.create(id='si', scale=1.2728)
 
     # Select fitting parameters
-    project.sample_models['si'].cell.length_a.free = True
-    project.sample_models['si'].atom_sites['Si'].b_iso.free = True
+    project.structures['si'].cell.length_a.free = True
+    project.structures['si'].atom_sites['Si'].b_iso.free = True
     experiment.linked_phases['si'].scale.free = True
     experiment.peak.damp_q.free = True
     experiment.peak.broad_q.free = True
@@ -179,7 +177,6 @@ def test_single_fit_pdf_neutron_pd_tof_si():
     experiment.peak.sharp_delta_2.free = True
 
     # Perform fit
-    project.analysis.current_calculator = 'pdffit'
     project.analysis.fit()
 
     # Compare fit quality
diff --git a/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py b/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py
index f8d7fd6d..49e7b4b1 100644
--- a/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py
+++ b/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py
@@ -8,18 +8,18 @@
 
 from easydiffraction import ExperimentFactory
 from easydiffraction import Project
-from easydiffraction import SampleModelFactory
+from easydiffraction import StructureFactory
 from easydiffraction import download_data
 
 TEMP_DIR = tempfile.gettempdir()
 
 
 def test_single_fit_neutron_pd_cwl_lbco() -> None:
-    # Set sample model
-    model = SampleModelFactory.create(name='lbco')
+    # Set structure
+    model = StructureFactory.from_scratch(name='lbco')
     model.space_group.name_h_m = 'P m -3 m'
     model.cell.length_a = 3.88
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='La',
         type_symbol='La',
         fract_x=0,
@@ -29,7 +29,7 @@ def test_single_fit_neutron_pd_cwl_lbco() -> None:
         occupancy=0.5,
         b_iso=0.1,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Ba',
         type_symbol='Ba',
         fract_x=0,
@@ -39,7 +39,7 @@ def test_single_fit_neutron_pd_cwl_lbco() -> None:
         occupancy=0.5,
         b_iso=0.1,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Co',
         type_symbol='Co',
         fract_x=0.5,
@@ -48,7 +48,7 @@ def test_single_fit_neutron_pd_cwl_lbco() -> None:
         wyckoff_letter='b',
         b_iso=0.1,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='O',
         type_symbol='O',
         fract_x=0,
@@ -61,7 +61,7 @@ def test_single_fit_neutron_pd_cwl_lbco() -> None:
     # Set experiment
     data_path = download_data(id=3, destination=TEMP_DIR)
 
-    expt = ExperimentFactory.create(
+    expt = ExperimentFactory.from_data_path(
         name='hrpt',
         data_path=data_path,
     )
@@ -75,19 +75,18 @@ def test_single_fit_neutron_pd_cwl_lbco() -> None:
     expt.peak.broad_lorentz_x = 0
     expt.peak.broad_lorentz_y = 0
 
-    expt.linked_phases.add(id='lbco', scale=5.0)
+    expt.linked_phases.create(id='lbco', scale=5.0)
 
-    expt.background.add(id='1', x=10, y=170)
-    expt.background.add(id='2', x=165, y=170)
+    expt.background.create(id='1', x=10, y=170)
+    expt.background.create(id='2', x=165, y=170)
 
     # Create project
     project = Project()
-    project.sample_models.add(sample_model=model)
-    project.experiments.add(experiment=expt)
+    project.structures.add(model)
+    project.experiments.add(expt)
 
     # Prepare for fitting
-    project.analysis.current_calculator = 'cryspy'
-    project.analysis.current_minimizer = 'lmfit (leastsq)'
+    project.analysis.current_minimizer = 'lmfit'
 
     # ------------ 1st fitting ------------
 
@@ -147,8 +146,8 @@ def test_single_fit_neutron_pd_cwl_lbco() -> None:
 
 @pytest.mark.fast
 def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
-    # Set sample model
-    model = SampleModelFactory.create(name='lbco')
+    # Set structure
+    model = StructureFactory.from_scratch(name='lbco')
 
     space_group = model.space_group
     space_group.name_h_m = 'P m -3 m'
@@ -157,7 +156,7 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
     cell.length_a = 3.8909
 
     atom_sites = model.atom_sites
-    atom_sites.add(
+    atom_sites.create(
         label='La',
         type_symbol='La',
         fract_x=0,
@@ -167,7 +166,7 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
         b_iso=1.0,
         occupancy=0.5,
     )
-    atom_sites.add(
+    atom_sites.create(
         label='Ba',
         type_symbol='Ba',
         fract_x=0,
@@ -177,7 +176,7 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
         b_iso=1.0,
         occupancy=0.5,
     )
-    atom_sites.add(
+    atom_sites.create(
         label='Co',
         type_symbol='Co',
         fract_x=0.5,
@@ -186,7 +185,7 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
         wyckoff_letter='b',
         b_iso=1.0,
     )
-    atom_sites.add(
+    atom_sites.create(
         label='O',
         type_symbol='O',
         fract_x=0,
@@ -199,7 +198,7 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
     # Set experiment
     data_path = download_data(id=3, destination=TEMP_DIR)
 
-    expt = ExperimentFactory.create(
+    expt = ExperimentFactory.from_data_path(
         name='hrpt',
         data_path=data_path,
     )
@@ -216,27 +215,26 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
     peak.broad_lorentz_y = 0.0797
 
     background = expt.background
-    background.add(id='10', x=10, y=174.3)
-    background.add(id='20', x=20, y=159.8)
-    background.add(id='30', x=30, y=167.9)
-    background.add(id='50', x=50, y=166.1)
-    background.add(id='70', x=70, y=172.3)
-    background.add(id='90', x=90, y=171.1)
-    background.add(id='110', x=110, y=172.4)
-    background.add(id='130', x=130, y=182.5)
-    background.add(id='150', x=150, y=173.0)
-    background.add(id='165', x=165, y=171.1)
-
-    expt.linked_phases.add(id='lbco', scale=9.0976)
+    background.create(id='10', x=10, y=174.3)
+    background.create(id='20', x=20, y=159.8)
+    background.create(id='30', x=30, y=167.9)
+    background.create(id='50', x=50, y=166.1)
+    background.create(id='70', x=70, y=172.3)
+    background.create(id='90', x=90, y=171.1)
+    background.create(id='110', x=110, y=172.4)
+    background.create(id='130', x=130, y=182.5)
+    background.create(id='150', x=150, y=173.0)
+    background.create(id='165', x=165, y=171.1)
+
+    expt.linked_phases.create(id='lbco', scale=9.0976)
 
     # Create project
     project = Project()
-    project.sample_models.add(sample_model=model)
-    project.experiments.add(experiment=expt)
+    project.structures.add(model)
+    project.experiments.add(expt)
 
     # Prepare for fitting
-    project.analysis.current_calculator = 'cryspy'
-    project.analysis.current_minimizer = 'lmfit (leastsq)'
+    project.analysis.current_minimizer = 'lmfit'
 
     # ------------ 1st fitting ------------
 
@@ -277,14 +275,26 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
     # ------------ 2nd fitting ------------
 
     # Set aliases for parameters
-    project.analysis.aliases.add(label='biso_La', param_uid=atom_sites['La'].b_iso.uid)
-    project.analysis.aliases.add(label='biso_Ba', param_uid=atom_sites['Ba'].b_iso.uid)
-    project.analysis.aliases.add(label='occ_La', param_uid=atom_sites['La'].occupancy.uid)
-    project.analysis.aliases.add(label='occ_Ba', param_uid=atom_sites['Ba'].occupancy.uid)
+    project.analysis.aliases.create(
+        label='biso_La',
+        param_uid=atom_sites['La'].b_iso.uid,
+    )
+    project.analysis.aliases.create(
+        label='biso_Ba',
+        param_uid=atom_sites['Ba'].b_iso.uid,
+    )
+    project.analysis.aliases.create(
+        label='occ_La',
+        param_uid=atom_sites['La'].occupancy.uid,
+    )
+    project.analysis.aliases.create(
+        label='occ_Ba',
+        param_uid=atom_sites['Ba'].occupancy.uid,
+    )
 
     # Set constraints
-    project.analysis.constraints.add(lhs_alias='biso_Ba', rhs_expr='biso_La')
-    project.analysis.constraints.add(lhs_alias='occ_Ba', rhs_expr='1 - occ_La')
+    project.analysis.constraints.create(lhs_alias='biso_Ba', rhs_expr='biso_La')
+    project.analysis.constraints.create(lhs_alias='occ_Ba', rhs_expr='1 - occ_La')
 
     # Apply constraints
     project.analysis.apply_constraints()
@@ -309,13 +319,13 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
 
 
 def test_fit_neutron_pd_cwl_hs() -> None:
-    # Set sample model
-    model = SampleModelFactory.create(name='hs')
+    # Set structure
+    model = StructureFactory.from_scratch(name='hs')
     model.space_group.name_h_m = 'R -3 m'
     model.space_group.it_coordinate_system_code = 'h'
     model.cell.length_a = 6.8615
     model.cell.length_c = 14.136
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Zn',
         type_symbol='Zn',
         fract_x=0,
@@ -324,7 +334,7 @@ def test_fit_neutron_pd_cwl_hs() -> None:
         wyckoff_letter='b',
         b_iso=0.1,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Cu',
         type_symbol='Cu',
         fract_x=0.5,
@@ -333,7 +343,7 @@ def test_fit_neutron_pd_cwl_hs() -> None:
         wyckoff_letter='e',
         b_iso=1.2,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='O',
         type_symbol='O',
         fract_x=0.206,
@@ -342,7 +352,7 @@ def test_fit_neutron_pd_cwl_hs() -> None:
         wyckoff_letter='h',
         b_iso=0.7,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Cl',
         type_symbol='Cl',
         fract_x=0,
@@ -351,7 +361,7 @@ def test_fit_neutron_pd_cwl_hs() -> None:
         wyckoff_letter='c',
         b_iso=1.1,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='H',
         type_symbol='2H',
         fract_x=0.132,
@@ -364,7 +374,7 @@ def test_fit_neutron_pd_cwl_hs() -> None:
     # Set experiment
     data_path = download_data(id=11, destination=TEMP_DIR)
 
-    expt = ExperimentFactory.create(name='hrpt', data_path=data_path)
+    expt = ExperimentFactory.from_data_path(name='hrpt', data_path=data_path)
 
     expt.instrument.setup_wavelength = 1.89
     expt.instrument.calib_twotheta_offset = 0.0
@@ -375,26 +385,25 @@ def test_fit_neutron_pd_cwl_hs() -> None:
     expt.peak.broad_lorentz_x = 0.2927
     expt.peak.broad_lorentz_y = 0
 
-    expt.background.add(id='1', x=4.4196, y=648.413)
-    expt.background.add(id='2', x=6.6207, y=523.788)
-    expt.background.add(id='3', x=10.4918, y=454.938)
-    expt.background.add(id='4', x=15.4634, y=435.913)
-    expt.background.add(id='5', x=45.6041, y=472.972)
-    expt.background.add(id='6', x=74.6844, y=486.606)
-    expt.background.add(id='7', x=103.4187, y=472.409)
-    expt.background.add(id='8', x=121.6311, y=496.734)
-    expt.background.add(id='9', x=159.4116, y=473.146)
+    expt.background.create(id='1', x=4.4196, y=648.413)
+    expt.background.create(id='2', x=6.6207, y=523.788)
+    expt.background.create(id='3', x=10.4918, y=454.938)
+    expt.background.create(id='4', x=15.4634, y=435.913)
+    expt.background.create(id='5', x=45.6041, y=472.972)
+    expt.background.create(id='6', x=74.6844, y=486.606)
+    expt.background.create(id='7', x=103.4187, y=472.409)
+    expt.background.create(id='8', x=121.6311, y=496.734)
+    expt.background.create(id='9', x=159.4116, y=473.146)
 
-    expt.linked_phases.add(id='hs', scale=0.492)
+    expt.linked_phases.create(id='hs', scale=0.492)
 
     # Create project
     project = Project()
-    project.sample_models.add(sample_model=model)
-    project.experiments.add(experiment=expt)
+    project.structures.add(model)
+    project.experiments.add(expt)
 
     # Prepare for fitting
-    project.analysis.current_calculator = 'cryspy'
-    project.analysis.current_minimizer = 'lmfit (leastsq)'
+    project.analysis.current_minimizer = 'lmfit'
 
     # ------------ 1st fitting ------------
 
diff --git a/tests/integration/fitting/test_powder-diffraction_joint-fit.py b/tests/integration/fitting/test_powder-diffraction_joint-fit.py
index 904b2e1c..cc3e600e 100644
--- a/tests/integration/fitting/test_powder-diffraction_joint-fit.py
+++ b/tests/integration/fitting/test_powder-diffraction_joint-fit.py
@@ -8,7 +8,7 @@
 
 from easydiffraction import ExperimentFactory
 from easydiffraction import Project
-from easydiffraction import SampleModelFactory
+from easydiffraction import StructureFactory
 from easydiffraction import download_data
 
 TEMP_DIR = tempfile.gettempdir()
@@ -16,13 +16,13 @@
 
 @pytest.mark.fast
 def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
-    # Set sample model
-    model = SampleModelFactory.create(name='pbso4')
+    # Set structure
+    model = StructureFactory.from_scratch(name='pbso4')
     model.space_group.name_h_m = 'P n m a'
     model.cell.length_a = 8.47
     model.cell.length_b = 5.39
     model.cell.length_c = 6.95
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Pb',
         type_symbol='Pb',
         fract_x=0.1876,
@@ -31,7 +31,7 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
         wyckoff_letter='c',
         b_iso=1.37,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='S',
         type_symbol='S',
         fract_x=0.0654,
@@ -40,7 +40,7 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
         wyckoff_letter='c',
         b_iso=0.3777,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='O1',
         type_symbol='O',
         fract_x=0.9082,
@@ -49,7 +49,7 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
         wyckoff_letter='c',
         b_iso=1.9764,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='O2',
         type_symbol='O',
         fract_x=0.1935,
@@ -58,7 +58,7 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
         wyckoff_letter='c',
         b_iso=1.4456,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='O3',
         type_symbol='O',
         fract_x=0.0811,
@@ -70,7 +70,7 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
 
     # Set experiments
     data_path = download_data(id=14, destination=TEMP_DIR)
-    expt1 = ExperimentFactory.create(name='npd1', data_path=data_path)
+    expt1 = ExperimentFactory.from_data_path(name='npd1', data_path=data_path)
     expt1.instrument.setup_wavelength = 1.91
     expt1.instrument.calib_twotheta_offset = -0.1406
     expt1.peak.broad_gauss_u = 0.139
@@ -78,7 +78,7 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
     expt1.peak.broad_gauss_w = 0.386
     expt1.peak.broad_lorentz_x = 0
     expt1.peak.broad_lorentz_y = 0.0878
-    expt1.linked_phases.add(id='pbso4', scale=1.46)
+    expt1.linked_phases.create(id='pbso4', scale=1.46)
     expt1.background_type = 'line-segment'
     for id, x, y in [
         ('1', 11.0, 206.1624),
@@ -90,10 +90,10 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
         ('7', 120.0, 244.4525),
         ('8', 153.0, 226.0595),
     ]:
-        expt1.background.add(id=id, x=x, y=y)
+        expt1.background.create(id=id, x=x, y=y)
 
     data_path = download_data(id=15, destination=TEMP_DIR)
-    expt2 = ExperimentFactory.create(name='npd2', data_path=data_path)
+    expt2 = ExperimentFactory.from_data_path(name='npd2', data_path=data_path)
     expt2.instrument.setup_wavelength = 1.91
     expt2.instrument.calib_twotheta_offset = -0.1406
     expt2.peak.broad_gauss_u = 0.139
@@ -101,7 +101,7 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
     expt2.peak.broad_gauss_w = 0.386
     expt2.peak.broad_lorentz_x = 0
     expt2.peak.broad_lorentz_y = 0.0878
-    expt2.linked_phases.add(id='pbso4', scale=1.46)
+    expt2.linked_phases.create(id='pbso4', scale=1.46)
     expt2.background_type = 'line-segment'
     for id, x, y in [
         ('1', 11.0, 206.1624),
@@ -113,18 +113,17 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
         ('7', 120.0, 244.4525),
         ('8', 153.0, 226.0595),
     ]:
-        expt2.background.add(id=id, x=x, y=y)
+        expt2.background.create(id=id, x=x, y=y)
 
     # Create project
     project = Project()
-    project.sample_models.add(sample_model=model)
-    project.experiments.add(experiment=expt1)
-    project.experiments.add(experiment=expt2)
+    project.structures.add(model)
+    project.experiments.add(expt1)
+    project.experiments.add(expt2)
 
     # Prepare for fitting
-    project.analysis.current_calculator = 'cryspy'
-    project.analysis.current_minimizer = 'lmfit (leastsq)'
-    project.analysis.fit_mode = 'joint'
+    project.analysis.current_minimizer = 'lmfit'
+    project.analysis.fit_mode.mode = 'joint'
 
     # Select fitting parameters
     model.cell.length_a.free = True
@@ -144,13 +143,13 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None:
 
 @pytest.mark.fast
 def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
-    # Set sample model
-    model = SampleModelFactory.create(name='pbso4')
+    # Set structure
+    model = StructureFactory.from_scratch(name='pbso4')
     model.space_group.name_h_m = 'P n m a'
     model.cell.length_a = 8.47
     model.cell.length_b = 5.39
     model.cell.length_c = 6.95
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Pb',
         type_symbol='Pb',
         fract_x=0.1876,
@@ -159,7 +158,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
         wyckoff_letter='c',
         b_iso=1.37,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='S',
         type_symbol='S',
         fract_x=0.0654,
@@ -168,7 +167,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
         wyckoff_letter='c',
         b_iso=0.3777,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='O1',
         type_symbol='O',
         fract_x=0.9082,
@@ -177,7 +176,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
         wyckoff_letter='c',
         b_iso=1.9764,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='O2',
         type_symbol='O',
         fract_x=0.1935,
@@ -186,7 +185,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
         wyckoff_letter='c',
         b_iso=1.4456,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='O3',
         type_symbol='O',
         fract_x=0.0811,
@@ -198,7 +197,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
 
     # Set experiments
     data_path = download_data(id=13, destination=TEMP_DIR)
-    expt1 = ExperimentFactory.create(
+    expt1 = ExperimentFactory.from_data_path(
         name='npd',
         data_path=data_path,
         radiation_probe='neutron',
@@ -210,7 +209,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
     expt1.peak.broad_gauss_w = 0.386
     expt1.peak.broad_lorentz_x = 0
     expt1.peak.broad_lorentz_y = 0.088
-    expt1.linked_phases.add(id='pbso4', scale=1.5)
+    expt1.linked_phases.create(id='pbso4', scale=1.5)
     for id, x, y in [
         ('1', 11.0, 206.1624),
         ('2', 15.0, 194.75),
@@ -221,10 +220,10 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
         ('7', 120.0, 244.4525),
         ('8', 153.0, 226.0595),
     ]:
-        expt1.background.add(id=id, x=x, y=y)
+        expt1.background.create(id=id, x=x, y=y)
 
     data_path = download_data(id=16, destination=TEMP_DIR)
-    expt2 = ExperimentFactory.create(
+    expt2 = ExperimentFactory.from_data_path(
         name='xrd',
         data_path=data_path,
         radiation_probe='xray',
@@ -236,7 +235,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
     expt2.peak.broad_gauss_w = 0.021272
     expt2.peak.broad_lorentz_x = 0
     expt2.peak.broad_lorentz_y = 0.057691
-    expt2.linked_phases.add(id='pbso4', scale=0.001)
+    expt2.linked_phases.create(id='pbso4', scale=0.001)
     for id, x, y in [
         ('1', 11.0, 141.8516),
         ('2', 13.0, 102.8838),
@@ -247,17 +246,16 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
         ('7', 90.0, 113.7473),
         ('8', 110.0, 132.4643),
     ]:
-        expt2.background.add(id=id, x=x, y=y)
+        expt2.background.create(id=id, x=x, y=y)
 
     # Create project
     project = Project()
-    project.sample_models.add(sample_model=model)
-    project.experiments.add(experiment=expt1)
-    project.experiments.add(experiment=expt2)
+    project.structures.add(model)
+    project.experiments.add(expt1)
+    project.experiments.add(expt2)
 
     # Prepare for fitting
-    project.analysis.current_calculator = 'cryspy'
-    project.analysis.current_minimizer = 'lmfit (leastsq)'
+    project.analysis.current_minimizer = 'lmfit'
 
     # Select fitting parameters
     model.cell.length_a.free = True
@@ -269,7 +267,6 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
     # ------------ 1st fitting ------------
 
     # Perform fit
-    project.analysis.fit_mode = 'single'  # Default
     project.analysis.fit()
 
     # Compare fit quality
@@ -282,7 +279,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
     # ------------ 2nd fitting ------------
 
     # Perform fit
-    project.analysis.fit_mode = 'joint'
+    project.analysis.fit_mode.mode = 'joint'
     project.analysis.fit()
 
     # Compare fit quality
@@ -297,7 +294,6 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
     # Perform fit
     project.analysis.joint_fit_experiments['xrd'].weight = 0.5  # Default
     project.analysis.joint_fit_experiments['npd'].weight = 0.5  # Default
-    project.analysis.fit_mode = 'joint'
     project.analysis.fit()
 
     # Compare fit quality
@@ -312,7 +308,6 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None:
     # Perform fit
     project.analysis.joint_fit_experiments['xrd'].weight = 0.3
     project.analysis.joint_fit_experiments['npd'].weight = 0.7
-    project.analysis.fit_mode = 'joint'
     project.analysis.fit()
 
     # Compare fit quality
diff --git a/tests/integration/fitting/test_powder-diffraction_multiphase.py b/tests/integration/fitting/test_powder-diffraction_multiphase.py
deleted file mode 100644
index 2880eb2b..00000000
--- a/tests/integration/fitting/test_powder-diffraction_multiphase.py
+++ /dev/null
@@ -1,143 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-import tempfile
-
-from numpy.testing import assert_almost_equal
-
-from easydiffraction import ExperimentFactory
-from easydiffraction import Project
-from easydiffraction import SampleModelFactory
-from easydiffraction import download_data
-
-TEMP_DIR = tempfile.gettempdir()
-
-
-def test_single_fit_neutron_pd_tof_mcstas_lbco_si() -> None:
-    # Set sample models
-    model_1 = SampleModelFactory.create(name='lbco')
-    model_1.space_group.name_h_m = 'P m -3 m'
-    model_1.space_group.it_coordinate_system_code = '1'
-    model_1.cell.length_a = 3.8909
-    model_1.atom_sites.add(
-        label='La',
-        type_symbol='La',
-        fract_x=0,
-        fract_y=0,
-        fract_z=0,
-        wyckoff_letter='a',
-        b_iso=0.2,
-        occupancy=0.5,
-    )
-    model_1.atom_sites.add(
-        label='Ba',
-        type_symbol='Ba',
-        fract_x=0,
-        fract_y=0,
-        fract_z=0,
-        wyckoff_letter='a',
-        b_iso=0.2,
-        occupancy=0.5,
-    )
-    model_1.atom_sites.add(
-        label='Co',
-        type_symbol='Co',
-        fract_x=0.5,
-        fract_y=0.5,
-        fract_z=0.5,
-        wyckoff_letter='b',
-        b_iso=0.2567,
-    )
-    model_1.atom_sites.add(
-        label='O',
-        type_symbol='O',
-        fract_x=0,
-        fract_y=0.5,
-        fract_z=0.5,
-        wyckoff_letter='c',
-        b_iso=1.4041,
-    )
-
-    model_2 = SampleModelFactory.create(name='si')
-    model_2.space_group.name_h_m = 'F d -3 m'
-    model_2.space_group.it_coordinate_system_code = '2'
-    model_2.cell.length_a = 5.43146
-    model_2.atom_sites.add(
-        label='Si',
-        type_symbol='Si',
-        fract_x=0.0,
-        fract_y=0.0,
-        fract_z=0.0,
-        wyckoff_letter='a',
-        b_iso=0.0,
-    )
-
-    # Set experiment
-    data_path = download_data(id=8, destination=TEMP_DIR)
-    expt = ExperimentFactory.create(
-        name='mcstas',
-        data_path=data_path,
-        beam_mode='time-of-flight',
-    )
-    expt.instrument.setup_twotheta_bank = 94.90931761529106
-    expt.instrument.calib_d_to_tof_offset = 0.0
-    expt.instrument.calib_d_to_tof_linear = 58724.76869981215
-    expt.instrument.calib_d_to_tof_quad = -0.00001
-    expt.peak_profile_type = 'pseudo-voigt * ikeda-carpenter'
-    expt.peak.broad_gauss_sigma_0 = 45137
-    expt.peak.broad_gauss_sigma_1 = -52394
-    expt.peak.broad_gauss_sigma_2 = 22998
-    expt.peak.broad_mix_beta_0 = 0.0055
-    expt.peak.broad_mix_beta_1 = 0.0041
-    expt.peak.asym_alpha_0 = 0.0
-    expt.peak.asym_alpha_1 = 0.0097
-    expt.linked_phases.add(id='lbco', scale=4.0)
-    expt.linked_phases.add(id='si', scale=0.2)
-    for x in range(45000, 115000, 5000):
-        expt.background.add(id=str(x), x=x, y=0.2)
-
-    # Create project
-    project = Project()
-    project.sample_models.add(sample_model=model_1)
-    project.sample_models.add(sample_model=model_2)
-    project.experiments.add(experiment=expt)
-
-    # Exclude regions from fitting
-    project.experiments['mcstas'].excluded_regions.add(start=108000, end=200000)
-
-    # Prepare for fitting
-    project.analysis.current_calculator = 'cryspy'
-    project.analysis.current_minimizer = 'lmfit (leastsq)'
-
-    # Select fitting parameters
-    model_1.cell.length_a.free = True
-    model_1.atom_sites['La'].b_iso.free = True
-    model_1.atom_sites['Ba'].b_iso.free = True
-    model_1.atom_sites['Co'].b_iso.free = True
-    model_1.atom_sites['O'].b_iso.free = True
-    model_2.cell.length_a.free = True
-    model_2.atom_sites['Si'].b_iso.free = True
-    expt.linked_phases['lbco'].scale.free = True
-    expt.linked_phases['si'].scale.free = True
-    expt.peak.broad_gauss_sigma_0.free = True
-    expt.peak.broad_gauss_sigma_1.free = True
-    expt.peak.broad_gauss_sigma_2.free = True
-    expt.peak.asym_alpha_1.free = True
-    expt.peak.broad_mix_beta_0.free = True
-    expt.peak.broad_mix_beta_1.free = True
-    for point in expt.background:
-        point.y.free = True
-
-    # Perform fit
-    project.analysis.fit()
-
-    # Compare fit quality
-    assert_almost_equal(
-        project.analysis.fit_results.reduced_chi_square,
-        desired=2.87,
-        decimal=1,
-    )
-
-
-if __name__ == '__main__':
-    test_single_fit_neutron_pd_tof_mcstas_lbco_si()
diff --git a/tests/integration/fitting/test_powder-diffraction_time-of-flight.py b/tests/integration/fitting/test_powder-diffraction_time-of-flight.py
index 0117a90b..ae702b4d 100644
--- a/tests/integration/fitting/test_powder-diffraction_time-of-flight.py
+++ b/tests/integration/fitting/test_powder-diffraction_time-of-flight.py
@@ -7,19 +7,19 @@
 
 from easydiffraction import ExperimentFactory
 from easydiffraction import Project
-from easydiffraction import SampleModelFactory
+from easydiffraction import StructureFactory
 from easydiffraction import download_data
 
 TEMP_DIR = tempfile.gettempdir()
 
 
 def test_single_fit_neutron_pd_tof_si() -> None:
-    # Set sample model
-    model = SampleModelFactory.create(name='si')
+    # Set structure
+    model = StructureFactory.from_scratch(name='si')
     model.space_group.name_h_m = 'F d -3 m'
     model.space_group.it_coordinate_system_code = '2'
     model.cell.length_a = 5.4315
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Si',
         type_symbol='Si',
         fract_x=0.125,
@@ -31,7 +31,7 @@ def test_single_fit_neutron_pd_tof_si() -> None:
 
     # Set experiment
     data_path = download_data(id=7, destination=TEMP_DIR)
-    expt = ExperimentFactory.create(
+    expt = ExperimentFactory.from_data_path(
         name='sepd',
         data_path=data_path,
         beam_mode='time-of-flight',
@@ -48,18 +48,17 @@ def test_single_fit_neutron_pd_tof_si() -> None:
     expt.peak.broad_mix_beta_1 = 0.00946
     expt.peak.asym_alpha_0 = 0.0
     expt.peak.asym_alpha_1 = 0.5971
-    expt.linked_phases.add(id='si', scale=14.92)
+    expt.linked_phases.create(id='si', scale=14.92)
     for x in range(0, 35000, 5000):
-        expt.background.add(id=str(x), x=x, y=200)
+        expt.background.create(id=str(x), x=x, y=200)
 
     # Create project
     project = Project()
-    project.sample_models.add(sample_model=model)
-    project.experiments.add(experiment=expt)
+    project.structures.add(model)
+    project.experiments.add(expt)
 
     # Prepare for fitting
-    project.analysis.current_calculator = 'cryspy'
-    project.analysis.current_minimizer = 'lmfit (leastsq)'
+    project.analysis.current_minimizer = 'lmfit'
 
     # Select fitting parameters
     model.cell.length_a.free = True
@@ -81,12 +80,12 @@ def test_single_fit_neutron_pd_tof_si() -> None:
 
 
 def test_single_fit_neutron_pd_tof_ncaf() -> None:
-    # Set sample model
-    model = SampleModelFactory.create(name='ncaf')
+    # Set structure
+    model = StructureFactory.from_scratch(name='ncaf')
     model.space_group.name_h_m = 'I 21 3'
     model.space_group.it_coordinate_system_code = '1'
     model.cell.length_a = 10.250256
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Ca',
         type_symbol='Ca',
         fract_x=0.4661,
@@ -95,7 +94,7 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None:
         wyckoff_letter='b',
         b_iso=0.9,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Al',
         type_symbol='Al',
         fract_x=0.25171,
@@ -104,7 +103,7 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None:
         wyckoff_letter='a',
         b_iso=0.66,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='Na',
         type_symbol='Na',
         fract_x=0.08481,
@@ -113,7 +112,7 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None:
         wyckoff_letter='a',
         b_iso=1.9,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='F1',
         type_symbol='F',
         fract_x=0.1375,
@@ -122,7 +121,7 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None:
         wyckoff_letter='c',
         b_iso=0.9,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='F2',
         type_symbol='F',
         fract_x=0.3626,
@@ -131,7 +130,7 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None:
         wyckoff_letter='c',
         b_iso=1.28,
     )
-    model.atom_sites.add(
+    model.atom_sites.create(
         label='F3',
         type_symbol='F',
         fract_x=0.4612,
@@ -143,13 +142,13 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None:
 
     # Set experiment
     data_path = download_data(id=9, destination=TEMP_DIR)
-    expt = ExperimentFactory.create(
+    expt = ExperimentFactory.from_data_path(
         name='wish',
         data_path=data_path,
         beam_mode='time-of-flight',
     )
-    expt.excluded_regions.add(id='1', start=0, end=9000)
-    expt.excluded_regions.add(id='2', start=100010, end=200000)
+    expt.excluded_regions.create(id='1', start=0, end=9000)
+    expt.excluded_regions.create(id='2', start=100010, end=200000)
     expt.instrument.setup_twotheta_bank = 152.827
     expt.instrument.calib_d_to_tof_offset = -13.7123
     expt.instrument.calib_d_to_tof_linear = 20773.1
@@ -162,7 +161,7 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None:
     expt.peak.broad_mix_beta_1 = 0.0099
     expt.peak.asym_alpha_0 = -0.009
     expt.peak.asym_alpha_1 = 0.1085
-    expt.linked_phases.add(id='ncaf', scale=1.0928)
+    expt.linked_phases.create(id='ncaf', scale=1.0928)
     for x, y in [
         (9162, 465),
         (11136, 593),
@@ -193,16 +192,15 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None:
         (91958, 268),
         (102712, 262),
     ]:
-        expt.background.add(id=str(x), x=x, y=y)
+        expt.background.create(id=str(x), x=x, y=y)
 
     # Create project
     project = Project()
-    project.sample_models.add(sample_model=model)
-    project.experiments.add(experiment=expt)
+    project.structures.add(model)
+    project.experiments.add(expt)
 
     # Prepare for fitting
-    project.analysis.current_calculator = 'cryspy'
-    project.analysis.current_minimizer = 'lmfit (leastsq)'
+    project.analysis.current_minimizer = 'lmfit'
 
     # Select fitting parameters
     expt.linked_phases['ncaf'].scale.free = True
diff --git a/tests/integration/fitting/test_single-crystal-diffraction.py b/tests/integration/fitting/test_single-crystal-diffraction.py
index da7da7eb..af915d92 100644
--- a/tests/integration/fitting/test_single-crystal-diffraction.py
+++ b/tests/integration/fitting/test_single-crystal-diffraction.py
@@ -14,13 +14,13 @@
 def test_single_fit_neut_sc_cwl_tbti() -> None:
     project = ed.Project()
 
-    # Set sample model
+    # Set structure
     model_path = ed.download_data(id=20, destination=TEMP_DIR)
-    project.sample_models.add(cif_path=model_path)
+    project.structures.add_from_cif_path(model_path)
 
     # Set experiment
     data_path = ed.download_data(id=19, destination=TEMP_DIR)
-    project.experiments.add(
+    project.experiments.add_from_data_path(
         name='heidi',
         data_path=data_path,
         sample_form='single crystal',
@@ -36,7 +36,7 @@ def test_single_fit_neut_sc_cwl_tbti() -> None:
     experiment.extinction.radius = 27
 
     # Select fitting parameters (experiment only)
-    # Sample model parameters are selected in the loaded CIF file
+    # Structure parameters are selected in the loaded CIF file
     experiment.linked_crystal.scale.free = True
     experiment.extinction.radius.free = True
 
@@ -52,13 +52,13 @@ def test_single_fit_neut_sc_cwl_tbti() -> None:
 def test_single_fit_neut_sc_tof_taurine() -> None:
     project = ed.Project()
 
-    # Set sample model
+    # Set structure
     model_path = ed.download_data(id=21, destination=TEMP_DIR)
-    project.sample_models.add(cif_path=model_path)
+    project.structures.add_from_cif_path(model_path)
 
     # Set experiment
     data_path = ed.download_data(id=22, destination=TEMP_DIR)
-    project.experiments.add(
+    project.experiments.add_from_data_path(
         name='senju',
         data_path=data_path,
         sample_form='single crystal',
@@ -73,7 +73,7 @@ def test_single_fit_neut_sc_tof_taurine() -> None:
     experiment.extinction.radius = 2.0
 
     # Select fitting parameters (experiment only)
-    # Sample model parameters are selected in the loaded CIF file
+    # Structure parameters are selected in the loaded CIF file
     experiment.linked_crystal.scale.free = True
     experiment.extinction.radius.free = True
 
diff --git a/tests/integration/scipp-analysis/dream/test_analyze_reduced_data.py b/tests/integration/scipp-analysis/dream/test_analyze_reduced_data.py
index eb528ff5..cde09c8a 100644
--- a/tests/integration/scipp-analysis/dream/test_analyze_reduced_data.py
+++ b/tests/integration/scipp-analysis/dream/test_analyze_reduced_data.py
@@ -4,7 +4,7 @@
 
 These tests verify the complete workflow:
 1. Define project
-2. Add sample model manually defined
+2. Add structure manually defined
 3. Modify experiment CIF file
 4. Add experiment from modified CIF file
 5. Modify default experiment configuration
@@ -56,11 +56,11 @@ def prepared_cif_path(
 def project_with_data(
     prepared_cif_path: str,
 ) -> ed.Project:
-    """Create project with sample model, experiment data, and
+    """Create project with structure, experiment data, and
     configuration.
 
     1. Define project
-    2. Add sample model manually defined
+    2. Add structure manually defined
     3. Modify experiment CIF file
     4. Add experiment from modified CIF file
     5. Modify default experiment configuration
@@ -68,16 +68,16 @@ def project_with_data(
     # Step 1: Define Project
     project = ed.Project()
 
-    # Step 2: Define Sample Model manually
-    project.sample_models.add(name='si')
-    sample_model = project.sample_models['si']
+    # Step 2: Define Structure manually
+    project.structures.create(name='si')
+    structure = project.structures['si']
 
-    sample_model.space_group.name_h_m = 'F d -3 m'
-    sample_model.space_group.it_coordinate_system_code = '1'
+    structure.space_group.name_h_m = 'F d -3 m'
+    structure.space_group.it_coordinate_system_code = '1'
 
-    sample_model.cell.length_a = 5.43146
+    structure.cell.length_a = 5.43146
 
-    sample_model.atom_sites.add(
+    structure.atom_sites.create(
         label='Si',
         type_symbol='Si',
         fract_x=0.125,
@@ -88,12 +88,12 @@ def project_with_data(
     )
 
     # Step 3: Add experiment from modified CIF file
-    project.experiments.add(cif_path=prepared_cif_path)
+    project.experiments.add_from_cif_path(prepared_cif_path)
     experiment = project.experiments['reduced_tof']
 
     # Step 4: Configure experiment
     # Link phase
-    experiment.linked_phases.add(id='si', scale=0.8)
+    experiment.linked_phases.create(id='si', scale=0.8)
 
     # Instrument setup
     experiment.instrument.setup_twotheta_bank = 90.0
@@ -109,8 +109,8 @@ def project_with_data(
     experiment.peak.asym_alpha_1 = 0.26
 
     # Excluded regions
-    experiment.excluded_regions.add(id='1', start=0, end=10000)
-    experiment.excluded_regions.add(id='2', start=70000, end=200000)
+    experiment.excluded_regions.create(id='1', start=0, end=10000)
+    experiment.excluded_regions.create(id='2', start=70000, end=200000)
 
     # Background points
     background_points = [
@@ -124,7 +124,7 @@ def project_with_data(
         ('9', 70000, 0.6),
     ]
     for id_, x, y in background_points:
-        experiment.background.add(id=id_, x=x, y=y)
+        experiment.background.create(id=id_, x=x, y=y)
 
     return project
 
@@ -139,12 +139,12 @@ def fitted_project(
     7. Do fitting
     """
     project = project_with_data
-    sample_model = project.sample_models['si']
+    structure = project.structures['si']
     experiment = project.experiments['reduced_tof']
 
     # Step 5: Select parameters to be fitted
-    # Set free parameters for sample model
-    sample_model.atom_sites['Si'].b_iso.free = True
+    # Set free parameters for structure
+    structure.atom_sites['Si'].b_iso.free = True
 
     # Set free parameters for experiment
     experiment.linked_phases['si'].scale.free = True
diff --git a/tests/unit/easydiffraction/analysis/calculators/test_crysfml.py b/tests/unit/easydiffraction/analysis/calculators/test_crysfml.py
index 866d4008..e35d1bd5 100644
--- a/tests/unit/easydiffraction/analysis/calculators/test_crysfml.py
+++ b/tests/unit/easydiffraction/analysis/calculators/test_crysfml.py
@@ -17,7 +17,7 @@ def test_crysfml_engine_flag_and_structure_factors_raises():
     # engine_imported is a boolean flag; it may be False in our env
     assert isinstance(calc.engine_imported, bool)
     with pytest.raises(NotImplementedError):
-        calc.calculate_structure_factors(sample_models=None, experiments=None)
+        calc.calculate_structure_factors(structures=None, experiments=None)
 
 
 def test_crysfml_adjust_pattern_length_truncates():
diff --git a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py
index 9289c15e..bccd63ec 100644
--- a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py
+++ b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py
@@ -21,5 +21,5 @@ class DummySample:
         def as_cif(self):
             return 'data_x'
 
-    # _convert_sample_model_to_cryspy_cif returns input as_cif
-    assert calc._convert_sample_model_to_cryspy_cif(DummySample()) == 'data_x'
+    # _convert_structure_to_cryspy_cif returns input as_cif
+    assert calc._convert_structure_to_cryspy_cif(DummySample()) == 'data_x'
diff --git a/tests/unit/easydiffraction/analysis/calculators/test_factory.py b/tests/unit/easydiffraction/analysis/calculators/test_factory.py
index 7cf9fc25..7df08b97 100644
--- a/tests/unit/easydiffraction/analysis/calculators/test_factory.py
+++ b/tests/unit/easydiffraction/analysis/calculators/test_factory.py
@@ -1,41 +1,22 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-def test_list_and_show_supported_calculators_do_not_crash(capsys, monkeypatch):
+import pytest
+
+
+def test_supported_tags_and_show_supported(capsys):
     from easydiffraction.analysis.calculators.factory import CalculatorFactory
 
-    # Simulate no engines available by forcing engine_imported to False
-    class DummyCalc:
-        def __call__(self):
-            return self
-
-        @property
-        def engine_imported(self):
-            return False
-
-    monkeypatch = monkeypatch  # keep name
-    monkeypatch.setitem(
-        CalculatorFactory._potential_calculators,
-        'dummy',
-        {
-            'description': 'Dummy calc',
-            'class': DummyCalc,
-        },
-    )
-
-    lst = CalculatorFactory.list_supported_calculators()
-    assert isinstance(lst, list)
-
-    CalculatorFactory.show_supported_calculators()
+    tags = CalculatorFactory.supported_tags()
+    assert isinstance(tags, list)
+
+    CalculatorFactory.show_supported()
     out = capsys.readouterr().out
-    # Should print the paragraph title
-    assert 'Supported calculators' in out
+    assert 'Supported types' in out
 
 
-def test_create_calculator_unknown_returns_none(capsys):
+def test_create_unknown_raises():
     from easydiffraction.analysis.calculators.factory import CalculatorFactory
 
-    obj = CalculatorFactory.create_calculator('this_is_unknown')
-    assert obj is None
-    out = capsys.readouterr().out
-    assert 'Unknown calculator' in out
+    with pytest.raises(ValueError):
+        CalculatorFactory.create('this_is_unknown')
diff --git a/tests/unit/easydiffraction/analysis/calculators/test_pdffit.py b/tests/unit/easydiffraction/analysis/calculators/test_pdffit.py
index e7662d43..268d0d84 100644
--- a/tests/unit/easydiffraction/analysis/calculators/test_pdffit.py
+++ b/tests/unit/easydiffraction/analysis/calculators/test_pdffit.py
@@ -16,7 +16,7 @@ def test_pdffit_engine_flag_and_hkl_message(capsys):
     calc = PdffitCalculator()
     assert isinstance(calc.engine_imported, bool)
     # calculate_structure_factors prints fixed message and returns [] by contract
-    out = calc.calculate_structure_factors(sample_models=None, experiments=None)
+    out = calc.calculate_structure_factors(structures=None, experiments=None)
     assert out == []
     # The method prints a note
     printed = capsys.readouterr().out
@@ -53,7 +53,7 @@ def __init__(self):
             self.type = type('T', (), {'radiation_probe': type('P', (), {'value': 'neutron'})()})()
             self.linked_phases = DummyLinkedPhases()
 
-    class DummySampleModel:
+    class DummyStructure:
         name = 'PhaseA'
 
         @property
@@ -93,6 +93,6 @@ def parse(self, text):
 
     calc = PdffitCalculator()
     pattern = calc.calculate_pattern(
-        DummySampleModel(), DummyExperiment(), called_by_minimizer=False
+        DummyStructure(), DummyExperiment(), called_by_minimizer=False
     )
     assert isinstance(pattern, np.ndarray) and pattern.shape[0] == 5
diff --git a/tests/unit/easydiffraction/analysis/categories/test_aliases.py b/tests/unit/easydiffraction/analysis/categories/test_aliases.py
index 73c7b9ab..4860961d 100644
--- a/tests/unit/easydiffraction/analysis/categories/test_aliases.py
+++ b/tests/unit/easydiffraction/analysis/categories/test_aliases.py
@@ -6,10 +6,12 @@
 
 
 def test_alias_creation_and_collection():
-    a = Alias(label='x', param_uid='p1')
+    a = Alias()
+    a.label='x'
+    a.param_uid='p1'
     assert a.label.value == 'x'
     coll = Aliases()
-    coll.add(label='x', param_uid='p1')
+    coll.create(label='x', param_uid='p1')
     # Collections index by entry name; check via names or direct indexing
     assert 'x' in coll.names
     assert coll['x'].param_uid.value == 'p1'
diff --git a/tests/unit/easydiffraction/analysis/categories/test_constraints.py b/tests/unit/easydiffraction/analysis/categories/test_constraints.py
index 542a9f52..7d4acb0a 100644
--- a/tests/unit/easydiffraction/analysis/categories/test_constraints.py
+++ b/tests/unit/easydiffraction/analysis/categories/test_constraints.py
@@ -6,9 +6,11 @@
 
 
 def test_constraint_creation_and_collection():
-    c = Constraint(lhs_alias='a', rhs_expr='b + c')
+    c = Constraint()
+    c.lhs_alias='a'
+    c.rhs_expr='b + c'
     assert c.lhs_alias.value == 'a'
     coll = Constraints()
-    coll.add(lhs_alias='a', rhs_expr='b + c')
+    coll.create(lhs_alias='a', rhs_expr='b + c')
     assert 'a' in coll.names
     assert coll['a'].rhs_expr.value == 'b + c'
diff --git a/tests/unit/easydiffraction/analysis/categories/test_joint_fit_experiments.py b/tests/unit/easydiffraction/analysis/categories/test_joint_fit_experiments.py
index 6a134ea0..c4194c35 100644
--- a/tests/unit/easydiffraction/analysis/categories/test_joint_fit_experiments.py
+++ b/tests/unit/easydiffraction/analysis/categories/test_joint_fit_experiments.py
@@ -6,10 +6,12 @@
 
 
 def test_joint_fit_experiment_and_collection():
-    j = JointFitExperiment(id='ex1', weight=0.5)
+    j = JointFitExperiment()
+    j.id='ex1'
+    j.weight=0.5
     assert j.id.value == 'ex1'
     assert j.weight.value == 0.5
     coll = JointFitExperiments()
-    coll.add(id='ex1', weight=0.5)
+    coll.create(id='ex1', weight=0.5)
     assert 'ex1' in coll.names
     assert coll['ex1'].weight.value == 0.5
diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_metrics.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_metrics.py
index 540c61c7..7c8b75c9 100644
--- a/tests/unit/easydiffraction/analysis/fit_helpers/test_metrics.py
+++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_metrics.py
@@ -46,9 +46,9 @@ class Expts(dict):
         def values(self):
             return [Expt()]
 
-    class SampleModels(dict):
+    class DummyStructures(dict):
         pass
 
-    y_obs, y_calc, y_err = M.get_reliability_inputs(SampleModels(), Expts())
+    y_obs, y_calc, y_err = M.get_reliability_inputs(DummyStructures(), Expts())
     assert y_obs.shape == (2,) and y_calc.shape == (2,) and y_err.shape == (2,)
     assert np.allclose(y_err, 1.0)
diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_base.py b/tests/unit/easydiffraction/analysis/minimizers/test_base.py
index dca36ee1..5d04a75a 100644
--- a/tests/unit/easydiffraction/analysis/minimizers/test_base.py
+++ b/tests/unit/easydiffraction/analysis/minimizers/test_base.py
@@ -49,7 +49,7 @@ def _check_success(self, raw_result):
 
         # Provide residuals implementation used by _objective_function
         def _compute_residuals(
-            self, engine_params, parameters, sample_models, experiments, calculator
+            self, engine_params, parameters, structures, experiments, calculator
         ):
             # Minimal residuals; verify engine params passed through
             assert engine_params == {'ok': True}
@@ -62,7 +62,7 @@ def _compute_residuals(
     # Wrap minimizer's objective creator to simulate higher-level usage
     objective = minim._create_objective_function(
         parameters=params,
-        sample_models=None,
+        structures=None,
         experiments=None,
         calculator=None,
     )
@@ -94,14 +94,14 @@ def _check_success(self, raw_result):
             return True
 
         def _compute_residuals(
-            self, engine_params, parameters, sample_models, experiments, calculator
+            self, engine_params, parameters, structures, experiments, calculator
         ):
             # Return a deterministic vector to assert against
             return np.array([1.0, 2.0, 3.0])
 
     m = M()
     f = m._create_objective_function(
-        parameters=[], sample_models=None, experiments=None, calculator=None
+        parameters=[], structures=None, experiments=None, calculator=None
     )
     out = f({})
     assert np.allclose(out, np.array([1.0, 2.0, 3.0]))
diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_dfols.py b/tests/unit/easydiffraction/analysis/minimizers/test_dfols.py
index f0a9ea9d..88e22dee 100644
--- a/tests/unit/easydiffraction/analysis/minimizers/test_dfols.py
+++ b/tests/unit/easydiffraction/analysis/minimizers/test_dfols.py
@@ -15,11 +15,22 @@ def test_dfols_prepare_run_and_sync(monkeypatch):
 
     class P:
         def __init__(self, v, lo=-np.inf, hi=np.inf):
-            self.value = v
+            self._value = v
             self.fit_min = lo
             self.fit_max = hi
             self.uncertainty = None
 
+        @property
+        def value(self):
+            return self._value
+
+        @value.setter
+        def value(self, v):
+            self._value = v
+
+        def _set_value_from_minimizer(self, v):
+            self._value = v
+
     class FakeRes:
         EXIT_SUCCESS = 0
 
diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_factory.py b/tests/unit/easydiffraction/analysis/minimizers/test_factory.py
index 940143a6..236d1656 100644
--- a/tests/unit/easydiffraction/analysis/minimizers/test_factory.py
+++ b/tests/unit/easydiffraction/analysis/minimizers/test_factory.py
@@ -4,34 +4,38 @@
 def test_minimizer_factory_list_and_show(capsys):
     from easydiffraction.analysis.minimizers.factory import MinimizerFactory
 
-    lst = MinimizerFactory.list_available_minimizers()
+    lst = MinimizerFactory.supported_tags()
     assert isinstance(lst, list) and len(lst) >= 1
-    MinimizerFactory.show_available_minimizers()
+    MinimizerFactory.show_supported()
     out = capsys.readouterr().out
-    assert 'Supported minimizers' in out
+    assert 'Supported types' in out
 
 
 def test_minimizer_factory_unknown_raises():
     from easydiffraction.analysis.minimizers.factory import MinimizerFactory
 
     try:
-        MinimizerFactory.create_minimizer('___unknown___')
+        MinimizerFactory.create('___unknown___')
     except ValueError as e:
-        assert 'Unknown minimizer' in str(e)
+        assert 'Unsupported type' in str(e)
     else:
         assert False, 'Expected ValueError'
 
 
-def test_minimizer_factory_create_known_and_register(monkeypatch):
+def test_minimizer_factory_create_known_and_register():
     from easydiffraction.analysis.minimizers.base import MinimizerBase
     from easydiffraction.analysis.minimizers.factory import MinimizerFactory
+    from easydiffraction.core.metadata import TypeInfo
 
-    # Create a known minimizer instance (lmfit (leastsq) exists)
-    m = MinimizerFactory.create_minimizer('lmfit (leastsq)')
+    # Create a known minimizer instance (lmfit exists)
+    m = MinimizerFactory.create('lmfit')
     assert isinstance(m, MinimizerBase)
 
     # Register a custom minimizer and create it
+    @MinimizerFactory.register
     class Custom(MinimizerBase):
+        type_info = TypeInfo(tag='custom-test', description='x')
+
         def _prepare_solver_args(self, parameters):
             return {}
 
@@ -44,8 +48,5 @@ def _sync_result_to_parameters(self, raw_result, parameters):
         def _check_success(self, raw_result):
             return True
 
-    MinimizerFactory.register_minimizer(
-        name='custom-test', minimizer_cls=Custom, method=None, description='x'
-    )
-    created = MinimizerFactory.create_minimizer('custom-test')
+    created = MinimizerFactory.create('custom-test')
     assert isinstance(created, Custom)
diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_lmfit.py b/tests/unit/easydiffraction/analysis/minimizers/test_lmfit.py
index c2385ee4..77b694ee 100644
--- a/tests/unit/easydiffraction/analysis/minimizers/test_lmfit.py
+++ b/tests/unit/easydiffraction/analysis/minimizers/test_lmfit.py
@@ -32,6 +32,9 @@ def value(self):
         def value(self, v):
             self._value = v
 
+        def _set_value_from_minimizer(self, v):
+            self._value = v
+
     # Fake lmfit.Parameters and result structure
     class FakeParam:
         def __init__(self, value, stderr=None):
diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py
index 4a16d206..56d493aa 100644
--- a/tests/unit/easydiffraction/analysis/test_analysis.py
+++ b/tests/unit/easydiffraction/analysis/test_analysis.py
@@ -20,68 +20,97 @@ def names(self):
 
     class P:
         experiments = ExpCol(names)
-        sample_models = object()
+        structures = object()
         _varname = 'proj'
 
     return P()
 
 
-def test_show_current_calculator_and_minimizer_prints(capsys):
+def test_show_current_minimizer_prints(capsys):
     from easydiffraction.analysis.analysis import Analysis
 
     a = Analysis(project=_make_project_with_names([]))
-    a.show_current_calculator()
     a.show_current_minimizer()
     out = capsys.readouterr().out
-    assert 'Current calculator' in out
-    assert 'cryspy' in out
     assert 'Current minimizer' in out
-    assert 'lmfit (leastsq)' in out
+    assert 'lmfit' in out
 
 
-def test_current_calculator_setter_success_and_unknown(monkeypatch, capsys):
-    from easydiffraction.analysis import calculators as calc_pkg
+
+def test_fit_mode_category_and_joint_fit_experiments(monkeypatch, capsys):
+    from easydiffraction.analysis.analysis import Analysis
+
+    a = Analysis(project=_make_project_with_names(['e1', 'e2']))
+
+    # Default fit mode is 'single'
+    assert a.fit_mode.mode.value == 'single'
+
+    # Switch to joint
+    a.fit_mode.mode = 'joint'
+    assert a.fit_mode.mode.value == 'joint'
+
+    # joint_fit_experiments exists but is empty until fit() populates it
+    assert len(a.joint_fit_experiments) == 0
+
+
+def test_fit_mode_type_getter(capsys):
     from easydiffraction.analysis.analysis import Analysis
 
     a = Analysis(project=_make_project_with_names([]))
+    assert a.fit_mode_type == 'default'
+
+
+def test_show_supported_fit_mode_types(capsys):
+    from easydiffraction.analysis.analysis import Analysis
+
+    a = Analysis(project=_make_project_with_names([]))
+    a.show_supported_fit_mode_types()
+    out = capsys.readouterr().out
+    assert 'default' in out
 
-    # Success path
-    monkeypatch.setattr(
-        calc_pkg.factory.CalculatorFactory,
-        'create_calculator',
-        lambda name: object(),
-    )
-    a.current_calculator = 'pdffit'
+
+def test_show_current_fit_mode_type(capsys):
+    from easydiffraction.analysis.analysis import Analysis
+
+    a = Analysis(project=_make_project_with_names([]))
+    a.show_current_fit_mode_type()
     out = capsys.readouterr().out
-    assert 'Current calculator changed to' in out
-    assert a.current_calculator == 'pdffit'
+    assert 'Current fit-mode type' in out
+    assert 'default' in out
 
-    # Unknown path (create_calculator returns None): no change
-    monkeypatch.setattr(
-        calc_pkg.factory.CalculatorFactory,
-        'create_calculator',
-        lambda name: None,
-    )
-    a.current_calculator = 'unknown'
-    assert a.current_calculator == 'pdffit'
+
+def test_fit_mode_type_setter_valid(capsys):
+    from easydiffraction.analysis.analysis import Analysis
+
+    a = Analysis(project=_make_project_with_names([]))
+    a.fit_mode_type = 'default'
+    assert a.fit_mode_type == 'default'
 
 
-def test_fit_modes_show_and_switch_to_joint(monkeypatch, capsys):
+def test_fit_mode_type_setter_invalid(capsys):
     from easydiffraction.analysis.analysis import Analysis
 
-    a = Analysis(project=_make_project_with_names(['e1', 'e2']))
+    a = Analysis(project=_make_project_with_names([]))
+    a.fit_mode_type = 'nonexistent'
+    out = capsys.readouterr().out
+    assert 'Unsupported' in out
+    # Type should remain unchanged
+    assert a.fit_mode_type == 'default'
 
-    a.show_available_fit_modes()
-    a.show_current_fit_mode()
-    out1 = capsys.readouterr().out
-    assert 'Available fit modes' in out1
-    assert 'Current fit mode' in out1
-    assert 'single' in out1
 
-    a.fit_mode = 'joint'
-    out2 = capsys.readouterr().out
-    assert 'Current fit mode changed to' in out2
-    assert a.fit_mode == 'joint'
+def test_analysis_help(capsys):
+    from easydiffraction.analysis.analysis import Analysis
+
+    a = Analysis(project=_make_project_with_names([]))
+    a.help()
+    out = capsys.readouterr().out
+    assert "Help for 'Analysis'" in out
+    assert 'fit_mode' in out
+    assert 'current_minimizer' in out
+    assert 'Properties' in out
+    assert 'Methods' in out
+    assert 'fit()' in out
+    assert 'show_fit_results()' in out
 
 
 def test_show_fit_results_warns_when_no_results(capsys):
@@ -105,13 +134,13 @@ def test_show_fit_results_calls_process_fit_results(monkeypatch):
     # Track if _process_fit_results was called
     process_called = {'called': False, 'args': None}
 
-    def mock_process_fit_results(sample_models, experiments):
+    def mock_process_fit_results(structures, experiments):
         process_called['called'] = True
-        process_called['args'] = (sample_models, experiments)
+        process_called['args'] = (structures, experiments)
 
-    # Create a mock project with sample_models and experiments
+    # Create a mock project with structures and experiments
     class MockProject:
-        sample_models = object()
+        structures = object()
         experiments = object()
         _varname = 'proj'
 
@@ -121,7 +150,7 @@ class experiments_cls:
         experiments = experiments_cls()
 
     project = MockProject()
-    project.sample_models = object()
+    project.structures = object()
     project.experiments.names = []
 
     a = Analysis(project=project)
diff --git a/tests/unit/easydiffraction/analysis/test_analysis_access_params.py b/tests/unit/easydiffraction/analysis/test_analysis_access_params.py
index df8827ea..96bf6ca1 100644
--- a/tests/unit/easydiffraction/analysis/test_analysis_access_params.py
+++ b/tests/unit/easydiffraction/analysis/test_analysis_access_params.py
@@ -3,9 +3,8 @@
 
 def test_how_to_access_parameters_prints_paths_and_uids(capsys, monkeypatch):
     from easydiffraction.analysis.analysis import Analysis
-    from easydiffraction.core.parameters import Parameter
+    from easydiffraction.core.variable import Parameter
     from easydiffraction.core.validation import AttributeSpec
-    from easydiffraction.core.validation import DataTypes
     from easydiffraction.io.cif.handler import CifHandler
     import easydiffraction.analysis.analysis as analysis_mod
 
@@ -13,9 +12,10 @@ def test_how_to_access_parameters_prints_paths_and_uids(capsys, monkeypatch):
     def make_param(db, cat, entry, name, val):
         p = Parameter(
             name=name,
-            value_spec=AttributeSpec(value=val, type_=DataTypes.NUMERIC, default=0.0),
+            value_spec=AttributeSpec(default=0.0),
             cif_handler=CifHandler(names=[f'_{cat}.{name}']),
         )
+        p.value = val
         # Inject identity metadata (avoid parent chain)
         p._identity.datablock_entry_name = lambda: db
         p._identity.category_code = cat
@@ -36,7 +36,7 @@ class Project:
         _varname = 'proj'
 
         def __init__(self):
-            self.sample_models = Coll([p1])
+            self.structures = Coll([p1])
             self.experiments = Coll([p2])
 
     # Capture the table payload by monkeypatching render_table to avoid
@@ -63,7 +63,7 @@ def fake_render_table(**kwargs):
     flat_rows = [' '.join(map(str, row)) for row in data]
 
     # Python access paths
-    assert any("proj.sample_models['db1'].catA.alpha" in r for r in flat_rows)
+    assert any("proj.structures['db1'].catA.alpha" in r for r in flat_rows)
     assert any("proj.experiments['db2'].catB['row1'].beta" in r for r in flat_rows)
 
     # Now check CIF unique identifiers via the new API
diff --git a/tests/unit/easydiffraction/analysis/test_analysis_show_empty.py b/tests/unit/easydiffraction/analysis/test_analysis_show_empty.py
index 8503398c..921127ba 100644
--- a/tests/unit/easydiffraction/analysis/test_analysis_show_empty.py
+++ b/tests/unit/easydiffraction/analysis/test_analysis_show_empty.py
@@ -18,7 +18,7 @@ def free_parameters(self):
             return []
 
     class P:
-        sample_models = Empty()
+        structures = Empty()
         experiments = Empty()
         _varname = 'proj'
 
diff --git a/tests/unit/easydiffraction/analysis/test_fitting.py b/tests/unit/easydiffraction/analysis/test_fitting.py
index 990cabaf..d3a1b1fb 100644
--- a/tests/unit/easydiffraction/analysis/test_fitting.py
+++ b/tests/unit/easydiffraction/analysis/test_fitting.py
@@ -31,7 +31,7 @@ def fit(self, params, obj):
     f = Fitter()
     # Avoid creating a real minimizer
     f.minimizer = DummyMin()
-    f.fit(sample_models=DummyCollection(), experiments=DummyCollection())
+    f.fit(structures=DummyCollection(), experiments=DummyCollection())
     out = capsys.readouterr().out
     assert 'No parameters selected for fitting' in out
 
@@ -83,7 +83,7 @@ def mock_process(*args, **kwargs):
 
     monkeypatch.setattr(f, '_process_fit_results', mock_process)
 
-    f.fit(sample_models=DummyCollection(), experiments=DummyCollection())
+    f.fit(structures=DummyCollection(), experiments=DummyCollection())
 
     assert not process_called['called'], (
         'Fitter.fit() should not call _process_fit_results automatically. '
diff --git a/tests/unit/easydiffraction/core/test_category.py b/tests/unit/easydiffraction/core/test_category.py
index 6143c479..c53fd12c 100644
--- a/tests/unit/easydiffraction/core/test_category.py
+++ b/tests/unit/easydiffraction/core/test_category.py
@@ -3,24 +3,22 @@
 
 from easydiffraction.core.category import CategoryCollection
 from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.parameters import StringDescriptor
+from easydiffraction.core.variable import StringDescriptor
 from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import DataTypes
 from easydiffraction.io.cif.handler import CifHandler
 
 
 class SimpleItem(CategoryItem):
-    def __init__(self, entry_name):
+    def __init__(self):
         super().__init__()
         self._identity.category_code = 'simple'
-        self._identity.category_entry_name = entry_name
         object.__setattr__(
             self,
             '_a',
             StringDescriptor(
                 name='a',
                 description='',
-                value_spec=AttributeSpec(value='x', type_=DataTypes.STRING, default=''),
+                value_spec=AttributeSpec(default='_'),
                 cif_handler=CifHandler(names=['_simple.a']),
             ),
         )
@@ -30,19 +28,28 @@ def __init__(self, entry_name):
             StringDescriptor(
                 name='b',
                 description='',
-                value_spec=AttributeSpec(value='y', type_=DataTypes.STRING, default=''),
+                value_spec=AttributeSpec(default='_'),
                 cif_handler=CifHandler(names=['_simple.b']),
             ),
         )
+        self._identity.category_entry_name = lambda: str(self._a.value)
 
     @property
     def a(self):
         return self._a
 
+    @a.setter
+    def a(self, value):
+        self._a.value = value
+
     @property
     def b(self):
         return self._b
 
+    @b.setter
+    def b(self, value):
+        self._b.value = value
+
 
 class SimpleCollection(CategoryCollection):
     def __init__(self):
@@ -50,7 +57,8 @@ def __init__(self):
 
 
 def test_category_item_str_and_properties():
-    it = SimpleItem('name1')
+    it = SimpleItem()
+    it.a = 'name1'
     s = str(it)
     assert '<' in s and 'a=' in s and 'b=' in s
     assert it.unique_name.endswith('.simple.name1') or it.unique_name == 'simple.name1'
@@ -59,9 +67,35 @@ def test_category_item_str_and_properties():
 
 def test_category_collection_str_and_cif_calls():
     c = SimpleCollection()
-    c.add('n1')
-    c.add('n2')
+    c.create(a='n1')
+    c.create(a='n2')
     s = str(c)
     assert 'collection' in s and '2 items' in s
     # as_cif delegates to serializer; should be a string (possibly empty)
     assert isinstance(c.as_cif, str)
+
+
+def test_category_item_help(capsys):
+    it = SimpleItem()
+    it.a = 'name1'
+    it.help()
+    out = capsys.readouterr().out
+    assert 'Help for' in out
+    assert 'Parameters' in out
+    assert 'string' in out  # Type column
+    assert '✓' in out  # a and b are writable
+    assert 'Methods' in out
+
+
+def test_category_collection_help(capsys):
+    c = SimpleCollection()
+    c.create(a='n1')
+    c.create(a='n2')
+    c.help()
+    out = capsys.readouterr().out
+    assert 'Help for' in out
+    assert 'Items (2)' in out
+    assert 'n1' in out
+    assert 'n2' in out
+
+
diff --git a/tests/unit/easydiffraction/core/test_collection.py b/tests/unit/easydiffraction/core/test_collection.py
index 9d89afea..616b232f 100644
--- a/tests/unit/easydiffraction/core/test_collection.py
+++ b/tests/unit/easydiffraction/core/test_collection.py
@@ -29,3 +29,94 @@ def as_cif(self) -> str:
     assert c['a'] is a2 and len(list(c.keys())) == 2
     del c['b']
     assert list(c.names) == ['a']
+
+
+def test_collection_contains():
+    from easydiffraction.core.collection import CollectionBase
+    from easydiffraction.core.identity import Identity
+
+    class Item:
+        def __init__(self, name):
+            self._identity = Identity(owner=self, category_entry=lambda: name)
+
+    class MyCollection(CollectionBase):
+        @property
+        def parameters(self):
+            return []
+
+        @property
+        def as_cif(self) -> str:
+            return ''
+
+    c = MyCollection(item_type=Item)
+    c['x'] = Item('x')
+    assert 'x' in c
+    assert 'y' not in c
+
+
+def test_collection_remove():
+    from easydiffraction.core.collection import CollectionBase
+    from easydiffraction.core.identity import Identity
+
+    import pytest
+
+    class Item:
+        def __init__(self, name):
+            self._identity = Identity(owner=self, category_entry=lambda: name)
+
+    class MyCollection(CollectionBase):
+        @property
+        def parameters(self):
+            return []
+
+        @property
+        def as_cif(self) -> str:
+            return ''
+
+    c = MyCollection(item_type=Item)
+    c['a'] = Item('a')
+    c['b'] = Item('b')
+    c.remove('a')
+    assert 'a' not in c
+    assert len(c) == 1
+    with pytest.raises(KeyError):
+        c.remove('nonexistent')
+
+
+def test_collection_datablock_keyed_items():
+    """Verify __setitem__/__delitem__/__contains__ work for datablock-keyed items."""
+    from easydiffraction.core.collection import CollectionBase
+    from easydiffraction.core.identity import Identity
+
+    class DbItem:
+        def __init__(self, name):
+            self._identity = Identity(owner=self, datablock_entry=lambda: name)
+
+    class MyCollection(CollectionBase):
+        @property
+        def parameters(self):
+            return []
+
+        @property
+        def as_cif(self) -> str:
+            return ''
+
+    c = MyCollection(item_type=DbItem)
+    a = DbItem('alpha')
+    b = DbItem('beta')
+    c['alpha'] = a
+    c['beta'] = b
+    assert 'alpha' in c
+    assert c['alpha'] is a
+
+    # Replace
+    a2 = DbItem('alpha')
+    c['alpha'] = a2
+    assert c['alpha'] is a2
+    assert len(c) == 2
+
+    # Delete
+    del c['beta']
+    assert 'beta' not in c
+    assert len(c) == 1
+
diff --git a/tests/unit/easydiffraction/core/test_datablock.py b/tests/unit/easydiffraction/core/test_datablock.py
index 91d35663..6565bbaa 100644
--- a/tests/unit/easydiffraction/core/test_datablock.py
+++ b/tests/unit/easydiffraction/core/test_datablock.py
@@ -5,9 +5,8 @@ def test_datablock_collection_add_and_filters_with_real_parameters():
     from easydiffraction.core.category import CategoryItem
     from easydiffraction.core.datablock import DatablockCollection
     from easydiffraction.core.datablock import DatablockItem
-    from easydiffraction.core.parameters import Parameter
+    from easydiffraction.core.variable import Parameter
     from easydiffraction.core.validation import AttributeSpec
-    from easydiffraction.core.validation import DataTypes
     from easydiffraction.io.cif.handler import CifHandler
 
     class Cat(CategoryItem):
@@ -19,17 +18,20 @@ def __init__(self):
             self._p1 = Parameter(
                 name='p1',
                 description='',
-                value_spec=AttributeSpec(value=1.0, type_=DataTypes.NUMERIC, default=0.0),
+                value_spec=AttributeSpec(default=0.0),
                 units='',
                 cif_handler=CifHandler(names=['_cat.p1']),
             )
             self._p2 = Parameter(
                 name='p2',
                 description='',
-                value_spec=AttributeSpec(value=2.0, type_=DataTypes.NUMERIC, default=0.0),
+                value_spec=AttributeSpec(default=0.0),
                 units='',
                 cif_handler=CifHandler(names=['_cat.p2']),
             )
+            # Set actual values via setter
+            self._p1.value = 1.0
+            self._p2.value = 2.0
             # Make p2 constrained and not free
             self._p2._constrained = True
             self._p2._free = False
@@ -59,8 +61,8 @@ def cat(self):
     coll = DatablockCollection(item_type=Block)
     a = Block('A')
     b = Block('B')
-    coll._add(a)
-    coll._add(b)
+    coll.add(a)
+    coll.add(b)
     # parameters collection aggregates from both blocks (p1 & p2 each)
     params = coll.parameters
     assert len(params) == 4
@@ -71,3 +73,64 @@ def cat(self):
     # free is subset of fittable where free=True (true for p1)
     free_params = coll.free_parameters
     assert free_params == fittable
+
+
+def test_datablock_item_help(capsys):
+    from easydiffraction.core.category import CategoryItem
+    from easydiffraction.core.datablock import DatablockItem
+    from easydiffraction.core.variable import Parameter
+    from easydiffraction.core.validation import AttributeSpec
+    from easydiffraction.io.cif.handler import CifHandler
+
+    class Cat(CategoryItem):
+        def __init__(self):
+            super().__init__()
+            self._identity.category_code = 'cat'
+            self._identity.category_entry_name = 'e1'
+            self._p1 = Parameter(
+                name='p1',
+                description='',
+                value_spec=AttributeSpec(default=0.0),
+                units='',
+                cif_handler=CifHandler(names=['_cat.p1']),
+            )
+
+        @property
+        def p1(self):
+            return self._p1
+
+    class Block(DatablockItem):
+        def __init__(self):
+            super().__init__()
+            self._identity.datablock_entry_name = lambda: 'blk'
+            self._cat = Cat()
+
+        @property
+        def cat(self):
+            return self._cat
+
+    b = Block()
+    b.help()
+    out = capsys.readouterr().out
+    assert 'Help for' in out
+    assert 'Categories' in out
+    assert 'cat' in out
+
+
+def test_datablock_collection_help(capsys):
+    from easydiffraction.core.datablock import DatablockCollection
+    from easydiffraction.core.datablock import DatablockItem
+
+    class Block(DatablockItem):
+        def __init__(self, name):
+            super().__init__()
+            self._identity.datablock_entry_name = lambda: name
+
+    coll = DatablockCollection(item_type=Block)
+    a = Block('A')
+    coll.add(a)
+    coll.help()
+    out = capsys.readouterr().out
+    assert 'Items (1)' in out
+    assert 'A' in out
+
diff --git a/tests/unit/easydiffraction/core/test_factory.py b/tests/unit/easydiffraction/core/test_factory.py
index 22ffad86..ee85b97c 100644
--- a/tests/unit/easydiffraction/core/test_factory.py
+++ b/tests/unit/easydiffraction/core/test_factory.py
@@ -1,29 +1,5 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-import pytest
-
-
-def test_module_import():
-    import easydiffraction.core.factory as MUT
-
-    expected_module_name = 'easydiffraction.core.factory'
-    actual_module_name = MUT.__name__
-    assert expected_module_name == actual_module_name
-
-
-def test_validate_args_valid_and_invalid():
-    import easydiffraction.core.factory as MUT
-
-    specs = [
-        {'required': ['a'], 'optional': ['b']},
-        {'required': ['x', 'y'], 'optional': []},
-    ]
-    # valid: only required
-    MUT.FactoryBase._validate_args({'a'}, specs, 'Thing')
-    # valid: required + optional subset
-    MUT.FactoryBase._validate_args({'a', 'b'}, specs, 'Thing')
-    MUT.FactoryBase._validate_args({'x', 'y'}, specs, 'Thing')
-    # invalid: unknown key
-    with pytest.raises(ValueError):
-        MUT.FactoryBase._validate_args({'a', 'c'}, specs, 'Thing')
+# core/factory.py was removed — FactoryBase and _validate_args are no
+# longer part of the codebase.  This test file is intentionally empty.
diff --git a/tests/unit/easydiffraction/core/test_guard.py b/tests/unit/easydiffraction/core/test_guard.py
index 1cd9dd15..34407914 100644
--- a/tests/unit/easydiffraction/core/test_guard.py
+++ b/tests/unit/easydiffraction/core/test_guard.py
@@ -51,3 +51,53 @@ def as_cif(self) -> str:
     # Unknown attribute should raise AttributeError under current logging mode
     with pytest.raises(AttributeError):
         p.child.unknown_attr = 1
+
+
+def test_help_lists_public_properties(capsys):
+    from easydiffraction.core.guard import GuardedBase
+
+    class Obj(GuardedBase):
+        @property
+        def parameters(self):
+            return []
+
+        @property
+        def as_cif(self) -> str:
+            return ''
+
+        @property
+        def name(self):
+            """Human-readable name."""
+            return 'test'
+
+        @property
+        def score(self):
+            """Computed score."""
+            return 42
+
+        @score.setter
+        def score(self, v):
+            pass
+
+    obj = Obj()
+    obj.help()
+    out = capsys.readouterr().out
+    assert "Help for 'Obj'" in out
+    assert 'name' in out
+    assert 'score' in out
+    assert 'Properties' in out
+    assert 'Methods' in out
+    assert '✓' in out  # score is writable
+    assert '✗' in out  # name is read-only
+
+
+def test_first_sentence_extracts_first_paragraph():
+    from easydiffraction.core.guard import GuardedBase
+
+    assert GuardedBase._first_sentence(None) == ''
+    assert GuardedBase._first_sentence('') == ''
+    assert GuardedBase._first_sentence('One liner.') == 'One liner.'
+    assert GuardedBase._first_sentence('First.\n\nSecond.') == 'First.'
+    assert GuardedBase._first_sentence('Line one\ncontinued.') == 'Line one continued.'
+
+
diff --git a/tests/unit/easydiffraction/core/test_parameters.py b/tests/unit/easydiffraction/core/test_parameters.py
index 3183fb81..35c65558 100644
--- a/tests/unit/easydiffraction/core/test_parameters.py
+++ b/tests/unit/easydiffraction/core/test_parameters.py
@@ -6,14 +6,14 @@
 
 
 def test_module_import():
-    import easydiffraction.core.parameters as MUT
+    import easydiffraction.core.variable as MUT
 
-    assert MUT.__name__ == 'easydiffraction.core.parameters'
+    assert MUT.__name__ == 'easydiffraction.core.variable'
 
 
 def test_string_descriptor_type_override_raises_type_error():
     # Creating a StringDescriptor with a NUMERIC spec should raise via Diagnostics
-    from easydiffraction.core.parameters import StringDescriptor
+    from easydiffraction.core.variable import StringDescriptor
     from easydiffraction.core.validation import AttributeSpec
     from easydiffraction.core.validation import DataTypes
     from easydiffraction.io.cif.handler import CifHandler
@@ -21,21 +21,20 @@ def test_string_descriptor_type_override_raises_type_error():
     with pytest.raises(TypeError):
         StringDescriptor(
             name='title',
-            value_spec=AttributeSpec(value='abc', type_=DataTypes.NUMERIC, default='x'),
+            value_spec=AttributeSpec(data_type=DataTypes.NUMERIC, default='x'),
             description='Title text',
             cif_handler=CifHandler(names=['_proj.title']),
         )
 
 
 def test_numeric_descriptor_str_includes_units():
-    from easydiffraction.core.parameters import NumericDescriptor
+    from easydiffraction.core.variable import NumericDescriptor
     from easydiffraction.core.validation import AttributeSpec
-    from easydiffraction.core.validation import DataTypes
     from easydiffraction.io.cif.handler import CifHandler
 
     d = NumericDescriptor(
         name='w',
-        value_spec=AttributeSpec(value=1.23, type_=DataTypes.NUMERIC, default=0.0),
+        value_spec=AttributeSpec(default=1.23),
         units='deg',
         cif_handler=CifHandler(names=['_x.w']),
     )
@@ -44,17 +43,17 @@ def test_numeric_descriptor_str_includes_units():
 
 
 def test_parameter_string_repr_and_as_cif_and_flags():
-    from easydiffraction.core.parameters import Parameter
+    from easydiffraction.core.variable import Parameter
     from easydiffraction.core.validation import AttributeSpec
-    from easydiffraction.core.validation import DataTypes
     from easydiffraction.io.cif.handler import CifHandler
 
     p = Parameter(
         name='a',
-        value_spec=AttributeSpec(value=2.5, type_=DataTypes.NUMERIC, default=0.0),
+        value_spec=AttributeSpec(default=0.0),
         units='A',
         cif_handler=CifHandler(names=['_param.a']),
     )
+    p.value = 2.5
     # Update extra attributes
     p.uncertainty = 0.1
     p.free = True
@@ -70,14 +69,13 @@ def test_parameter_string_repr_and_as_cif_and_flags():
 
 
 def test_parameter_uncertainty_must_be_non_negative():
-    from easydiffraction.core.parameters import Parameter
+    from easydiffraction.core.variable import Parameter
     from easydiffraction.core.validation import AttributeSpec
-    from easydiffraction.core.validation import DataTypes
     from easydiffraction.io.cif.handler import CifHandler
 
     p = Parameter(
         name='b',
-        value_spec=AttributeSpec(value=1.0, type_=DataTypes.NUMERIC, default=0.0),
+        value_spec=AttributeSpec(default=1.0),
         cif_handler=CifHandler(names=['_param.b']),
     )
     with pytest.raises(TypeError):
@@ -85,14 +83,13 @@ def test_parameter_uncertainty_must_be_non_negative():
 
 
 def test_parameter_fit_bounds_assign_and_read():
-    from easydiffraction.core.parameters import Parameter
+    from easydiffraction.core.variable import Parameter
     from easydiffraction.core.validation import AttributeSpec
-    from easydiffraction.core.validation import DataTypes
     from easydiffraction.io.cif.handler import CifHandler
 
     p = Parameter(
         name='c',
-        value_spec=AttributeSpec(value=0.0, type_=DataTypes.NUMERIC, default=0.0),
+        value_spec=AttributeSpec(default=0.0),
         cif_handler=CifHandler(names=['_param.c']),
     )
     p.fit_min = -1.0
diff --git a/tests/unit/easydiffraction/core/test_singletons.py b/tests/unit/easydiffraction/core/test_singletons.py
index d2f8fa3a..bd2c5aef 100644
--- a/tests/unit/easydiffraction/core/test_singletons.py
+++ b/tests/unit/easydiffraction/core/test_singletons.py
@@ -5,7 +5,7 @@
 
 
 def test_uid_map_handler_rejects_non_descriptor():
-    from easydiffraction.core.singletons import UidMapHandler
+    from easydiffraction.core.singleton import UidMapHandler
 
     h = UidMapHandler.get()
     with pytest.raises(TypeError):
diff --git a/tests/unit/easydiffraction/core/test_validation.py b/tests/unit/easydiffraction/core/test_validation.py
index 64150f33..70a92bb5 100644
--- a/tests/unit/easydiffraction/core/test_validation.py
+++ b/tests/unit/easydiffraction/core/test_validation.py
@@ -9,7 +9,7 @@ def test_module_import():
     assert expected_module_name == actual_module_name
 
 
-def test_type_validator_accepts_and_rejects(monkeypatch):
+def test_data_type_validator_accepts_and_rejects(monkeypatch):
     from easydiffraction.core.validation import AttributeSpec
     from easydiffraction.core.validation import DataTypes
     from easydiffraction.utils.logging import log
@@ -17,7 +17,7 @@ def test_type_validator_accepts_and_rejects(monkeypatch):
     # So that errors do not raise in test process
     log.configure(reaction=log.Reaction.WARN)
 
-    spec = AttributeSpec(type_=DataTypes.STRING, default='abc')
+    spec = AttributeSpec(data_type=DataTypes.STRING, default='abc')
     # valid
     expected = 'xyz'
     actual = spec.validated('xyz', name='p')
@@ -36,7 +36,7 @@ def test_range_validator_bounds(monkeypatch):
 
     log.configure(reaction=log.Reaction.WARN)
     spec = AttributeSpec(
-        type_=DataTypes.NUMERIC, default=1.0, content_validator=RangeValidator(ge=0, le=2)
+        data_type=DataTypes.NUMERIC, default=1.0, validator=RangeValidator(ge=0, le=2)
     )
     # inside range
     expected = 1.5
@@ -55,11 +55,11 @@ def test_membership_and_regex_validators(monkeypatch):
     from easydiffraction.utils.logging import log
 
     log.configure(reaction=log.Reaction.WARN)
-    mspec = AttributeSpec(default='b', content_validator=MembershipValidator(['a', 'b']))
+    mspec = AttributeSpec(default='b', validator=MembershipValidator(['a', 'b']))
     assert mspec.validated('a', name='m') == 'a'
     # reject -> fallback default
     assert mspec.validated('c', name='m') == 'b'
 
-    rspec = AttributeSpec(default='a1', content_validator=RegexValidator(r'^[a-z]\d$'))
+    rspec = AttributeSpec(default='a1', validator=RegexValidator(r'^[a-z]\d$'))
     assert rspec.validated('b2', name='r') == 'b2'
     assert rspec.validated('BAD', name='r') == 'a1'
diff --git a/tests/unit/easydiffraction/experiments/categories/background/test_base.py b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_base.py
similarity index 75%
rename from tests/unit/easydiffraction/experiments/categories/background/test_base.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/background/test_base.py
index 57f1bfeb..31c7fd0b 100644
--- a/tests/unit/easydiffraction/experiments/categories/background/test_base.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_base.py
@@ -7,23 +7,30 @@
 def test_background_base_minimal_impl_and_collection_cif():
     from easydiffraction.core.category import CategoryItem
     from easydiffraction.core.collection import CollectionBase
-    from easydiffraction.core.parameters import Parameter
+    from easydiffraction.core.variable import Parameter
     from easydiffraction.core.validation import AttributeSpec
     from easydiffraction.core.validation import DataTypes
-    from easydiffraction.experiments.categories.background.base import BackgroundBase
+    from easydiffraction.datablocks.experiment.categories.background.base import BackgroundBase
     from easydiffraction.io.cif.handler import CifHandler
 
     class ConstantBackground(CategoryItem):
-        def __init__(self, name: str, value: float):
-            # CategoryItem doesn't define __init__; call GuardedBase via super()
+        def __init__(self):
             super().__init__()
             self._identity.category_code = 'background'
-            self._identity.category_entry_name = name
             self._level = Parameter(
                 name='level',
-                value_spec=AttributeSpec(value=value, type_=DataTypes.NUMERIC, default=0.0),
+                value_spec=AttributeSpec(data_type=DataTypes.NUMERIC, default=0.0),
                 cif_handler=CifHandler(names=['_bkg.level']),
             )
+            self._identity.category_entry_name = lambda: str(self._level.value)
+
+        @property
+        def level(self):
+            return self._level
+
+        @level.setter
+        def level(self, value):
+            self._level.value = value
 
         def calculate(self, x_data):
             return np.full_like(np.asarray(x_data), fill_value=self._level.value, dtype=float)
@@ -48,9 +55,10 @@ def show(self) -> None:  # pragma: no cover - trivial
             return None
 
     coll = BackgroundCollection()
-    a = ConstantBackground('a', 1.0)
-    coll.add('a', 1.0)
-    coll.add('b', 2.0)
+    a = ConstantBackground()
+    a.level = 1.0
+    coll.create(level=1.0)
+    coll.create(level=2.0)
 
     # calculate sums two backgrounds externally (out of scope), here just verify item.calculate
     x = np.array([0.0, 1.0, 2.0])
diff --git a/tests/unit/easydiffraction/experiments/categories/background/test_chebyshev.py b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_chebyshev.py
similarity index 85%
rename from tests/unit/easydiffraction/experiments/categories/background/test_chebyshev.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/background/test_chebyshev.py
index 07a61a94..b59ea102 100644
--- a/tests/unit/easydiffraction/experiments/categories/background/test_chebyshev.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_chebyshev.py
@@ -7,7 +7,7 @@
 def test_chebyshev_background_calculate_and_cif():
     from types import SimpleNamespace
 
-    from easydiffraction.experiments.categories.background.chebyshev import (
+    from easydiffraction.datablocks.experiment.categories.background.chebyshev import (
         ChebyshevPolynomialBackground,
     )
 
@@ -25,7 +25,7 @@ def test_chebyshev_background_calculate_and_cif():
     assert np.allclose(mock_data._bkg, 0.0)
 
     # Add two terms and verify CIF contains expected tags
-    cb.add(order=0, coef=1.0)
-    cb.add(order=1, coef=0.5)
+    cb.create(order=0, coef=1.0)
+    cb.create(order=1, coef=0.5)
     cif = cb.as_cif
     assert '_pd_background.Chebyshev_order' in cif and '_pd_background.Chebyshev_coef' in cif
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_enums.py b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_enums.py
new file mode 100644
index 00000000..47e0f5f3
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_enums.py
@@ -0,0 +1,17 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+
+def test_background_type_info():
+    from easydiffraction.datablocks.experiment.categories.background.line_segment import (
+        LineSegmentBackground,
+    )
+    from easydiffraction.datablocks.experiment.categories.background.chebyshev import (
+        ChebyshevPolynomialBackground,
+    )
+
+    assert LineSegmentBackground.type_info.tag == 'line-segment'
+    assert LineSegmentBackground.type_info.description == 'Linear interpolation between points'
+
+    assert ChebyshevPolynomialBackground.type_info.tag == 'chebyshev'
+    assert ChebyshevPolynomialBackground.type_info.description == 'Chebyshev polynomial background'
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_factory.py
new file mode 100644
index 00000000..15c40a19
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_factory.py
@@ -0,0 +1,20 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+import pytest
+
+
+def test_background_factory_default_and_errors():
+    from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory
+
+    # Default via default_tag()
+    obj = BackgroundFactory.create(BackgroundFactory.default_tag())
+    assert obj.__class__.__name__.endswith('LineSegmentBackground')
+
+    # Explicit type by tag
+    obj2 = BackgroundFactory.create('chebyshev')
+    assert obj2.__class__.__name__.endswith('ChebyshevPolynomialBackground')
+
+    # Unsupported tag should raise ValueError
+    with pytest.raises(ValueError):
+        BackgroundFactory.create('nonexistent')
diff --git a/tests/unit/easydiffraction/experiments/categories/background/test_line_segment.py b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_line_segment.py
similarity index 86%
rename from tests/unit/easydiffraction/experiments/categories/background/test_line_segment.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/background/test_line_segment.py
index 23067f3c..e4e89605 100644
--- a/tests/unit/easydiffraction/experiments/categories/background/test_line_segment.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/background/test_line_segment.py
@@ -7,7 +7,7 @@
 def test_line_segment_background_calculate_and_cif():
     from types import SimpleNamespace
 
-    from easydiffraction.experiments.categories.background.line_segment import (
+    from easydiffraction.datablocks.experiment.categories.background.line_segment import (
         LineSegmentBackground,
     )
 
@@ -25,8 +25,8 @@ def test_line_segment_background_calculate_and_cif():
     assert np.allclose(mock_data._bkg, [0.0, 0.0, 0.0])
 
     # Add two points -> linear interpolation
-    bkg.add(id='1', x=0.0, y=0.0)
-    bkg.add(id='2', x=2.0, y=4.0)
+    bkg.create(id='1', x=0.0, y=0.0)
+    bkg.create(id='2', x=2.0, y=4.0)
     bkg._update()
     assert np.allclose(mock_data._bkg, [0.0, 2.0, 4.0])
 
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_pd.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_pd.py
new file mode 100644
index 00000000..eaa48808
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_pd.py
@@ -0,0 +1,145 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+
+
+def test_pd_cwl_data_point_defaults():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlDataPoint
+
+    pt = PdCwlDataPoint()
+    assert pt.point_id.value == '0'
+    assert pt.d_spacing.value == 0.0
+    assert pt.two_theta.value == 0.0
+    assert pt.intensity_meas.value == 0.0
+    assert pt.intensity_meas_su.value == 1.0
+    assert pt.intensity_calc.value == 0.0
+    assert pt.intensity_bkg.value == 0.0
+    assert pt.calc_status.value == 'incl'
+    assert pt._identity.category_code == 'pd_data'
+
+
+def test_pd_tof_data_point_defaults():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdTofDataPoint
+
+    pt = PdTofDataPoint()
+    assert pt.point_id.value == '0'
+    assert pt.d_spacing.value == 0.0
+    assert pt.time_of_flight.value == 0.0
+    assert pt.intensity_meas.value == 0.0
+    assert pt.intensity_meas_su.value == 1.0
+    assert pt.intensity_calc.value == 0.0
+    assert pt.intensity_bkg.value == 0.0
+    assert pt.calc_status.value == 'incl'
+    assert pt._identity.category_code == 'pd_data'
+
+
+def test_pd_cwl_data_collection_create_and_properties():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData
+
+    coll = PdCwlData()
+
+    # Create items with x-coordinate (two_theta) values
+    x_vals = np.array([10.0, 20.0, 30.0])
+    coll._create_items_set_xcoord_and_id(x_vals)
+
+    assert len(coll._items) == 3
+
+    # Check two_theta property (returns calc items only, all included)
+    np.testing.assert_array_almost_equal(coll.two_theta, x_vals)
+
+    # Check x is alias for two_theta
+    np.testing.assert_array_almost_equal(coll.x, coll.two_theta)
+
+    # Check unfiltered_x returns all items
+    np.testing.assert_array_almost_equal(coll.unfiltered_x, x_vals)
+
+    # Set and read measured intensities
+    meas = np.array([100.0, 200.0, 300.0])
+    coll._set_intensity_meas(meas)
+    np.testing.assert_array_almost_equal(coll.intensity_meas, meas)
+
+    # Set and read standard uncertainties
+    su = np.array([10.0, 20.0, 30.0])
+    coll._set_intensity_meas_su(su)
+    np.testing.assert_array_almost_equal(coll.intensity_meas_su, su)
+
+    # Check point IDs are set
+    assert coll._items[0].point_id.value == '1'
+    assert coll._items[1].point_id.value == '2'
+    assert coll._items[2].point_id.value == '3'
+
+
+def test_pd_tof_data_collection_create_and_properties():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdTofData
+
+    coll = PdTofData()
+
+    # Create items with x-coordinate (time_of_flight) values
+    x_vals = np.array([1000.0, 2000.0, 3000.0])
+    coll._create_items_set_xcoord_and_id(x_vals)
+
+    assert len(coll._items) == 3
+
+    # Check time_of_flight property
+    np.testing.assert_array_almost_equal(coll.time_of_flight, x_vals)
+
+    # Check x is alias for time_of_flight
+    np.testing.assert_array_almost_equal(coll.x, coll.time_of_flight)
+
+    # Check unfiltered_x returns all items
+    np.testing.assert_array_almost_equal(coll.unfiltered_x, x_vals)
+
+    # Check point IDs are set
+    assert coll._items[0].point_id.value == '1'
+    assert coll._items[2].point_id.value == '3'
+
+
+def test_pd_data_calc_status_exclusion():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData
+
+    coll = PdCwlData()
+
+    x_vals = np.array([10.0, 20.0, 30.0, 40.0])
+    coll._create_items_set_xcoord_and_id(x_vals)
+    coll._set_intensity_meas(np.array([100.0, 200.0, 300.0, 400.0]))
+    coll._set_intensity_meas_su(np.array([10.0, 20.0, 30.0, 40.0]))
+
+    # Exclude the second and third points
+    coll._set_calc_status([True, False, False, True])
+
+    # calc_status should reflect the change
+    assert np.array_equal(coll.calc_status, np.array(['incl', 'excl', 'excl', 'incl']))
+
+    # x should only return included points
+    np.testing.assert_array_almost_equal(coll.x, np.array([10.0, 40.0]))
+
+    # intensity_meas should only return included points
+    np.testing.assert_array_almost_equal(coll.intensity_meas, np.array([100.0, 400.0]))
+
+
+def test_pd_cwl_data_type_info():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData
+    from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdTofData
+
+    assert PdCwlData.type_info.tag == 'bragg-pd'
+    assert PdCwlData.type_info.description == 'Bragg powder CWL data'
+
+    assert PdTofData.type_info.tag == 'bragg-pd-tof'
+    assert PdTofData.type_info.description == 'Bragg powder TOF data'
+
+
+def test_pd_data_intensity_meas_su_zero_replacement():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData
+
+    coll = PdCwlData()
+    x_vals = np.array([10.0, 20.0, 30.0])
+    coll._create_items_set_xcoord_and_id(x_vals)
+
+    # Set su with near-zero values — those should be replaced by 1.0
+    coll._set_intensity_meas_su(np.array([0.0, 0.00001, 5.0]))
+    su = coll.intensity_meas_su
+    assert su[0] == 1.0  # replaced
+    assert su[1] == 1.0  # replaced
+    assert su[2] == 5.0  # kept
+
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py
new file mode 100644
index 00000000..06dd634d
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py
@@ -0,0 +1,93 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+
+
+def test_refln_data_point_defaults():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_sc import Refln
+
+    pt = Refln()
+    assert pt.id.value == '0'
+    assert pt.d_spacing.value == 0.0
+    assert pt.sin_theta_over_lambda.value == 0.0
+    assert pt.index_h.value == 0.0
+    assert pt.index_k.value == 0.0
+    assert pt.index_l.value == 0.0
+    assert pt.intensity_meas.value == 0.0
+    assert pt.intensity_meas_su.value == 0.0
+    assert pt.intensity_calc.value == 0.0
+    assert pt.wavelength.value == 0.0
+    assert pt._identity.category_code == 'refln'
+
+
+def test_refln_data_collection_create_and_properties():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData
+
+    coll = ReflnData()
+
+    # Create items with hkl
+    h = np.array([1.0, 2.0, 0.0])
+    k = np.array([0.0, 1.0, 0.0])
+    l = np.array([0.0, 0.0, 2.0])
+    coll._create_items_set_hkl_and_id(h, k, l)
+
+    assert len(coll._items) == 3
+
+    # Check hkl arrays
+    np.testing.assert_array_almost_equal(coll.index_h, h)
+    np.testing.assert_array_almost_equal(coll.index_k, k)
+    np.testing.assert_array_almost_equal(coll.index_l, l)
+
+    # Check IDs are sequential
+    assert coll._items[0].id.value == '1'
+    assert coll._items[1].id.value == '2'
+    assert coll._items[2].id.value == '3'
+
+    # Set and read measured intensities
+    meas = np.array([50.0, 100.0, 150.0])
+    coll._set_intensity_meas(meas)
+    np.testing.assert_array_almost_equal(coll.intensity_meas, meas)
+
+    # Set and read su
+    su = np.array([5.0, 10.0, 15.0])
+    coll._set_intensity_meas_su(su)
+    np.testing.assert_array_almost_equal(coll.intensity_meas_su, su)
+
+    # Set wavelength
+    wl = np.array([0.84, 0.84, 0.84])
+    coll._set_wavelength(wl)
+    np.testing.assert_array_almost_equal(coll.wavelength, wl)
+
+    # Set and read calculated intensities
+    calc = np.array([48.0, 102.0, 148.0])
+    coll._set_intensity_calc(calc)
+    np.testing.assert_array_almost_equal(coll.intensity_calc, calc)
+
+
+def test_refln_data_d_spacing_and_stol():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData
+
+    coll = ReflnData()
+    h = np.array([1.0, 2.0])
+    k = np.array([0.0, 0.0])
+    l = np.array([0.0, 0.0])
+    coll._create_items_set_hkl_and_id(h, k, l)
+
+    # Set d-spacing
+    d = np.array([5.43, 2.715])
+    coll._set_d_spacing(d)
+    np.testing.assert_array_almost_equal(coll.d_spacing, d)
+
+    # Set sin(theta)/lambda
+    stol = np.array([0.092, 0.184])
+    coll._set_sin_theta_over_lambda(stol)
+    np.testing.assert_array_almost_equal(coll.sin_theta_over_lambda, stol)
+
+
+def test_refln_data_type_info():
+    from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData
+
+    assert ReflnData.type_info.tag == 'bragg-sc'
+    assert ReflnData.type_info.description == 'Bragg single-crystal reflection data'
+
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py
new file mode 100644
index 00000000..18ecd5d0
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py
@@ -0,0 +1,89 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+import pytest
+
+
+def test_data_factory_default_and_errors():
+    from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory
+
+    # Ensure concrete classes are registered
+    from easydiffraction.datablocks.experiment.categories.data import bragg_pd  # noqa: F401
+    from easydiffraction.datablocks.experiment.categories.data import bragg_sc  # noqa: F401
+    from easydiffraction.datablocks.experiment.categories.data import total_pd  # noqa: F401
+
+    # Explicit type by tag
+    obj = DataFactory.create('bragg-pd')
+    assert obj.__class__.__name__ == 'PdCwlData'
+
+    # Explicit type by tag
+    obj2 = DataFactory.create('bragg-pd-tof')
+    assert obj2.__class__.__name__ == 'PdTofData'
+
+    obj3 = DataFactory.create('bragg-sc')
+    assert obj3.__class__.__name__ == 'ReflnData'
+
+    obj4 = DataFactory.create('total-pd')
+    assert obj4.__class__.__name__ == 'TotalData'
+
+    # Unsupported tag should raise ValueError
+    with pytest.raises(ValueError):
+        DataFactory.create('nonexistent')
+
+
+def test_data_factory_default_tag_resolution():
+    from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory
+    from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+    from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+    from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+
+    # Ensure concrete classes are registered
+    from easydiffraction.datablocks.experiment.categories.data import bragg_pd  # noqa: F401
+    from easydiffraction.datablocks.experiment.categories.data import bragg_sc  # noqa: F401
+    from easydiffraction.datablocks.experiment.categories.data import total_pd  # noqa: F401
+
+    # Context-dependent default: Bragg powder CWL
+    tag = DataFactory.default_tag(
+        sample_form=SampleFormEnum.POWDER,
+        scattering_type=ScatteringTypeEnum.BRAGG,
+        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH,
+    )
+    assert tag == 'bragg-pd'
+
+    # Context-dependent default: Bragg powder TOF
+    tag = DataFactory.default_tag(
+        sample_form=SampleFormEnum.POWDER,
+        scattering_type=ScatteringTypeEnum.BRAGG,
+        beam_mode=BeamModeEnum.TIME_OF_FLIGHT,
+    )
+    assert tag == 'bragg-pd-tof'
+
+    # Context-dependent default: total scattering
+    tag = DataFactory.default_tag(
+        sample_form=SampleFormEnum.POWDER,
+        scattering_type=ScatteringTypeEnum.TOTAL,
+    )
+    assert tag == 'total-pd'
+
+    # Context-dependent default: single crystal
+    tag = DataFactory.default_tag(
+        sample_form=SampleFormEnum.SINGLE_CRYSTAL,
+        scattering_type=ScatteringTypeEnum.BRAGG,
+    )
+    assert tag == 'bragg-sc'
+
+
+def test_data_factory_supported_tags():
+    from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory
+
+    # Ensure concrete classes are registered
+    from easydiffraction.datablocks.experiment.categories.data import bragg_pd  # noqa: F401
+    from easydiffraction.datablocks.experiment.categories.data import bragg_sc  # noqa: F401
+    from easydiffraction.datablocks.experiment.categories.data import total_pd  # noqa: F401
+
+    tags = DataFactory.supported_tags()
+    assert 'bragg-pd' in tags
+    assert 'bragg-pd-tof' in tags
+    assert 'bragg-sc' in tags
+    assert 'total-pd' in tags
+
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_total_pd.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_total_pd.py
new file mode 100644
index 00000000..90c85e3e
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_total_pd.py
@@ -0,0 +1,92 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+
+
+def test_total_data_point_defaults():
+    from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalDataPoint
+
+    pt = TotalDataPoint()
+    assert pt.point_id.value == '0'
+    assert pt.r.value == 0.0
+    assert pt.g_r_meas.value == 0.0
+    assert pt.g_r_meas_su.value == 0.0
+    assert pt.g_r_calc.value == 0.0
+    assert pt.calc_status.value == 'incl'
+    assert pt._identity.category_code == 'total_data'
+
+
+def test_total_data_collection_create_and_properties():
+    from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData
+
+    coll = TotalData()
+
+    # Create items with r values
+    r_vals = np.array([1.0, 2.0, 3.0, 4.0])
+    coll._create_items_set_xcoord_and_id(r_vals)
+
+    assert len(coll._items) == 4
+
+    # Check x property (returns calc items, all included)
+    np.testing.assert_array_almost_equal(coll.x, r_vals)
+
+    # Check unfiltered_x returns all items
+    np.testing.assert_array_almost_equal(coll.unfiltered_x, r_vals)
+
+    # Set and read measured G(r)
+    g_meas = np.array([0.1, 0.5, 0.3, 0.2])
+    coll._set_g_r_meas(g_meas)
+    np.testing.assert_array_almost_equal(coll.intensity_meas, g_meas)
+
+    # Set and read su
+    g_su = np.array([0.01, 0.05, 0.03, 0.02])
+    coll._set_g_r_meas_su(g_su)
+    np.testing.assert_array_almost_equal(coll.intensity_meas_su, g_su)
+
+    # Point IDs
+    assert coll._items[0].point_id.value == '1'
+    assert coll._items[3].point_id.value == '4'
+
+
+def test_total_data_calc_status_and_exclusion():
+    from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData
+
+    coll = TotalData()
+    r_vals = np.array([1.0, 2.0, 3.0, 4.0])
+    coll._create_items_set_xcoord_and_id(r_vals)
+    coll._set_g_r_meas(np.array([0.1, 0.5, 0.3, 0.2]))
+
+    # Exclude the second and third points
+    coll._set_calc_status([True, False, False, True])
+
+    assert np.array_equal(coll.calc_status, np.array(['incl', 'excl', 'excl', 'incl']))
+
+    # x should only return included points
+    np.testing.assert_array_almost_equal(coll.x, np.array([1.0, 4.0]))
+
+    # intensity_meas should only return included points
+    np.testing.assert_array_almost_equal(coll.intensity_meas, np.array([0.1, 0.2]))
+
+
+def test_total_data_intensity_bkg_always_zero():
+    from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData
+
+    coll = TotalData()
+    r_vals = np.array([1.0, 2.0, 3.0])
+    coll._create_items_set_xcoord_and_id(r_vals)
+
+    # Set calc G(r) so intensity_calc is non-empty
+    coll._set_g_r_calc(np.array([0.5, 0.6, 0.7]))
+
+    # Background should always be zeros
+    bkg = coll.intensity_bkg
+    np.testing.assert_array_almost_equal(bkg, np.zeros(3))
+
+
+def test_total_data_type_info():
+    from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData
+
+    assert TotalData.type_info.tag == 'total-pd'
+    assert TotalData.type_info.description == 'Total scattering (PDF) data'
+
diff --git a/tests/unit/easydiffraction/experiments/categories/instrument/test_base.py b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_base.py
similarity index 79%
rename from tests/unit/easydiffraction/experiments/categories/instrument/test_base.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_base.py
index 047d314b..70d36b8a 100644
--- a/tests/unit/easydiffraction/experiments/categories/instrument/test_base.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_base.py
@@ -2,7 +2,7 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 def test_instrument_base_sets_category_code():
-    from easydiffraction.experiments.categories.instrument.base import InstrumentBase
+    from easydiffraction.datablocks.experiment.categories.instrument.base import InstrumentBase
 
     class DummyInstr(InstrumentBase):
         def __init__(self):
diff --git a/tests/unit/easydiffraction/experiments/categories/instrument/test_cwl.py b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_cwl.py
similarity index 81%
rename from tests/unit/easydiffraction/experiments/categories/instrument/test_cwl.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_cwl.py
index 2dc7bd6c..205abd50 100644
--- a/tests/unit/easydiffraction/experiments/categories/instrument/test_cwl.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_cwl.py
@@ -1,7 +1,7 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.experiments.categories.instrument.cwl import CwlPdInstrument
+from easydiffraction.datablocks.experiment.categories.instrument.cwl import CwlPdInstrument
 
 
 def test_cwl_instrument_parameters_settable():
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_factory.py
new file mode 100644
index 00000000..f122f9be
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_factory.py
@@ -0,0 +1,35 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+import pytest
+
+
+def test_instrument_factory_default_and_errors():
+    try:
+        from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory
+        from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+        from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+    except ImportError as e:  # pragma: no cover - environment-specific circular import
+        pytest.skip(f'InstrumentFactory import triggers circular import in this context: {e}')
+        return
+
+    # By tag
+    inst = InstrumentFactory.create('cwl-pd')
+    assert inst.__class__.__name__ == 'CwlPdInstrument'
+
+    # By tag
+    inst2 = InstrumentFactory.create('cwl-pd')
+    assert inst2.__class__.__name__ == 'CwlPdInstrument'
+    inst3 = InstrumentFactory.create('tof-pd')
+    assert inst3.__class__.__name__ == 'TofPdInstrument'
+
+    # Context-dependent default
+    tag = InstrumentFactory.default_tag(
+        beam_mode=BeamModeEnum.TIME_OF_FLIGHT,
+        sample_form=SampleFormEnum.POWDER,
+    )
+    assert tag == 'tof-pd'
+
+    # Invalid tag
+    with pytest.raises(ValueError):
+        InstrumentFactory.create('nonexistent')
diff --git a/tests/unit/easydiffraction/experiments/categories/instrument/test_tof.py b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_tof.py
similarity index 94%
rename from tests/unit/easydiffraction/experiments/categories/instrument/test_tof.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_tof.py
index 339bcded..91c4f0d0 100644
--- a/tests/unit/easydiffraction/experiments/categories/instrument/test_tof.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_tof.py
@@ -5,7 +5,7 @@
 
 
 def test_tof_instrument_defaults_and_setters_and_parameters_and_cif():
-    from easydiffraction.experiments.categories.instrument.tof import TofPdInstrument
+    from easydiffraction.datablocks.experiment.categories.instrument.tof import TofPdInstrument
 
     inst = TofPdInstrument()
 
diff --git a/tests/unit/easydiffraction/experiments/categories/peak/test_base.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_base.py
similarity index 81%
rename from tests/unit/easydiffraction/experiments/categories/peak/test_base.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_base.py
index 737c7e17..ad3708dc 100644
--- a/tests/unit/easydiffraction/experiments/categories/peak/test_base.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_base.py
@@ -1,7 +1,7 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.experiments.categories.peak.base import PeakBase
+from easydiffraction.datablocks.experiment.categories.peak.base import PeakBase
 
 
 def test_peak_base_identity_code():
diff --git a/tests/unit/easydiffraction/experiments/categories/peak/test_cwl.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl.py
similarity index 79%
rename from tests/unit/easydiffraction/experiments/categories/peak/test_cwl.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl.py
index b3b16d4e..f4505287 100644
--- a/tests/unit/easydiffraction/experiments/categories/peak/test_cwl.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl.py
@@ -2,9 +2,9 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 def test_cwl_peak_classes_expose_expected_parameters_and_category():
-    from easydiffraction.experiments.categories.peak.cwl import CwlPseudoVoigt
-    from easydiffraction.experiments.categories.peak.cwl import CwlSplitPseudoVoigt
-    from easydiffraction.experiments.categories.peak.cwl import CwlThompsonCoxHastings
+    from easydiffraction.datablocks.experiment.categories.peak.cwl import CwlPseudoVoigt
+    from easydiffraction.datablocks.experiment.categories.peak.cwl import CwlSplitPseudoVoigt
+    from easydiffraction.datablocks.experiment.categories.peak.cwl import CwlThompsonCoxHastings
 
     pv = CwlPseudoVoigt()
     spv = CwlSplitPseudoVoigt()
diff --git a/tests/unit/easydiffraction/experiments/categories/peak/test_cwl_mixins.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl_mixins.py
similarity index 74%
rename from tests/unit/easydiffraction/experiments/categories/peak/test_cwl_mixins.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl_mixins.py
index ffde4deb..3bdc4466 100644
--- a/tests/unit/easydiffraction/experiments/categories/peak/test_cwl_mixins.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl_mixins.py
@@ -1,14 +1,14 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.experiments.categories.peak.cwl import CwlPseudoVoigt
-from easydiffraction.experiments.categories.peak.cwl import CwlSplitPseudoVoigt
-from easydiffraction.experiments.categories.peak.cwl import CwlThompsonCoxHastings
+from easydiffraction.datablocks.experiment.categories.peak.cwl import CwlPseudoVoigt
+from easydiffraction.datablocks.experiment.categories.peak.cwl import CwlSplitPseudoVoigt
+from easydiffraction.datablocks.experiment.categories.peak.cwl import CwlThompsonCoxHastings
 
 
 def test_cwl_pseudo_voigt_params_exist_and_settable():
     peak = CwlPseudoVoigt()
-    # Created by _add_constant_wavelength_broadening
+    # CwlBroadening parameters
     assert peak.broad_gauss_u.name == 'broad_gauss_u'
     peak.broad_gauss_u = 0.123
     assert peak.broad_gauss_u.value == 0.123
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_factory.py
new file mode 100644
index 00000000..6ff155ac
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_factory.py
@@ -0,0 +1,54 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+import pytest
+
+
+def test_peak_factory_default_and_combinations_and_errors():
+    from easydiffraction.datablocks.experiment.categories.peak.factory import PeakFactory
+    from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+    from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+
+    # Explicit valid combos by tag
+    p = PeakFactory.create('pseudo-voigt')
+    assert p._identity.category_code == 'peak'
+
+    # Explicit valid combos by tag
+    p1 = PeakFactory.create('pseudo-voigt')
+    assert p1.__class__.__name__ == 'CwlPseudoVoigt'
+
+    p2 = PeakFactory.create('pseudo-voigt * ikeda-carpenter')
+    assert p2.__class__.__name__ == 'TofPseudoVoigtIkedaCarpenter'
+
+    p3 = PeakFactory.create('gaussian-damped-sinc')
+    assert p3.__class__.__name__ == 'TotalGaussianDampedSinc'
+
+    # Context-dependent defaults
+    tag_bragg_cwl = PeakFactory.default_tag(
+        scattering_type=ScatteringTypeEnum.BRAGG,
+        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH,
+    )
+    assert tag_bragg_cwl == 'pseudo-voigt'
+
+    tag_bragg_tof = PeakFactory.default_tag(
+        scattering_type=ScatteringTypeEnum.BRAGG,
+        beam_mode=BeamModeEnum.TIME_OF_FLIGHT,
+    )
+    assert tag_bragg_tof == 'pseudo-voigt * ikeda-carpenter'
+
+    tag_total = PeakFactory.default_tag(
+        scattering_type=ScatteringTypeEnum.TOTAL,
+    )
+    assert tag_total == 'gaussian-damped-sinc'
+
+    # supported_for filtering
+    cwl_profiles = PeakFactory.supported_for(
+        scattering_type=ScatteringTypeEnum.BRAGG,
+        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH,
+    )
+    assert len(cwl_profiles) == 3
+    assert all(k.type_info.tag for k in cwl_profiles)
+
+    # Invalid tag
+    with pytest.raises(ValueError):
+        PeakFactory.create('nonexistent-profile')
diff --git a/tests/unit/easydiffraction/experiments/categories/peak/test_tof.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_tof.py
similarity index 72%
rename from tests/unit/easydiffraction/experiments/categories/peak/test_tof.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_tof.py
index 0ace3beb..fde6d062 100644
--- a/tests/unit/easydiffraction/experiments/categories/peak/test_tof.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_tof.py
@@ -1,9 +1,9 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.experiments.categories.peak.tof import TofPseudoVoigt
-from easydiffraction.experiments.categories.peak.tof import TofPseudoVoigtBackToBack
-from easydiffraction.experiments.categories.peak.tof import TofPseudoVoigtIkedaCarpenter
+from easydiffraction.datablocks.experiment.categories.peak.tof import TofPseudoVoigt
+from easydiffraction.datablocks.experiment.categories.peak.tof import TofPseudoVoigtBackToBack
+from easydiffraction.datablocks.experiment.categories.peak.tof import TofPseudoVoigtIkedaCarpenter
 
 
 def test_tof_pseudo_voigt_has_broadening_params():
diff --git a/tests/unit/easydiffraction/experiments/categories/peak/test_tof_mixins.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_tof_mixins.py
similarity index 71%
rename from tests/unit/easydiffraction/experiments/categories/peak/test_tof_mixins.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_tof_mixins.py
index c54fdc93..ae39c99c 100644
--- a/tests/unit/easydiffraction/experiments/categories/peak/test_tof_mixins.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_tof_mixins.py
@@ -5,15 +5,13 @@
 
 
 def test_tof_broadening_and_asymmetry_mixins():
-    from easydiffraction.experiments.categories.peak.base import PeakBase
-    from easydiffraction.experiments.categories.peak.tof_mixins import IkedaCarpenterAsymmetryMixin
-    from easydiffraction.experiments.categories.peak.tof_mixins import TofBroadeningMixin
+    from easydiffraction.datablocks.experiment.categories.peak.base import PeakBase
+    from easydiffraction.datablocks.experiment.categories.peak.tof_mixins import IkedaCarpenterAsymmetryMixin
+    from easydiffraction.datablocks.experiment.categories.peak.tof_mixins import TofBroadeningMixin
 
-    class TofPeak(PeakBase, TofBroadeningMixin, IkedaCarpenterAsymmetryMixin):
+    class TofPeak(PeakBase, TofBroadeningMixin, IkedaCarpenterAsymmetryMixin,):
         def __init__(self):
             super().__init__()
-            self._add_time_of_flight_broadening()
-            self._add_ikeda_carpenter_asymmetry()
 
     p = TofPeak()
     names = {param.name for param in p.parameters}
diff --git a/tests/unit/easydiffraction/experiments/categories/peak/test_total.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_total.py
similarity index 91%
rename from tests/unit/easydiffraction/experiments/categories/peak/test_total.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_total.py
index 7529d875..c49d1cf7 100644
--- a/tests/unit/easydiffraction/experiments/categories/peak/test_total.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_total.py
@@ -5,7 +5,7 @@
 
 
 def test_total_gaussian_damped_sinc_parameters_and_setters():
-    from easydiffraction.experiments.categories.peak.total import TotalGaussianDampedSinc
+    from easydiffraction.datablocks.experiment.categories.peak.total import TotalGaussianDampedSinc
 
     p = TotalGaussianDampedSinc()
     assert p._identity.category_code == 'peak'
diff --git a/tests/unit/easydiffraction/experiments/categories/peak/test_total_mixins.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_total_mixins.py
similarity index 79%
rename from tests/unit/easydiffraction/experiments/categories/peak/test_total_mixins.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_total_mixins.py
index 475ab781..0979dcb9 100644
--- a/tests/unit/easydiffraction/experiments/categories/peak/test_total_mixins.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_total_mixins.py
@@ -1,7 +1,7 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.experiments.categories.peak.total import TotalGaussianDampedSinc
+from easydiffraction.datablocks.experiment.categories.peak.total import TotalGaussianDampedSinc
 
 
 def test_total_gaussian_damped_sinc_params():
diff --git a/tests/unit/easydiffraction/experiments/categories/test_excluded_regions.py b/tests/unit/easydiffraction/datablocks/experiment/categories/test_excluded_regions.py
similarity index 92%
rename from tests/unit/easydiffraction/experiments/categories/test_excluded_regions.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/test_excluded_regions.py
index bf363e7f..8026740b 100644
--- a/tests/unit/easydiffraction/experiments/categories/test_excluded_regions.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/test_excluded_regions.py
@@ -7,7 +7,7 @@
 def test_excluded_regions_add_updates_datastore_and_cif():
     from types import SimpleNamespace
 
-    from easydiffraction.experiments.categories.excluded_regions import ExcludedRegions
+    from easydiffraction.datablocks.experiment.categories.excluded_regions import ExcludedRegions
 
     # Minimal fake datastore
     full_x = np.array([0.0, 1.0, 2.0, 3.0])
@@ -38,7 +38,7 @@ def set_calc_status(status):
     # stitch in a parent with data
     object.__setattr__(coll, '_parent', SimpleNamespace(data=ds))
 
-    coll.add(start=1.0, end=2.0)
+    coll.create(start=1.0, end=2.0)
     # Call _update() to apply exclusions
     coll._update()
 
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/test_experiment_type.py b/tests/unit/easydiffraction/datablocks/experiment/categories/test_experiment_type.py
new file mode 100644
index 00000000..169510ab
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/test_experiment_type.py
@@ -0,0 +1,36 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+def test_module_import():
+    import easydiffraction.datablocks.experiment.categories.experiment_type as MUT
+
+    expected_module_name = 'easydiffraction.datablocks.experiment.categories.experiment_type'
+    actual_module_name = MUT.__name__
+    assert expected_module_name == actual_module_name
+
+
+def test_experiment_type_properties_and_validation(monkeypatch):
+    from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
+    from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+    from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum
+    from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+    from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+    from easydiffraction.utils.logging import log
+
+    log.configure(reaction=log.Reaction.WARN)
+
+    et = ExperimentType()
+    et._set_sample_form(SampleFormEnum.POWDER.value)
+    et._set_beam_mode(BeamModeEnum.CONSTANT_WAVELENGTH.value)
+    et._set_radiation_probe(RadiationProbeEnum.NEUTRON.value)
+    et._set_scattering_type(ScatteringTypeEnum.BRAGG.value)
+
+    # getters nominal
+    assert et.sample_form.value == SampleFormEnum.POWDER.value
+    assert et.beam_mode.value == BeamModeEnum.CONSTANT_WAVELENGTH.value
+    assert et.radiation_probe.value == RadiationProbeEnum.NEUTRON.value
+    assert et.scattering_type.value == ScatteringTypeEnum.BRAGG.value
+
+    # public setters are blocked (read-only properties via GuardedBase)
+    et.sample_form = 'single crystal'
+    assert et.sample_form.value == SampleFormEnum.POWDER.value
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/test_extinction.py b/tests/unit/easydiffraction/datablocks/experiment/categories/test_extinction.py
new file mode 100644
index 00000000..5287b488
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/test_extinction.py
@@ -0,0 +1,78 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+
+def test_module_import():
+    import easydiffraction.datablocks.experiment.categories.extinction.shelx as MUT
+
+    expected_module_name = 'easydiffraction.datablocks.experiment.categories.extinction.shelx'
+    actual_module_name = MUT.__name__
+    assert expected_module_name == actual_module_name
+
+
+def test_extinction_defaults():
+    from easydiffraction.datablocks.experiment.categories.extinction.shelx import ShelxExtinction
+
+    ext = ShelxExtinction()
+    assert ext.mosaicity.value == 1.0
+    assert ext.radius.value == 1.0
+    assert ext._identity.category_code == 'extinction'
+
+
+def test_extinction_property_setters():
+    from easydiffraction.datablocks.experiment.categories.extinction.shelx import ShelxExtinction
+
+    ext = ShelxExtinction()
+
+    ext.mosaicity = 0.5
+    assert ext.mosaicity.value == 0.5
+
+    ext.radius = 10.0
+    assert ext.radius.value == 10.0
+
+
+def test_extinction_cif_handler_names():
+    from easydiffraction.datablocks.experiment.categories.extinction.shelx import ShelxExtinction
+
+    ext = ShelxExtinction()
+
+    mosaicity_cif_names = ext._mosaicity._cif_handler.names
+    assert '_extinction.mosaicity' in mosaicity_cif_names
+
+    radius_cif_names = ext._radius._cif_handler.names
+    assert '_extinction.radius' in radius_cif_names
+
+
+def test_extinction_type_info():
+    from easydiffraction.datablocks.experiment.categories.extinction.shelx import ShelxExtinction
+
+    assert ShelxExtinction.type_info.tag == 'shelx'
+    assert ShelxExtinction.type_info.description != ''
+
+
+def test_extinction_factory_registration():
+    from easydiffraction.datablocks.experiment.categories.extinction.factory import (
+        ExtinctionFactory,
+    )
+
+    assert 'shelx' in ExtinctionFactory.supported_tags()
+
+
+def test_extinction_factory_create():
+    from easydiffraction.datablocks.experiment.categories.extinction.factory import (
+        ExtinctionFactory,
+    )
+    from easydiffraction.datablocks.experiment.categories.extinction.shelx import ShelxExtinction
+
+    ext = ExtinctionFactory.create('shelx')
+    assert isinstance(ext, ShelxExtinction)
+
+
+def test_extinction_factory_default_tag():
+    from easydiffraction.datablocks.experiment.categories.extinction.factory import (
+        ExtinctionFactory,
+    )
+
+    assert ExtinctionFactory.default_tag() == 'shelx'
+
+
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/test_linked_crystal.py b/tests/unit/easydiffraction/datablocks/experiment/categories/test_linked_crystal.py
new file mode 100644
index 00000000..06035598
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/test_linked_crystal.py
@@ -0,0 +1,90 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+
+def test_module_import():
+    import easydiffraction.datablocks.experiment.categories.linked_crystal.default as MUT
+
+    expected_module_name = (
+        'easydiffraction.datablocks.experiment.categories.linked_crystal.default'
+    )
+    actual_module_name = MUT.__name__
+    assert expected_module_name == actual_module_name
+
+
+def test_linked_crystal_defaults():
+    from easydiffraction.datablocks.experiment.categories.linked_crystal.default import (
+        LinkedCrystal,
+    )
+
+    lc = LinkedCrystal()
+    assert lc.id.value == 'Si'
+    assert lc.scale.value == 1.0
+    assert lc._identity.category_code == 'linked_crystal'
+
+
+def test_linked_crystal_property_setters():
+    from easydiffraction.datablocks.experiment.categories.linked_crystal.default import (
+        LinkedCrystal,
+    )
+
+    lc = LinkedCrystal()
+
+    lc.id = 'Ge'
+    assert lc.id.value == 'Ge'
+
+    lc.scale = 2.5
+    assert lc.scale.value == 2.5
+
+
+def test_linked_crystal_cif_handler_names():
+    from easydiffraction.datablocks.experiment.categories.linked_crystal.default import (
+        LinkedCrystal,
+    )
+
+    lc = LinkedCrystal()
+
+    id_cif_names = lc._id._cif_handler.names
+    assert '_sc_crystal_block.id' in id_cif_names
+
+    scale_cif_names = lc._scale._cif_handler.names
+    assert '_sc_crystal_block.scale' in scale_cif_names
+
+
+def test_linked_crystal_type_info():
+    from easydiffraction.datablocks.experiment.categories.linked_crystal.default import (
+        LinkedCrystal,
+    )
+
+    assert LinkedCrystal.type_info.tag == 'default'
+    assert LinkedCrystal.type_info.description != ''
+
+
+def test_linked_crystal_factory_registration():
+    from easydiffraction.datablocks.experiment.categories.linked_crystal.factory import (
+        LinkedCrystalFactory,
+    )
+
+    assert 'default' in LinkedCrystalFactory.supported_tags()
+
+
+def test_linked_crystal_factory_create():
+    from easydiffraction.datablocks.experiment.categories.linked_crystal.default import (
+        LinkedCrystal,
+    )
+    from easydiffraction.datablocks.experiment.categories.linked_crystal.factory import (
+        LinkedCrystalFactory,
+    )
+
+    lc = LinkedCrystalFactory.create('default')
+    assert isinstance(lc, LinkedCrystal)
+
+
+def test_linked_crystal_factory_default_tag():
+    from easydiffraction.datablocks.experiment.categories.linked_crystal.factory import (
+        LinkedCrystalFactory,
+    )
+
+    assert LinkedCrystalFactory.default_tag() == 'default'
+
+
diff --git a/tests/unit/easydiffraction/experiments/categories/test_linked_phases.py b/tests/unit/easydiffraction/datablocks/experiment/categories/test_linked_phases.py
similarity index 60%
rename from tests/unit/easydiffraction/experiments/categories/test_linked_phases.py
rename to tests/unit/easydiffraction/datablocks/experiment/categories/test_linked_phases.py
index 2b7de53c..263586f8 100644
--- a/tests/unit/easydiffraction/experiments/categories/test_linked_phases.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/test_linked_phases.py
@@ -2,14 +2,16 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 def test_linked_phases_add_and_cif_headers():
-    from easydiffraction.experiments.categories.linked_phases import LinkedPhase
-    from easydiffraction.experiments.categories.linked_phases import LinkedPhases
+    from easydiffraction.datablocks.experiment.categories.linked_phases import LinkedPhase
+    from easydiffraction.datablocks.experiment.categories.linked_phases import LinkedPhases
 
-    lp = LinkedPhase(id='Si', scale=2.0)
+    lp = LinkedPhase()
+    lp.id = 'Si'
+    lp.scale = 2.0
     assert lp.id.value == 'Si' and lp.scale.value == 2.0
 
     coll = LinkedPhases()
-    coll.add(id='Si', scale=2.0)
+    coll.create(id='Si', scale=2.0)
 
     # CIF loop header presence
     cif = coll.as_cif
diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py
new file mode 100644
index 00000000..6a6766ed
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py
@@ -0,0 +1,37 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+def test_module_import():
+    import easydiffraction.datablocks.experiment.item.base as MUT
+
+    expected_module_name = 'easydiffraction.datablocks.experiment.item.base'
+    actual_module_name = MUT.__name__
+    assert expected_module_name == actual_module_name
+
+
+def test_pd_experiment_peak_profile_type_switch(capsys):
+    from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
+    from easydiffraction.datablocks.experiment.item.base import PdExperimentBase
+    from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+    from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum
+    from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+    from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+
+    class ConcretePd(PdExperimentBase):
+        def _load_ascii_data_to_experiment(self, data_path: str) -> None:
+            pass
+
+    et = ExperimentType()
+    et._set_sample_form(SampleFormEnum.POWDER.value)
+    et._set_beam_mode(BeamModeEnum.CONSTANT_WAVELENGTH.value)
+    et._set_radiation_probe(RadiationProbeEnum.NEUTRON.value)
+    et._set_scattering_type(ScatteringTypeEnum.BRAGG.value)
+
+    ex = ConcretePd(name='ex1', type=et)
+    # valid switch using tag string
+    ex.peak_profile_type = 'pseudo-voigt'
+    assert ex.peak_profile_type == 'pseudo-voigt'
+    # invalid string should warn and keep previous
+    ex.peak_profile_type = 'non-existent'
+    captured = capsys.readouterr().out
+    assert 'Unsupported' in captured or 'Unknown' in captured
diff --git a/tests/unit/easydiffraction/experiments/experiment/test_bragg_pd.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py
similarity index 65%
rename from tests/unit/easydiffraction/experiments/experiment/test_bragg_pd.py
rename to tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py
index a0dfa3f2..8b164ed0 100644
--- a/tests/unit/easydiffraction/experiments/experiment/test_bragg_pd.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py
@@ -4,36 +4,38 @@
 import numpy as np
 import pytest
 
-from easydiffraction.experiments.categories.background.enums import BackgroundTypeEnum
-from easydiffraction.experiments.categories.experiment_type import ExperimentType
-from easydiffraction.experiments.experiment.bragg_pd import BraggPdExperiment
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import RadiationProbeEnum
-from easydiffraction.experiments.experiment.enums import SampleFormEnum
-from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
+from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory
+from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
+from easydiffraction.datablocks.experiment.item.bragg_pd import BraggPdExperiment
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 
 
 def _mk_type_powder_cwl_bragg():
-    return ExperimentType(
-        sample_form=SampleFormEnum.POWDER.value,
-        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH.value,
-        radiation_probe=RadiationProbeEnum.NEUTRON.value,
-        scattering_type=ScatteringTypeEnum.BRAGG.value,
-    )
+    et = ExperimentType()
+    et._set_sample_form(SampleFormEnum.POWDER.value)
+    et._set_beam_mode(BeamModeEnum.CONSTANT_WAVELENGTH.value)
+    et._set_radiation_probe(RadiationProbeEnum.NEUTRON.value)
+    et._set_scattering_type(ScatteringTypeEnum.BRAGG.value)
+    return et
+
+
 
 
 def test_background_defaults_and_change():
     expt = BraggPdExperiment(name='e1', type=_mk_type_powder_cwl_bragg())
     # default background type
-    assert expt.background_type == BackgroundTypeEnum.default()
+    assert expt.background_type == BackgroundFactory.default_tag()
 
     # change to a supported type
-    expt.background_type = BackgroundTypeEnum.CHEBYSHEV
-    assert expt.background_type == BackgroundTypeEnum.CHEBYSHEV
+    expt.background_type = 'chebyshev'
+    assert expt.background_type == 'chebyshev'
 
     # unknown type keeps previous type and prints warnings (no raise)
     expt.background_type = 'not-a-type'  # invalid string
-    assert expt.background_type == BackgroundTypeEnum.CHEBYSHEV
+    assert expt.background_type == 'chebyshev'
 
 
 def test_load_ascii_data_rounds_and_defaults_sy(tmp_path: pytest.TempPathFactory):
diff --git a/tests/unit/easydiffraction/experiments/experiment/test_bragg_sc.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc.py
similarity index 51%
rename from tests/unit/easydiffraction/experiments/experiment/test_bragg_sc.py
rename to tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc.py
index af9a65f7..c01a5ee2 100644
--- a/tests/unit/easydiffraction/experiments/experiment/test_bragg_sc.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc.py
@@ -3,22 +3,23 @@
 
 import pytest
 
-from easydiffraction.experiments.categories.experiment_type import ExperimentType
-from easydiffraction.experiments.experiment.bragg_sc import CwlScExperiment
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import RadiationProbeEnum
-from easydiffraction.experiments.experiment.enums import SampleFormEnum
-from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
+from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
+from easydiffraction.datablocks.experiment.item.bragg_sc import CwlScExperiment
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 from easydiffraction.utils.logging import Logger
 
 
 def _mk_type_sc_bragg():
-    return ExperimentType(
-        sample_form=SampleFormEnum.SINGLE_CRYSTAL.value,
-        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH.value,
-        radiation_probe=RadiationProbeEnum.NEUTRON.value,
-        scattering_type=ScatteringTypeEnum.BRAGG.value,
-    )
+    et = ExperimentType()
+    et._set_sample_form(SampleFormEnum.SINGLE_CRYSTAL.value)
+    et._set_beam_mode(BeamModeEnum.CONSTANT_WAVELENGTH.value)
+    et._set_radiation_probe(RadiationProbeEnum.NEUTRON.value)
+    et._set_scattering_type(ScatteringTypeEnum.BRAGG.value)
+    return et
+
 
 
 class _ConcreteCwlSc(CwlScExperiment):
diff --git a/tests/unit/easydiffraction/experiments/experiment/test_enums.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_enums.py
similarity index 73%
rename from tests/unit/easydiffraction/experiments/experiment/test_enums.py
rename to tests/unit/easydiffraction/datablocks/experiment/item/test_enums.py
index 2df2dbb5..dff44c0f 100644
--- a/tests/unit/easydiffraction/experiments/experiment/test_enums.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_enums.py
@@ -2,15 +2,15 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 def test_module_import():
-    import easydiffraction.experiments.experiment.enums as MUT
+    import easydiffraction.datablocks.experiment.item.enums as MUT
 
-    expected_module_name = 'easydiffraction.experiments.experiment.enums'
+    expected_module_name = 'easydiffraction.datablocks.experiment.item.enums'
     actual_module_name = MUT.__name__
     assert expected_module_name == actual_module_name
 
 
 def test_default_enums_consistency():
-    import easydiffraction.experiments.experiment.enums as MUT
+    import easydiffraction.datablocks.experiment.item.enums as MUT
 
     assert MUT.SampleFormEnum.default() in list(MUT.SampleFormEnum)
     assert MUT.ScatteringTypeEnum.default() in list(MUT.ScatteringTypeEnum)
diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_factory.py
new file mode 100644
index 00000000..8dac91b6
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_factory.py
@@ -0,0 +1,28 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+def test_module_import():
+    import easydiffraction.datablocks.experiment.item.factory as MUT
+
+    expected_module_name = 'easydiffraction.datablocks.experiment.item.factory'
+    actual_module_name = MUT.__name__
+    assert expected_module_name == actual_module_name
+
+
+def test_experiment_factory_from_scratch():
+    import easydiffraction.datablocks.experiment.item.factory as EF
+    from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+    from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum
+    from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+    from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+
+    ex = EF.ExperimentFactory.from_scratch(
+        name='ex1',
+        sample_form=SampleFormEnum.POWDER.value,
+        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH.value,
+        radiation_probe=RadiationProbeEnum.NEUTRON.value,
+        scattering_type=ScatteringTypeEnum.BRAGG.value,
+    )
+    # Instance should be created (BraggPdExperiment)
+    assert hasattr(ex, 'type') and ex.type.sample_form.value == SampleFormEnum.POWDER.value
+
diff --git a/tests/unit/easydiffraction/experiments/experiment/test_total_pd.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_total_pd.py
similarity index 61%
rename from tests/unit/easydiffraction/experiments/experiment/test_total_pd.py
rename to tests/unit/easydiffraction/datablocks/experiment/item/test_total_pd.py
index 8f10c692..78d40afc 100644
--- a/tests/unit/easydiffraction/experiments/experiment/test_total_pd.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_total_pd.py
@@ -4,21 +4,21 @@
 import numpy as np
 import pytest
 
-from easydiffraction.experiments.categories.experiment_type import ExperimentType
-from easydiffraction.experiments.experiment.enums import BeamModeEnum
-from easydiffraction.experiments.experiment.enums import RadiationProbeEnum
-from easydiffraction.experiments.experiment.enums import SampleFormEnum
-from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-from easydiffraction.experiments.experiment.total_pd import TotalPdExperiment
+from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
+from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum
+from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+from easydiffraction.datablocks.experiment.item.total_pd import TotalPdExperiment
 
 
 def _mk_type_powder_total():
-    return ExperimentType(
-        sample_form=SampleFormEnum.POWDER.value,
-        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH.value,
-        radiation_probe=RadiationProbeEnum.NEUTRON.value,
-        scattering_type=ScatteringTypeEnum.TOTAL.value,
-    )
+    et = ExperimentType()
+    et._set_sample_form(SampleFormEnum.POWDER.value)
+    et._set_beam_mode(BeamModeEnum.CONSTANT_WAVELENGTH.value)
+    et._set_radiation_probe(RadiationProbeEnum.NEUTRON.value)
+    et._set_scattering_type(ScatteringTypeEnum.TOTAL.value)
+    return et
 
 
 def test_load_ascii_data_pdf(tmp_path: pytest.TempPathFactory):
diff --git a/tests/unit/easydiffraction/experiments/test_experiments.py b/tests/unit/easydiffraction/datablocks/experiment/test_collection.py
similarity index 74%
rename from tests/unit/easydiffraction/experiments/test_experiments.py
rename to tests/unit/easydiffraction/datablocks/experiment/test_collection.py
index 89dd874e..5165f141 100644
--- a/tests/unit/easydiffraction/experiments/test_experiments.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/test_collection.py
@@ -2,16 +2,16 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 def test_module_import():
-    import easydiffraction.experiments.experiments as MUT
+    import easydiffraction.datablocks.experiment.collection as MUT
 
-    expected_module_name = 'easydiffraction.experiments.experiments'
+    expected_module_name = 'easydiffraction.datablocks.experiment.collection'
     actual_module_name = MUT.__name__
     assert expected_module_name == actual_module_name
 
 
 def test_experiments_show_and_remove(monkeypatch, capsys):
-    from easydiffraction.experiments.experiment.base import ExperimentBase
-    from easydiffraction.experiments.experiments import Experiments
+    from easydiffraction.datablocks.experiment.item.base import ExperimentBase
+    from easydiffraction.datablocks.experiment.collection import Experiments
 
     class DummyType:
         def __init__(self):
@@ -26,8 +26,8 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None:
             pass
 
     exps = Experiments()
-    exps.add(experiment=DummyExp('a'))
-    exps.add(experiment=DummyExp('b'))
+    exps.add(DummyExp('a'))
+    exps.add(DummyExp('b'))
     exps.show_names()
     out = capsys.readouterr().out
     assert 'Defined experiments' in out
diff --git a/tests/unit/easydiffraction/sample_models/categories/test_space_group.py b/tests/unit/easydiffraction/datablocks/structure/categories/test_space_group.py
similarity index 88%
rename from tests/unit/easydiffraction/sample_models/categories/test_space_group.py
rename to tests/unit/easydiffraction/datablocks/structure/categories/test_space_group.py
index d1a0238c..89786b9e 100644
--- a/tests/unit/easydiffraction/sample_models/categories/test_space_group.py
+++ b/tests/unit/easydiffraction/datablocks/structure/categories/test_space_group.py
@@ -1,7 +1,7 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.sample_models.categories.space_group import SpaceGroup
+from easydiffraction.datablocks.structure.categories.space_group import SpaceGroup
 
 
 def test_space_group_name_updates_it_code():
diff --git a/tests/unit/easydiffraction/sample_models/sample_model/test_base.py b/tests/unit/easydiffraction/datablocks/structure/item/test_base.py
similarity index 50%
rename from tests/unit/easydiffraction/sample_models/sample_model/test_base.py
rename to tests/unit/easydiffraction/datablocks/structure/item/test_base.py
index 61c4b3db..33bb878b 100644
--- a/tests/unit/easydiffraction/sample_models/sample_model/test_base.py
+++ b/tests/unit/easydiffraction/datablocks/structure/item/test_base.py
@@ -1,12 +1,12 @@
 # SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
-from easydiffraction.sample_models.sample_model.base import SampleModelBase
+from easydiffraction.datablocks.structure.item.base import Structure
 
 
-def test_sample_model_base_str_and_properties():
-    m = SampleModelBase(name='m1')
+def test_structure_base_str_and_properties():
+    m = Structure(name='m1')
     m.name = 'm2'
     assert m.name == 'm2'
     s = str(m)
-    assert 'SampleModelBase' in s or '<' in s
+    assert 'Structure' in s or '<' in s
diff --git a/tests/unit/easydiffraction/datablocks/structure/item/test_factory.py b/tests/unit/easydiffraction/datablocks/structure/item/test_factory.py
new file mode 100644
index 00000000..d1b50776
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/structure/item/test_factory.py
@@ -0,0 +1,9 @@
+# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.structure.item.factory import StructureFactory
+
+
+def test_from_scratch():
+    m = StructureFactory.from_scratch(name='abc')
+    assert m.name == 'abc'
diff --git a/tests/unit/easydiffraction/sample_models/test_sample_models.py b/tests/unit/easydiffraction/datablocks/structure/test_collection.py
similarity index 70%
rename from tests/unit/easydiffraction/sample_models/test_sample_models.py
rename to tests/unit/easydiffraction/datablocks/structure/test_collection.py
index eed0ea84..9955a1e3 100644
--- a/tests/unit/easydiffraction/sample_models/test_sample_models.py
+++ b/tests/unit/easydiffraction/datablocks/structure/test_collection.py
@@ -3,4 +3,4 @@
 
 import pytest
 
-from easydiffraction.sample_models.sample_models import SampleModels
+from easydiffraction.datablocks.structure.collection import Structures
diff --git a/tests/unit/easydiffraction/display/plotters/test_base.py b/tests/unit/easydiffraction/display/plotters/test_base.py
index 1b81f7c8..d1c2d561 100644
--- a/tests/unit/easydiffraction/display/plotters/test_base.py
+++ b/tests/unit/easydiffraction/display/plotters/test_base.py
@@ -32,8 +32,8 @@ def test_default_engine_switches_with_notebook(monkeypatch):
 
 def test_default_axes_labels_keys_present():
     import easydiffraction.display.plotters.base as pb
-    from easydiffraction.experiments.experiment.enums import SampleFormEnum
-    from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
+    from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+    from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
 
     # Powder Bragg
     assert (SampleFormEnum.POWDER, ScatteringTypeEnum.BRAGG, pb.XAxisType.TWO_THETA) in pb.DEFAULT_AXES_LABELS
diff --git a/tests/unit/easydiffraction/display/test_plotting.py b/tests/unit/easydiffraction/display/test_plotting.py
index a27c7e47..15caf344 100644
--- a/tests/unit/easydiffraction/display/test_plotting.py
+++ b/tests/unit/easydiffraction/display/test_plotting.py
@@ -53,9 +53,9 @@ def test_plotter_factory_supported_and_unsupported():
 
 
 def test_plotter_error_paths_and_filtering(capsys):
-    from easydiffraction.experiments.experiment.enums import BeamModeEnum
-    from easydiffraction.experiments.experiment.enums import SampleFormEnum
-    from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
+    from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+    from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+    from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
     from easydiffraction.display.plotting import Plotter
 
     class Ptn:
@@ -113,9 +113,9 @@ def test_plotter_routes_to_ascii_plotter(monkeypatch):
     import numpy as np
 
     import easydiffraction.display.plotters.ascii as ascii_mod
-    from easydiffraction.experiments.experiment.enums import BeamModeEnum
-    from easydiffraction.experiments.experiment.enums import SampleFormEnum
-    from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
+    from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum
+    from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
+    from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
     from easydiffraction.display.plotting import Plotter
 
     called = {}
diff --git a/tests/unit/easydiffraction/experiments/categories/background/test_enums.py b/tests/unit/easydiffraction/experiments/categories/background/test_enums.py
deleted file mode 100644
index 4a64fb29..00000000
--- a/tests/unit/easydiffraction/experiments/categories/background/test_enums.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-def test_background_enum_default_and_descriptions():
-    import easydiffraction.experiments.categories.background.enums as MUT
-
-    assert MUT.BackgroundTypeEnum.default() == MUT.BackgroundTypeEnum.LINE_SEGMENT
-    assert (
-        MUT.BackgroundTypeEnum.LINE_SEGMENT.description() == 'Linear interpolation between points'
-    )
-    assert MUT.BackgroundTypeEnum.CHEBYSHEV.description() == 'Chebyshev polynomial background'
diff --git a/tests/unit/easydiffraction/experiments/categories/background/test_factory.py b/tests/unit/easydiffraction/experiments/categories/background/test_factory.py
deleted file mode 100644
index 57308f95..00000000
--- a/tests/unit/easydiffraction/experiments/categories/background/test_factory.py
+++ /dev/null
@@ -1,24 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-import pytest
-
-
-def test_background_factory_default_and_errors():
-    from easydiffraction.experiments.categories.background.enums import BackgroundTypeEnum
-    from easydiffraction.experiments.categories.background.factory import BackgroundFactory
-
-    # Default should produce a LineSegmentBackground
-    obj = BackgroundFactory.create()
-    assert obj.__class__.__name__.endswith('LineSegmentBackground')
-
-    # Explicit type
-    obj2 = BackgroundFactory.create(BackgroundTypeEnum.CHEBYSHEV)
-    assert obj2.__class__.__name__.endswith('ChebyshevPolynomialBackground')
-
-    # Unsupported enum (fake) should raise ValueError
-    class FakeEnum:
-        value = 'x'
-
-    with pytest.raises(ValueError):
-        BackgroundFactory.create(FakeEnum)  # type: ignore[arg-type]
diff --git a/tests/unit/easydiffraction/experiments/categories/instrument/test_factory.py b/tests/unit/easydiffraction/experiments/categories/instrument/test_factory.py
deleted file mode 100644
index 0c6066b0..00000000
--- a/tests/unit/easydiffraction/experiments/categories/instrument/test_factory.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-import pytest
-
-
-def test_instrument_factory_default_and_errors():
-    try:
-        from easydiffraction.experiments.categories.instrument.factory import InstrumentFactory
-        from easydiffraction.experiments.experiment.enums import BeamModeEnum
-        from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-    except ImportError as e:  # pragma: no cover - environment-specific circular import
-        pytest.skip(f'InstrumentFactory import triggers circular import in this context: {e}')
-        return
-
-    inst = InstrumentFactory.create()  # defaults
-    assert inst.__class__.__name__ in {'CwlPdInstrument', 'CwlScInstrument', 'TofPdInstrument', 'TofScInstrument'}
-
-    # Valid combinations
-    inst2 = InstrumentFactory.create(ScatteringTypeEnum.BRAGG, BeamModeEnum.CONSTANT_WAVELENGTH)
-    assert inst2.__class__.__name__ == 'CwlPdInstrument'
-    inst3 = InstrumentFactory.create(ScatteringTypeEnum.BRAGG, BeamModeEnum.TIME_OF_FLIGHT)
-    assert inst3.__class__.__name__ == 'TofPdInstrument'
-
-    # Invalid scattering type
-    class FakeST:
-        pass
-
-    with pytest.raises(ValueError):
-        InstrumentFactory.create(FakeST, BeamModeEnum.CONSTANT_WAVELENGTH)  # type: ignore[arg-type]
-
-    # Invalid beam mode
-    class FakeBM:
-        pass
-
-    with pytest.raises(ValueError):
-        InstrumentFactory.create(ScatteringTypeEnum.BRAGG, FakeBM)  # type: ignore[arg-type]
diff --git a/tests/unit/easydiffraction/experiments/categories/peak/test_factory.py b/tests/unit/easydiffraction/experiments/categories/peak/test_factory.py
deleted file mode 100644
index bc474949..00000000
--- a/tests/unit/easydiffraction/experiments/categories/peak/test_factory.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-import pytest
-
-
-def test_peak_factory_default_and_combinations_and_errors():
-    from easydiffraction.experiments.categories.peak.factory import PeakFactory
-    from easydiffraction.experiments.experiment.enums import BeamModeEnum
-    from easydiffraction.experiments.experiment.enums import PeakProfileTypeEnum
-    from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-
-    # Defaults -> valid object for default enums
-    p = PeakFactory.create()
-    assert p._identity.category_code == 'peak'
-
-    # Explicit valid combos
-    p1 = PeakFactory.create(
-        ScatteringTypeEnum.BRAGG,
-        BeamModeEnum.CONSTANT_WAVELENGTH,
-        PeakProfileTypeEnum.PSEUDO_VOIGT,
-    )
-    assert p1.__class__.__name__ == 'CwlPseudoVoigt'
-    p2 = PeakFactory.create(
-        ScatteringTypeEnum.BRAGG,
-        BeamModeEnum.TIME_OF_FLIGHT,
-        PeakProfileTypeEnum.PSEUDO_VOIGT_IKEDA_CARPENTER,
-    )
-    assert p2.__class__.__name__ == 'TofPseudoVoigtIkedaCarpenter'
-    p3 = PeakFactory.create(
-        ScatteringTypeEnum.TOTAL,
-        BeamModeEnum.CONSTANT_WAVELENGTH,
-        PeakProfileTypeEnum.GAUSSIAN_DAMPED_SINC,
-    )
-    assert p3.__class__.__name__ == 'TotalGaussianDampedSinc'
-
-    # Invalid scattering type
-    class FakeST:
-        pass
-
-    with pytest.raises(ValueError):
-        PeakFactory.create(
-            FakeST, BeamModeEnum.CONSTANT_WAVELENGTH, PeakProfileTypeEnum.PSEUDO_VOIGT
-        )  # type: ignore[arg-type]
-
-    # Invalid beam mode
-    class FakeBM:
-        pass
-
-    with pytest.raises(ValueError):
-        PeakFactory.create(ScatteringTypeEnum.BRAGG, FakeBM, PeakProfileTypeEnum.PSEUDO_VOIGT)  # type: ignore[arg-type]
-
-    # Invalid profile type
-    class FakePPT:
-        pass
-
-    with pytest.raises(ValueError):
-        PeakFactory.create(ScatteringTypeEnum.BRAGG, BeamModeEnum.CONSTANT_WAVELENGTH, FakePPT)  # type: ignore[arg-type]
diff --git a/tests/unit/easydiffraction/experiments/categories/test_experiment_type.py b/tests/unit/easydiffraction/experiments/categories/test_experiment_type.py
deleted file mode 100644
index 429eb419..00000000
--- a/tests/unit/easydiffraction/experiments/categories/test_experiment_type.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-def test_module_import():
-    import easydiffraction.experiments.categories.experiment_type as MUT
-
-    expected_module_name = 'easydiffraction.experiments.categories.experiment_type'
-    actual_module_name = MUT.__name__
-    assert expected_module_name == actual_module_name
-
-
-def test_experiment_type_properties_and_validation(monkeypatch):
-    from easydiffraction.experiments.categories.experiment_type import ExperimentType
-    from easydiffraction.experiments.experiment.enums import BeamModeEnum
-    from easydiffraction.experiments.experiment.enums import RadiationProbeEnum
-    from easydiffraction.experiments.experiment.enums import SampleFormEnum
-    from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-    from easydiffraction.utils.logging import log
-
-    log.configure(reaction=log.Reaction.WARN)
-
-    et = ExperimentType(
-        sample_form=SampleFormEnum.POWDER.value,
-        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH.value,
-        radiation_probe=RadiationProbeEnum.NEUTRON.value,
-        scattering_type=ScatteringTypeEnum.BRAGG.value,
-    )
-    # getters nominal
-    assert et.sample_form.value == SampleFormEnum.POWDER.value
-    assert et.beam_mode.value == BeamModeEnum.CONSTANT_WAVELENGTH.value
-    assert et.radiation_probe.value == RadiationProbeEnum.NEUTRON.value
-    assert et.scattering_type.value == ScatteringTypeEnum.BRAGG.value
-
-    # try invalid value should fall back to previous (membership validator)
-    et.sample_form = 'invalid'
-    assert et.sample_form.value == SampleFormEnum.POWDER.value
diff --git a/tests/unit/easydiffraction/experiments/experiment/test_base.py b/tests/unit/easydiffraction/experiments/experiment/test_base.py
deleted file mode 100644
index bd781afb..00000000
--- a/tests/unit/easydiffraction/experiments/experiment/test_base.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-def test_module_import():
-    import easydiffraction.experiments.experiment.base as MUT
-
-    expected_module_name = 'easydiffraction.experiments.experiment.base'
-    actual_module_name = MUT.__name__
-    assert expected_module_name == actual_module_name
-
-
-def test_pd_experiment_peak_profile_type_switch(capsys):
-    from easydiffraction.experiments.categories.experiment_type import ExperimentType
-    from easydiffraction.experiments.experiment.base import PdExperimentBase
-    from easydiffraction.experiments.experiment.enums import BeamModeEnum
-    from easydiffraction.experiments.experiment.enums import PeakProfileTypeEnum
-    from easydiffraction.experiments.experiment.enums import RadiationProbeEnum
-    from easydiffraction.experiments.experiment.enums import SampleFormEnum
-    from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-
-    class ConcretePd(PdExperimentBase):
-        def _load_ascii_data_to_experiment(self, data_path: str) -> None:
-            pass
-
-    et = ExperimentType(
-        sample_form=SampleFormEnum.POWDER.value,
-        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH.value,
-        radiation_probe=RadiationProbeEnum.NEUTRON.value,
-        scattering_type=ScatteringTypeEnum.BRAGG.value,
-    )
-    ex = ConcretePd(name='ex1', type=et)
-    # valid switch using enum
-    ex.peak_profile_type = PeakProfileTypeEnum.PSEUDO_VOIGT
-    assert ex.peak_profile_type == PeakProfileTypeEnum.PSEUDO_VOIGT
-    # invalid string should warn and keep previous
-    ex.peak_profile_type = 'non-existent'
-    captured = capsys.readouterr().out
-    assert 'Unsupported' in captured or 'Unknown' in captured
diff --git a/tests/unit/easydiffraction/experiments/experiment/test_factory.py b/tests/unit/easydiffraction/experiments/experiment/test_factory.py
deleted file mode 100644
index d7838b04..00000000
--- a/tests/unit/easydiffraction/experiments/experiment/test_factory.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-import pytest
-
-
-
-def test_module_import():
-    import easydiffraction.experiments.experiment.factory as MUT
-
-    expected_module_name = 'easydiffraction.experiments.experiment.factory'
-    actual_module_name = MUT.__name__
-    assert expected_module_name == actual_module_name
-
-
-def test_experiment_factory_create_without_data_and_invalid_combo():
-    import easydiffraction.experiments.experiment.factory as EF
-    from easydiffraction.experiments.experiment.enums import BeamModeEnum
-    from easydiffraction.experiments.experiment.enums import RadiationProbeEnum
-    from easydiffraction.experiments.experiment.enums import SampleFormEnum
-    from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
-
-    ex = EF.ExperimentFactory.create(
-        name='ex1',
-        sample_form=SampleFormEnum.POWDER.value,
-        beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH.value,
-        radiation_probe=RadiationProbeEnum.NEUTRON.value,
-        scattering_type=ScatteringTypeEnum.BRAGG.value,
-    )
-    # Instance should be created (BraggPdExperiment)
-    assert hasattr(ex, 'type') and ex.type.sample_form.value == SampleFormEnum.POWDER.value
-
-    # invalid combination: unexpected key
-    with pytest.raises(ValueError):
-        EF.ExperimentFactory.create(name='ex2', unexpected=True)
diff --git a/tests/unit/easydiffraction/io/cif/test_serialize.py b/tests/unit/easydiffraction/io/cif/test_serialize.py
index d85126e8..48f044a1 100644
--- a/tests/unit/easydiffraction/io/cif/test_serialize.py
+++ b/tests/unit/easydiffraction/io/cif/test_serialize.py
@@ -73,7 +73,7 @@ def as_cif(self):
     class Project:
         def __init__(self):
             self.info = Obj('I')
-            self.sample_models = None
+            self.structures = None
             self.experiments = Obj('E')
             self.analysis = None
             self.summary = None
diff --git a/tests/unit/easydiffraction/io/cif/test_serialize_more.py b/tests/unit/easydiffraction/io/cif/test_serialize_more.py
index c36a01ab..54f345ee 100644
--- a/tests/unit/easydiffraction/io/cif/test_serialize_more.py
+++ b/tests/unit/easydiffraction/io/cif/test_serialize_more.py
@@ -117,6 +117,8 @@ def as_cif(self):
 
 def test_analysis_to_cif_renders_all_sections():
     import easydiffraction.io.cif.serialize as MUT
+    from easydiffraction.analysis.categories.fit_mode import FitMode
+    from easydiffraction.analysis.categories.joint_fit_experiments import JointFitExperiments
 
     class Obj:
         def __init__(self, t):
@@ -127,16 +129,14 @@ def as_cif(self):
             return self._t
 
     class A:
-        current_calculator = 'cryspy engine'
-        current_minimizer = 'lmfit (leastsq)'
-        fit_mode = 'single'
+        current_minimizer = 'lmfit'
+        fit_mode = FitMode()
+        joint_fit_experiments = JointFitExperiments()
         aliases = Obj('ALIASES')
         constraints = Obj('CONSTRAINTS')
 
     out = MUT.analysis_to_cif(A())
     lines = out.splitlines()
-    assert lines[0].startswith('_analysis.calculator_engine')
-    assert '"cryspy engine"' in lines[0]
-    assert lines[1].startswith('_analysis.fitting_engine') and '"lmfit (leastsq)"' in lines[1]
-    assert lines[2].startswith('_analysis.fit_mode') and 'single' in lines[2]
+    assert lines[0].startswith('_analysis.fitting_engine') and 'lmfit' in lines[0]
+    assert lines[1].startswith('_analysis.fit_mode') and 'single' in lines[1]
     assert 'ALIASES' in out and 'CONSTRAINTS' in out
diff --git a/tests/unit/easydiffraction/project/test_project.py b/tests/unit/easydiffraction/project/test_project.py
index 046a44f0..1a949fc5 100644
--- a/tests/unit/easydiffraction/project/test_project.py
+++ b/tests/unit/easydiffraction/project/test_project.py
@@ -7,3 +7,16 @@ def test_module_import():
     expected_module_name = 'easydiffraction.project.project'
     actual_module_name = MUT.__name__
     assert expected_module_name == actual_module_name
+
+
+def test_project_help(capsys):
+    from easydiffraction.project.project import Project
+
+    p = Project()
+    p.help()
+    out = capsys.readouterr().out
+    assert "Help for 'Project'" in out
+    assert 'experiments' in out
+    assert 'analysis' in out
+    assert 'summary' in out
+
diff --git a/tests/unit/easydiffraction/project/test_project_load_and_summary_wrap.py b/tests/unit/easydiffraction/project/test_project_load_and_summary_wrap.py
index d3e0b2d6..5cb103b8 100644
--- a/tests/unit/easydiffraction/project/test_project_load_and_summary_wrap.py
+++ b/tests/unit/easydiffraction/project/test_project_load_and_summary_wrap.py
@@ -2,15 +2,14 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 def test_project_load_prints_and_sets_path(tmp_path, capsys):
+    import pytest
+
     from easydiffraction.project.project import Project
 
     p = Project()
     dir_path = tmp_path / 'pdir'
-    p.load(str(dir_path))
-    out = capsys.readouterr().out
-    assert 'Loading project' in out and str(dir_path) in out
-    # Path should be set on ProjectInfo
-    assert p.info.path == dir_path
+    with pytest.raises(NotImplementedError, match='not implemented yet'):
+        p.load(str(dir_path))
 
 
 def test_summary_show_project_info_wraps_description(capsys):
diff --git a/tests/unit/easydiffraction/project/test_project_save.py b/tests/unit/easydiffraction/project/test_project_save.py
index 2879f85c..eb662dfc 100644
--- a/tests/unit/easydiffraction/project/test_project_save.py
+++ b/tests/unit/easydiffraction/project/test_project_save.py
@@ -35,5 +35,5 @@ def test_project_save_as_writes_core_files(tmp_path, monkeypatch):
     assert (target / 'project.cif').is_file()
     assert (target / 'analysis.cif').is_file()
     assert (target / 'summary.cif').is_file()
-    assert (target / 'sample_models').is_dir()
+    assert (target / 'structures').is_dir()
     assert (target / 'experiments').is_dir()
diff --git a/tests/unit/easydiffraction/sample_models/categories/test_atom_sites.py b/tests/unit/easydiffraction/sample_models/categories/test_atom_sites.py
deleted file mode 100644
index db4c22ed..00000000
--- a/tests/unit/easydiffraction/sample_models/categories/test_atom_sites.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-from easydiffraction.sample_models.categories.atom_sites import AtomSite
-from easydiffraction.sample_models.categories.atom_sites import AtomSites
-
-
-def test_atom_site_defaults_and_setters():
-    a = AtomSite(label='Si1', type_symbol='Si')
-    a.fract_x = 0.1
-    a.fract_y = 0.2
-    a.fract_z = 0.3
-    a.occupancy = 0.9
-    a.b_iso = 1.5
-    a.adp_type = 'Biso'
-    assert a.label.value == 'Si1'
-    assert a.type_symbol.value == 'Si'
-    assert (a.fract_x.value, a.fract_y.value, a.fract_z.value) == (0.1, 0.2, 0.3)
-    assert a.occupancy.value == 0.9
-    assert a.b_iso.value == 1.5
-    assert a.adp_type.value == 'Biso'
-
-
-def test_atom_sites_collection_adds_by_label():
-    sites = AtomSites()
-    sites.add(label='O1', type_symbol='O')
-    assert 'O1' in sites.names
-    assert sites['O1'].type_symbol.value == 'O'
diff --git a/tests/unit/easydiffraction/sample_models/categories/test_cell.py b/tests/unit/easydiffraction/sample_models/categories/test_cell.py
deleted file mode 100644
index 8de1da42..00000000
--- a/tests/unit/easydiffraction/sample_models/categories/test_cell.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-import pytest
-
-
-def test_cell_defaults_and_overrides():
-    from easydiffraction.sample_models.categories.cell import Cell
-
-    c = Cell()
-    # Defaults from AttributeSpec in implementation
-    assert pytest.approx(c.length_a.value) == 10.0
-    assert pytest.approx(c.length_b.value) == 10.0
-    assert pytest.approx(c.length_c.value) == 10.0
-    assert pytest.approx(c.angle_alpha.value) == 90.0
-    assert pytest.approx(c.angle_beta.value) == 90.0
-    assert pytest.approx(c.angle_gamma.value) == 90.0
-
-    # Override through constructor
-    c2 = Cell(length_a=12.3, angle_beta=100.0)
-    assert pytest.approx(c2.length_a.value) == 12.3
-    assert pytest.approx(c2.angle_beta.value) == 100.0
-
-
-def test_cell_setters_apply_validation_and_units():
-    from easydiffraction.sample_models.categories.cell import Cell
-
-    c = Cell()
-    # Set valid values within range
-    c.length_a = 5.5
-    c.angle_gamma = 120.0
-    assert pytest.approx(c.length_a.value) == 5.5
-    assert pytest.approx(c.angle_gamma.value) == 120.0
-    # Check units are preserved on parameter objects
-    assert c.length_a.units == 'Å'
-    assert c.angle_gamma.units == 'deg'
-
-
-def test_cell_identity_category_code():
-    from easydiffraction.sample_models.categories.cell import Cell
-
-    c = Cell()
-    assert c._identity.category_code == 'cell'
diff --git a/tests/unit/easydiffraction/sample_models/sample_model/test_factory.py b/tests/unit/easydiffraction/sample_models/sample_model/test_factory.py
deleted file mode 100644
index aa9fd9a0..00000000
--- a/tests/unit/easydiffraction/sample_models/sample_model/test_factory.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# SPDX-FileCopyrightText: 2021-2026 EasyDiffraction contributors 
-# SPDX-License-Identifier: BSD-3-Clause
-
-import pytest
-
-from easydiffraction.sample_models.sample_model.factory import SampleModelFactory
-
-
-def test_create_minimal_by_name():
-    m = SampleModelFactory.create(name='abc')
-    assert m.name == 'abc'
-
-
-def test_invalid_arg_combo_raises():
-    with pytest.raises(ValueError):
-        SampleModelFactory.create(name=None, cif_path=None)
diff --git a/tests/unit/easydiffraction/summary/test_summary.py b/tests/unit/easydiffraction/summary/test_summary.py
index e6388cbf..29da4e28 100644
--- a/tests/unit/easydiffraction/summary/test_summary.py
+++ b/tests/unit/easydiffraction/summary/test_summary.py
@@ -23,11 +23,10 @@ class Info:
     class Project:
         def __init__(self):
             self.info = Info()
-            self.sample_models = {}  # empty mapping to exercise loops safely
+            self.structures = {}  # empty mapping to exercise loops safely
             self.experiments = {}  # empty mapping to exercise loops safely
 
             class A:
-                current_calculator = 'cryspy'
                 current_minimizer = 'lmfit'
 
                 class R:
diff --git a/tests/unit/easydiffraction/summary/test_summary_details.py b/tests/unit/easydiffraction/summary/test_summary_details.py
index 8965c5ed..0e9fcf04 100644
--- a/tests/unit/easydiffraction/summary/test_summary_details.py
+++ b/tests/unit/easydiffraction/summary/test_summary_details.py
@@ -4,7 +4,7 @@
 def test_summary_crystallographic_and_experimental_sections(capsys):
     from easydiffraction.summary.summary import Summary
 
-    # Build a minimal sample model stub that exposes required attributes
+    # Build a minimal structure stub that exposes required attributes
     class Val:
         def __init__(self, v):
             self.value = v
@@ -96,11 +96,10 @@ class Info:
     class Project:
         def __init__(self):
             self.info = Info()
-            self.sample_models = {'phaseA': Model()}
+            self.structures = {'phaseA': Model()}
             self.experiments = {'exp1': Expt()}
 
             class A:
-                current_calculator = 'cryspy'
                 current_minimizer = 'lmfit'
 
                 class R:
diff --git a/tests/unit/easydiffraction/test___init__.py b/tests/unit/easydiffraction/test___init__.py
index 714ffc4d..5eb8c38f 100644
--- a/tests/unit/easydiffraction/test___init__.py
+++ b/tests/unit/easydiffraction/test___init__.py
@@ -14,7 +14,7 @@ def test_lazy_attributes_resolve_and_are_accessible():
     # Access a few lazy attributes; just ensure they exist and are callable/class-like
     assert hasattr(ed, 'Project')
     assert hasattr(ed, 'ExperimentFactory')
-    assert hasattr(ed, 'SampleModelFactory')
+    assert hasattr(ed, 'StructureFactory')
 
     # Access utility functions from utils via lazy getattr
     assert callable(ed.show_version)
diff --git a/tmp/__validator.py b/tmp/__validator.py
new file mode 100644
index 00000000..359ecd1a
--- /dev/null
+++ b/tmp/__validator.py
@@ -0,0 +1,87 @@
+# %%
+import easydiffraction as ed
+import numpy as np
+
+# %%
+project = ed.Project()
+
+#
+
+project.experiments.add_from_data_path(name='aaa', data_path=23)
+
+exit()
+
+# %%
+model_path = ed.download_data(id=1, destination='data')
+project.structures.add_from_cif_path(cif_path=model_path)
+
+#project.structures.add_from_scratch(name='qwe')
+#project.structures['qwe'] = 6
+#print(project.structures['qwe'].name.value)
+#struct = project.structures['qwe']
+#struct.cell = "cell"
+#print(struct.cell)
+
+#exit()
+
+
+
+# %%
+expt_path = ed.download_data(id=2, destination='data')
+project.experiments.add_from_cif_path(cif_path=expt_path)
+#project.experiments.add_from_cif_path(cif_path=77)
+
+#expt = ed.ExperimentFactory.from_scratch(name='expt', scattering_type='total2')
+#print(expt)
+exit()
+
+print('\nStructure:')
+print(project.structures['lbco'])
+
+print('\nExperiment:')
+print(project.experiments['hrpt'])
+
+
+exit()
+
+
+
+# %%
+#sample = project.sample_models.get(id=1)
+#sample = project.sample_models.get(name='Fe2O3')
+sample = project.sample_models['lbco']
+
+# %%
+print()
+print("=== Testing cell.length_a ===")
+sample.cell.length_a = 3
+print(sample.cell.length_a, type(sample.cell.length_a.value))
+sample.cell.length_a = np.int64(4)
+print(sample.cell.length_a, type(sample.cell.length_a.value))
+sample.cell.length_a = np.float64(5.5)
+print(sample.cell.length_a, type(sample.cell.length_a.value))
+###sample.cell.length_a = "6.0"
+###sample.cell.length_a = -7.0
+###sample.cell.length_a = None
+print(sample.cell.length_a, type(sample.cell.length_a.value))
+
+# %%
+print()
+print("=== Testing space_group ===")
+sample.space_group.name_h_m = 'P n m a'
+print(sample.space_group.name_h_m)
+print(sample.space_group.it_coordinate_system_code)
+###sample.space_group.name_h_m = 'P x y z'
+print(sample.space_group.name_h_m)
+###sample.space_group.name_h_m = 4500
+print(sample.space_group.name_h_m)
+sample.space_group.it_coordinate_system_code = 'cab'
+print(sample.space_group.it_coordinate_system_code)
+
+# %%
+print()
+print("=== Testing atom_sites ===")
+sample.atom_sites.add(label2='O5', type_symbol='O')
+
+# %%
+sample.show_as_cif()
\ No newline at end of file
diff --git a/tmp/basic_single-fit_pd-neut-cwl_LBCO-HRPT.py b/tmp/basic_single-fit_pd-neut-cwl_LBCO-HRPT.py
index cf21bcc8..18db2b22 100644
--- a/tmp/basic_single-fit_pd-neut-cwl_LBCO-HRPT.py
+++ b/tmp/basic_single-fit_pd-neut-cwl_LBCO-HRPT.py
@@ -375,22 +375,22 @@
 #
 # #### Set Calculator
 #
-# Show supported calculation engines.
+# Show supported calculation engines for this experiment.
 
 # %%
-project.analysis.show_supported_calculators()
+project.experiments['hrpt'].show_supported_calculator_types()
 
 # %% [markdown]
-# Show current calculation engine.
+# Show current calculation engine for this experiment.
 
 # %%
-project.analysis.show_current_calculator()
+project.experiments['hrpt'].show_current_calculator_type()
 
 # %% [markdown]
 # Select the desired calculation engine.
 
 # %%
-project.analysis.current_calculator = 'cryspy'
+project.experiments['hrpt'].calculator_type = 'cryspy'
 
 # %% [markdown]
 # #### Show Calculated Data
@@ -435,23 +435,9 @@
 
 # %% [markdown]
 # #### Set Fit Mode
-#
-# Show supported fit modes.
-
-# %%
-project.analysis.show_available_fit_modes()
-
-# %% [markdown]
-# Show current fit mode.
-
-# %%
-project.analysis.show_current_fit_mode()
-
-# %% [markdown]
-# Select desired fit mode.
 
 # %%
-project.analysis.fit_mode = 'single'
+project.analysis.fit_mode.mode = 'single'
 
 # %% [markdown]
 # #### Set Minimizer
diff --git a/tmp/cryst-struct_pd-neut-tof_multiphase-BSFTO-HRPT.py b/tmp/cryst-struct_pd-neut-tof_multiphase-BSFTO-HRPT.py
index b4ee7e15..e7e6c1d6 100644
--- a/tmp/cryst-struct_pd-neut-tof_multiphase-BSFTO-HRPT.py
+++ b/tmp/cryst-struct_pd-neut-tof_multiphase-BSFTO-HRPT.py
@@ -220,12 +220,6 @@
 # This section outlines the analysis process, including how to configure
 # calculation and fitting engines.
 #
-# #### Set Calculator
-
-# %%
-project.analysis.current_calculator = 'cryspy'
-
-# %% [markdown]
 # #### Set Minimizer
 
 # %%
diff --git a/tmp/short7.py b/tmp/short7.py
index dfec2cd8..285704e9 100644
--- a/tmp/short7.py
+++ b/tmp/short7.py
@@ -76,7 +76,6 @@ def single_fit_neutron_pd_cwl_lbco() -> None:
     expt.show_as_cif()
 
     # Prepare for fitting
-    project.analysis.current_calculator = 'cryspy'
     project.analysis.current_minimizer = 'lmfit (leastsq)'
 
     # ------------ 1st fitting ------------
diff --git a/tutorials/data/ed-3.xye b/tutorials/data/ed-3.xye
new file mode 100644
index 00000000..0b9b63e3
--- /dev/null
+++ b/tutorials/data/ed-3.xye
@@ -0,0 +1,3099 @@
+#  2theta intensity   su
+   10.00    167.00   12.60
+   10.05    157.00   12.50
+   10.10    187.00   13.30
+   10.15    197.00   14.00
+   10.20    164.00   12.50
+   10.25    171.00   13.00
+   10.30    190.00   13.40
+   10.35    182.00   13.50
+   10.40    166.00   12.60
+   10.45    203.00   14.30
+   10.50    156.00   12.20
+   10.55    190.00   13.90
+   10.60    175.00   13.00
+   10.65    161.00   12.90
+   10.70    187.00   13.50
+   10.75    166.00   13.10
+   10.80    171.00   13.00
+   10.85    177.00   13.60
+   10.90    159.00   12.60
+   10.95    184.00   13.90
+   11.00    160.00   12.60
+   11.05    182.00   13.90
+   11.10    167.00   13.00
+   11.15    169.00   13.40
+   11.20    186.00   13.70
+   11.25    167.00   13.30
+   11.30    169.00   13.10
+   11.35    159.00   13.10
+   11.40    170.00   13.20
+   11.45    179.00   13.90
+   11.50    178.00   13.50
+   11.55    188.00   14.20
+   11.60    176.00   13.50
+   11.65    196.00   14.60
+   11.70    182.00   13.70
+   11.75    183.00   14.00
+   11.80    195.00   14.10
+   11.85    144.00   12.40
+   11.90    178.00   13.50
+   11.95    175.00   13.70
+   12.00    200.00   14.20
+   12.05    157.00   12.90
+   12.10    195.00   14.00
+   12.15    164.00   13.10
+   12.20    188.00   13.70
+   12.25    168.00   13.10
+   12.30    191.00   13.70
+   12.35    178.00   13.40
+   12.40    182.00   13.30
+   12.45    174.00   13.30
+   12.50    171.00   12.90
+   12.55    174.00   13.20
+   12.60    184.00   13.30
+   12.65    164.00   12.80
+   12.70    166.00   12.50
+   12.75    177.00   13.20
+   12.80    174.00   12.80
+   12.85    187.00   13.50
+   12.90    183.00   13.10
+   12.95    187.00   13.50
+   13.00    175.00   12.80
+   13.05    165.00   12.70
+   13.10    177.00   12.80
+   13.15    182.00   13.30
+   13.20    195.00   13.50
+   13.25    163.00   12.60
+   13.30    180.00   12.90
+   13.35    171.00   12.90
+   13.40    182.00   13.00
+   13.45    179.00   13.10
+   13.50    161.00   12.20
+   13.55    156.00   12.30
+   13.60    197.00   13.50
+   13.65    167.00   12.70
+   13.70    180.00   12.80
+   13.75    182.00   13.20
+   13.80    176.00   12.70
+   13.85    153.00   12.10
+   13.90    179.00   12.80
+   13.95    156.00   12.30
+   14.00    187.00   13.10
+   14.05    170.00   12.80
+   14.10    185.00   13.00
+   14.15    180.00   13.20
+   14.20    167.00   12.40
+   14.25    159.00   12.40
+   14.30    152.00   11.80
+   14.35    173.00   13.00
+   14.40    169.00   12.50
+   14.45    185.00   13.40
+   14.50    168.00   12.40
+   14.55    193.00   13.70
+   14.60    177.00   12.80
+   14.65    161.00   12.50
+   14.70    180.00   12.90
+   14.75    165.00   12.60
+   14.80    178.00   12.80
+   14.85    157.00   12.30
+   14.90    163.00   12.30
+   14.95    143.00   11.70
+   15.00    155.00   11.90
+   15.05    168.00   12.80
+   15.10    160.00   12.10
+   15.15    155.00   12.20
+   15.20    203.00   13.70
+   15.25    164.00   12.60
+   15.30    158.00   12.10
+   15.35    152.00   12.10
+   15.40    173.00   12.60
+   15.45    160.00   12.50
+   15.50    172.00   12.60
+   15.55    164.00   12.60
+   15.60    163.00   12.30
+   15.65    173.00   13.00
+   15.70    177.00   12.80
+   15.75    184.00   13.40
+   15.80    173.00   12.70
+   15.85    182.00   13.30
+   15.90    156.00   12.10
+   15.95    152.00   12.20
+   16.00    201.00   13.70
+   16.05    156.00   12.30
+   16.10    169.00   12.50
+   16.15    178.00   13.20
+   16.20    150.00   11.80
+   16.25    163.00   12.60
+   16.30    165.00   12.40
+   16.35    160.00   12.50
+   16.40    171.00   12.60
+   16.45    168.00   12.80
+   16.50    159.00   12.20
+   16.55    166.00   12.80
+   16.60    156.00   12.10
+   16.65    156.00   12.40
+   16.70    154.00   12.10
+   16.75    173.00   13.10
+   16.80    173.00   12.80
+   16.85    161.00   12.70
+   16.90    177.00   13.00
+   16.95    159.00   12.70
+   17.00    162.00   12.50
+   17.05    166.00   13.00
+   17.10    167.00   12.70
+   17.15    166.00   13.10
+   17.20    168.00   12.80
+   17.25    188.00   14.00
+   17.30    165.00   12.80
+   17.35    171.00   13.40
+   17.40    171.00   13.10
+   17.45    162.00   13.10
+   17.50    161.00   12.80
+   17.55    177.00   13.80
+   17.60    176.00   13.40
+   17.65    175.00   13.70
+   17.70    140.00   12.00
+   17.75    177.00   13.90
+   17.80    150.00   12.40
+   17.85    154.00   12.90
+   17.90    138.00   11.90
+   17.95    161.00   13.20
+   18.00    171.00   13.30
+   18.05    144.00   12.50
+   18.10    148.00   12.40
+   18.15    169.00   13.50
+   18.20    162.00   12.90
+   18.25    171.00   13.50
+   18.30    155.00   12.60
+   18.35    143.00   12.30
+   18.40    162.00   12.80
+   18.45    177.00   13.60
+   18.50    158.00   12.60
+   18.55    142.00   12.20
+   18.60    153.00   12.40
+   18.65    169.00   13.30
+   18.70    144.00   12.00
+   18.75    171.00   13.30
+   18.80    159.00   12.50
+   18.85    169.00   13.10
+   18.90    163.00   12.60
+   18.95    154.00   12.50
+   19.00    146.00   11.90
+   19.05    154.00   12.50
+   19.10    156.00   12.20
+   19.15    195.00   14.00
+   19.20    154.00   12.10
+   19.25    167.00   12.90
+   19.30    156.00   12.20
+   19.35    148.00   12.10
+   19.40    173.00   12.80
+   19.45    155.00   12.40
+   19.50    146.00   11.70
+   19.55    173.00   13.10
+   19.60    179.00   13.00
+   19.65    152.00   12.30
+   19.70    182.00   13.10
+   19.75    183.00   13.40
+   19.80    150.00   11.90
+   19.85    155.00   12.30
+   19.90    158.00   12.20
+   19.95    161.00   12.60
+   20.00    164.00   12.40
+   20.05    166.00   12.80
+   20.10    172.00   12.70
+   20.15    148.00   12.10
+   20.20    161.00   12.30
+   20.25    160.00   12.60
+   20.30    185.00   13.20
+   20.35    165.00   12.80
+   20.40    155.00   12.10
+   20.45    172.00   13.00
+   20.50    170.00   12.70
+   20.55    180.00   13.40
+   20.60    184.00   13.20
+   20.65    164.00   12.80
+   20.70    177.00   13.00
+   20.75    150.00   12.20
+   20.80    176.00   12.90
+   20.85    174.00   13.20
+   20.90    173.00   12.80
+   20.95    167.00   12.90
+   21.00    158.00   12.20
+   21.05    174.00   13.20
+   21.10    160.00   12.30
+   21.15    174.00   13.20
+   21.20    160.00   12.30
+   21.25    182.00   13.40
+   21.30    155.00   12.10
+   21.35    182.00   13.40
+   21.40    157.00   12.20
+   21.45    174.00   13.20
+   21.50    173.00   12.80
+   21.55    165.00   12.80
+   21.60    182.00   13.10
+   21.65    176.00   13.20
+   21.70    150.00   11.90
+   21.75    162.00   12.60
+   21.80    172.00   12.70
+   21.85    162.00   12.70
+   21.90    171.00   12.70
+   21.95    165.00   12.80
+   22.00    180.00   13.00
+   22.05    167.00   12.80
+   22.10    159.00   12.20
+   22.15    159.00   12.50
+   22.20    160.00   12.30
+   22.25    174.00   13.10
+   22.30    175.00   12.90
+   22.35    172.00   13.10
+   22.40    176.00   12.90
+   22.45    140.00   11.80
+   22.50    163.00   12.40
+   22.55    180.00   13.50
+   22.60    211.00   14.20
+   22.65    190.00   13.90
+   22.70    179.00   13.10
+   22.75    195.00   14.10
+   22.80    198.00   13.90
+   22.85    181.00   13.70
+   22.90    203.00   14.10
+   22.95    193.00   14.10
+   23.00    155.00   12.40
+   23.05    159.00   12.90
+   23.10    184.00   13.50
+   23.15    145.00   12.30
+   23.20    145.00   12.00
+   23.25    179.00   13.70
+   23.30    185.00   13.60
+   23.35    168.00   13.30
+   23.40    185.00   13.60
+   23.45    170.00   13.40
+   23.50    174.00   13.30
+   23.55    164.00   13.20
+   23.60    168.00   13.10
+   23.65    185.00   14.10
+   23.70    183.00   13.70
+   23.75    172.00   13.70
+   23.80    156.00   12.70
+   23.85    182.00   14.00
+   23.90    182.00   13.70
+   23.95    149.00   12.70
+   24.00    160.00   12.80
+   24.05    168.00   13.50
+   24.10    178.00   13.60
+   24.15    169.00   13.60
+   24.20    172.00   13.40
+   24.25    170.00   13.60
+   24.30    161.00   12.90
+   24.35    168.00   13.50
+   24.40    162.00   13.00
+   24.45    157.00   13.00
+   24.50    162.00   12.90
+   24.55    159.00   13.10
+   24.60    168.00   13.20
+   24.65    170.00   13.50
+   24.70    166.00   13.00
+   24.75    146.00   12.50
+   24.80    154.00   12.50
+   24.85    154.00   12.70
+   24.90    198.00   14.10
+   24.95    195.00   14.30
+   25.00    148.00   12.20
+   25.05    161.00   12.90
+   25.10    160.00   12.60
+   25.15    160.00   12.80
+   25.20    149.00   12.10
+   25.25    179.00   13.50
+   25.30    174.00   13.00
+   25.35    168.00   13.00
+   25.40    146.00   11.90
+   25.45    160.00   12.70
+   25.50    145.00   11.80
+   25.55    151.00   12.30
+   25.60    161.00   12.40
+   25.65    187.00   13.60
+   25.70    154.00   12.10
+   25.75    157.00   12.40
+   25.80    169.00   12.60
+   25.85    181.00   13.40
+   25.90    156.00   12.10
+   25.95    185.00   13.40
+   26.00    192.00   13.40
+   26.05    153.00   12.20
+   26.10    149.00   11.80
+   26.15    154.00   12.20
+   26.20    152.00   11.90
+   26.25    179.00   13.20
+   26.30    180.00   12.90
+   26.35    160.00   12.50
+   26.40    174.00   12.60
+   26.45    145.00   11.80
+   26.50    171.00   12.50
+   26.55    162.00   12.50
+   26.60    154.00   11.80
+   26.65    153.00   12.10
+   26.70    162.00   12.10
+   26.75    160.00   12.40
+   26.80    150.00   11.70
+   26.85    189.00   13.40
+   26.90    168.00   12.40
+   26.95    144.00   11.70
+   27.00    147.00   11.60
+   27.05    155.00   12.20
+   27.10    174.00   12.60
+   27.15    169.00   12.70
+   27.20    174.00   12.60
+   27.25    164.00   12.60
+   27.30    146.00   11.60
+   27.35    149.00   12.00
+   27.40    155.00   11.90
+   27.45    155.00   12.20
+   27.50    168.00   12.40
+   27.55    131.00   11.20
+   27.60    159.00   12.10
+   27.65    181.00   13.20
+   27.70    146.00   11.60
+   27.75    188.00   13.50
+   27.80    162.00   12.20
+   27.85    161.00   12.50
+   27.90    176.00   12.70
+   27.95    152.00   12.10
+   28.00    170.00   12.40
+   28.05    152.00   12.00
+   28.10    158.00   12.00
+   28.15    168.00   12.60
+   28.20    161.00   12.10
+   28.25    184.00   13.30
+   28.30    166.00   12.30
+   28.35    193.00   13.60
+   28.40    157.00   12.00
+   28.45    167.00   12.60
+   28.50    158.00   12.00
+   28.55    135.00   11.40
+   28.60    150.00   11.70
+   28.65    167.00   12.70
+   28.70    161.00   12.20
+   28.75    157.00   12.30
+   28.80    153.00   11.80
+   28.85    161.00   12.50
+   28.90    163.00   12.20
+   28.95    133.00   11.40
+   29.00    169.00   12.50
+   29.05    162.00   12.50
+   29.10    161.00   12.20
+   29.15    163.00   12.60
+   29.20    144.00   11.60
+   29.25    178.00   13.20
+   29.30    161.00   12.20
+   29.35    141.00   11.80
+   29.40    169.00   12.50
+   29.45    160.00   12.50
+   29.50    177.00   12.90
+   29.55    174.00   13.10
+   29.60    157.00   12.10
+   29.65    176.00   13.20
+   29.70    179.00   13.00
+   29.75    166.00   12.90
+   29.80    162.00   12.40
+   29.85    147.00   12.20
+   29.90    152.00   12.00
+   29.95    171.00   13.20
+   30.00    178.00   13.10
+   30.05    208.00   14.60
+   30.10    178.00   13.20
+   30.15    149.00   12.40
+   30.20    181.00   13.30
+   30.25    162.00   13.00
+   30.30    177.00   13.20
+   30.35    165.00   13.10
+   30.40    177.00   13.30
+   30.45    158.00   12.90
+   30.50    157.00   12.60
+   30.55    163.00   13.10
+   30.60    144.00   12.00
+   30.65    156.00   12.80
+   30.70    176.00   13.30
+   30.75    179.00   13.70
+   30.80    174.00   13.20
+   30.85    182.00   13.80
+   30.90    161.00   12.70
+   30.95    166.00   13.10
+   31.00    168.00   13.00
+   31.05    153.00   12.60
+   31.10    156.00   12.40
+   31.15    174.00   13.40
+   31.20    167.00   12.80
+   31.25    192.00   14.00
+   31.30    154.00   12.30
+   31.35    166.00   13.00
+   31.40    169.00   12.90
+   31.45    185.00   13.70
+   31.50    165.00   12.60
+   31.55    163.00   12.80
+   31.60    173.00   12.90
+   31.65    169.00   13.00
+   31.70    188.00   13.40
+   31.75    195.00   13.90
+   31.80    195.00   13.60
+   31.85    221.00   14.70
+   31.90    229.00   14.70
+   31.95    302.00   17.20
+   32.00    327.00   17.50
+   32.05    380.00   19.30
+   32.10    358.00   18.30
+   32.15    394.00   19.60
+   32.20    373.00   18.70
+   32.25    362.00   18.70
+   32.30    306.00   16.90
+   32.35    276.00   16.40
+   32.40    237.00   14.80
+   32.45    203.00   14.00
+   32.50    178.00   12.80
+   32.55    199.00   13.90
+   32.60    167.00   12.40
+   32.65    185.00   13.40
+   32.70    180.00   12.90
+   32.75    178.00   13.10
+   32.80    145.00   11.50
+   32.85    176.00   13.00
+   32.90    177.00   12.70
+   32.95    182.00   13.20
+   33.00    167.00   12.40
+   33.05    152.00   12.10
+   33.10    144.00   11.50
+   33.15    170.00   12.80
+   33.20    156.00   11.90
+   33.25    154.00   12.20
+   33.30    180.00   12.80
+   33.35    176.00   13.00
+   33.40    183.00   12.90
+   33.45    162.00   12.40
+   33.50    180.00   12.80
+   33.55    165.00   12.60
+   33.60    174.00   12.50
+   33.65    179.00   13.00
+   33.70    152.00   11.70
+   33.75    182.00   13.10
+   33.80    184.00   12.90
+   33.85    166.00   12.50
+   33.90    182.00   12.80
+   33.95    162.00   12.40
+   34.00    174.00   12.50
+   34.05    153.00   12.00
+   34.10    182.00   12.80
+   34.15    180.00   13.00
+   34.20    167.00   12.20
+   34.25    173.00   12.70
+   34.30    153.00   11.70
+   34.35    160.00   12.30
+   34.40    180.00   12.70
+   34.45    168.00   12.50
+   34.50    167.00   12.20
+   34.55    176.00   12.80
+   34.60    165.00   12.10
+   34.65    174.00   12.80
+   34.70    161.00   12.00
+   34.75    178.00   12.90
+   34.80    170.00   12.30
+   34.85    166.00   12.50
+   34.90    173.00   12.40
+   34.95    158.00   12.20
+   35.00    166.00   12.20
+   35.05    170.00   12.60
+   35.10    162.00   12.00
+   35.15    183.00   13.10
+   35.20    176.00   12.50
+   35.25    171.00   12.60
+   35.30    174.00   12.50
+   35.35    179.00   12.90
+   35.40    176.00   12.50
+   35.45    193.00   13.40
+   35.50    180.00   12.70
+   35.55    188.00   13.30
+   35.60    177.00   12.60
+   35.65    176.00   12.90
+   35.70    171.00   12.40
+   35.75    185.00   13.30
+   35.80    178.00   12.70
+   35.85    152.00   12.10
+   35.90    160.00   12.10
+   35.95    187.00   13.50
+   36.00    167.00   12.40
+   36.05    181.00   13.30
+   36.10    166.00   12.40
+   36.15    165.00   12.80
+   36.20    170.00   12.70
+   36.25    197.00   14.10
+   36.30    179.00   13.10
+   36.35    172.00   13.20
+   36.40    181.00   13.30
+   36.45    174.00   13.40
+   36.50    162.00   12.60
+   36.55    166.00   13.10
+   36.60    158.00   12.50
+   36.65    199.00   14.40
+   36.70    188.00   13.70
+   36.75    177.00   13.70
+   36.80    167.00   12.90
+   36.85    156.00   12.90
+   36.90    174.00   13.20
+   36.95    176.00   13.70
+   37.00    152.00   12.40
+   37.05    191.00   14.40
+   37.10    151.00   12.50
+   37.15    202.00   14.80
+   37.20    191.00   14.00
+   37.25    161.00   13.20
+   37.30    199.00   14.30
+   37.35    175.00   13.70
+   37.40    146.00   12.30
+   37.45    181.00   14.00
+   37.50    221.00   15.00
+   37.55    194.00   14.40
+   37.60    158.00   12.70
+   37.65    171.00   13.50
+   37.70    172.00   13.20
+   37.75    168.00   13.30
+   37.80    192.00   13.90
+   37.85    185.00   13.90
+   37.90    193.00   13.90
+   37.95    178.00   13.60
+   38.00    195.00   13.90
+   38.05    175.00   13.40
+   38.10    178.00   13.20
+   38.15    173.00   13.30
+   38.20    195.00   13.70
+   38.25    194.00   13.90
+   38.30    191.00   13.50
+   38.35    178.00   13.30
+   38.40    184.00   13.30
+   38.45    186.00   13.50
+   38.50    202.00   13.80
+   38.55    200.00   14.00
+   38.60    210.00   14.00
+   38.65    198.00   13.90
+   38.70    225.00   14.50
+   38.75    209.00   14.30
+   38.80    229.00   14.60
+   38.85    197.00   13.90
+   38.90    220.00   14.30
+   38.95    215.00   14.40
+   39.00    242.00   15.00
+   39.05    340.00   18.10
+   39.10    441.00   20.20
+   39.15    654.00   25.10
+   39.20    962.00   29.70
+   39.25   1477.00   37.70
+   39.30   2012.00   43.00
+   39.35   2634.00   50.20
+   39.40   3115.00   53.40
+   39.45   3467.00   57.50
+   39.50   3532.00   56.70
+   39.55   3337.00   56.30
+   39.60   2595.00   48.60
+   39.65   1943.00   42.90
+   39.70   1251.00   33.70
+   39.75    828.00   28.00
+   39.80    525.00   21.80
+   39.85    377.00   18.80
+   39.90    294.00   16.30
+   39.95    233.00   14.80
+   40.00    233.00   14.50
+   40.05    253.00   15.40
+   40.10    253.00   15.10
+   40.15    213.00   14.10
+   40.20    196.00   13.20
+   40.25    222.00   14.40
+   40.30    172.00   12.40
+   40.35    218.00   14.30
+   40.40    206.00   13.60
+   40.45    195.00   13.60
+   40.50    209.00   13.70
+   40.55    192.00   13.50
+   40.60    197.00   13.30
+   40.65    188.00   13.30
+   40.70    202.00   13.50
+   40.75    208.00   14.00
+   40.80    184.00   12.90
+   40.85    177.00   13.00
+   40.90    202.00   13.50
+   40.95    198.00   13.80
+   41.00    203.00   13.60
+   41.05    193.00   13.60
+   41.10    188.00   13.10
+   41.15    211.00   14.20
+   41.20    189.00   13.10
+   41.25    200.00   13.90
+   41.30    198.00   13.50
+   41.35    203.00   14.00
+   41.40    197.00   13.40
+   41.45    190.00   13.60
+   41.50    212.00   14.00
+   41.55    185.00   13.40
+   41.60    228.00   14.50
+   41.65    167.00   12.80
+   41.70    207.00   13.90
+   41.75    187.00   13.60
+   41.80    190.00   13.30
+   41.85    192.00   13.80
+   41.90    185.00   13.20
+   41.95    161.00   12.70
+   42.00    187.00   13.30
+   42.05    191.00   13.80
+   42.10    159.00   12.30
+   42.15    170.00   13.10
+   42.20    182.00   13.20
+   42.25    186.00   13.70
+   42.30    192.00   13.60
+   42.35    178.00   13.50
+   42.40    186.00   13.40
+   42.45    180.00   13.50
+   42.50    178.00   13.10
+   42.55    182.00   13.60
+   42.60    179.00   13.20
+   42.65    203.00   14.50
+   42.70    191.00   13.70
+   42.75    207.00   14.60
+   42.80    183.00   13.40
+   42.85    180.00   13.60
+   42.90    191.00   13.70
+   42.95    187.00   13.90
+   43.00    184.00   13.50
+   43.05    182.00   13.80
+   43.10    178.00   13.30
+   43.15    169.00   13.30
+   43.20    158.00   12.60
+   43.25    180.00   13.70
+   43.30    174.00   13.20
+   43.35    184.00   14.00
+   43.40    178.00   13.40
+   43.45    180.00   13.80
+   43.50    144.00   12.00
+   43.55    169.00   13.40
+   43.60    177.00   13.30
+   43.65    156.00   12.80
+   43.70    148.00   12.20
+   43.75    159.00   12.90
+   43.80    195.00   14.00
+   43.85    186.00   14.00
+   43.90    180.00   13.40
+   43.95    192.00   14.10
+   44.00    186.00   13.50
+   44.05    180.00   13.60
+   44.10    174.00   13.10
+   44.15    181.00   13.60
+   44.20    178.00   13.20
+   44.25    189.00   13.80
+   44.30    206.00   14.10
+   44.35    183.00   13.60
+   44.40    161.00   12.40
+   44.45    170.00   13.00
+   44.50    203.00   13.90
+   44.55    168.00   12.90
+   44.60    199.00   13.70
+   44.65    192.00   13.70
+   44.70    192.00   13.40
+   44.75    200.00   14.00
+   44.80    206.00   13.90
+   44.85    193.00   13.70
+   44.90    188.00   13.20
+   44.95    200.00   13.90
+   45.00    193.00   13.40
+   45.05    203.00   14.00
+   45.10    212.00   14.00
+   45.15    197.00   13.80
+   45.20    219.00   14.20
+   45.25    219.00   14.60
+   45.30    226.00   14.50
+   45.35    282.00   16.50
+   45.40    353.00   18.10
+   45.45    469.00   21.30
+   45.50    741.00   26.20
+   45.55   1176.00   33.70
+   45.60   1577.00   38.10
+   45.65   2122.00   45.30
+   45.70   2726.00   50.10
+   45.75   2990.00   53.70
+   45.80   2991.00   52.50
+   45.85   2796.00   52.00
+   45.90   2372.00   46.80
+   45.95   1752.00   41.20
+   46.00   1209.00   33.40
+   46.05    824.00   28.30
+   46.10    512.00   21.80
+   46.15    353.00   18.60
+   46.20    273.00   15.90
+   46.25    259.00   15.90
+   46.30    233.00   14.80
+   46.35    220.00   14.70
+   46.40    228.00   14.60
+   46.45    231.00   15.10
+   46.50    218.00   14.30
+   46.55    210.00   14.40
+   46.60    212.00   14.20
+   46.65    187.00   13.60
+   46.70    207.00   14.00
+   46.75    212.00   14.50
+   46.80    188.00   13.40
+   46.85    178.00   13.30
+   46.90    186.00   13.30
+   46.95    192.00   13.80
+   47.00    192.00   13.50
+   47.05    186.00   13.60
+   47.10    208.00   14.10
+   47.15    199.00   14.10
+   47.20    165.00   12.50
+   47.25    212.00   14.50
+   47.30    191.00   13.50
+   47.35    185.00   13.60
+   47.40    171.00   12.70
+   47.45    176.00   13.20
+   47.50    179.00   13.00
+   47.55    187.00   13.60
+   47.60    181.00   13.10
+   47.65    173.00   13.10
+   47.70    167.00   12.50
+   47.75    182.00   13.40
+   47.80    171.00   12.70
+   47.85    185.00   13.50
+   47.90    177.00   12.90
+   47.95    154.00   12.40
+   48.00    200.00   13.70
+   48.05    177.00   13.30
+   48.10    184.00   13.20
+   48.15    166.00   12.80
+   48.20    181.00   13.10
+   48.25    208.00   14.40
+   48.30    186.00   13.20
+   48.35    164.00   12.70
+   48.40    196.00   13.60
+   48.45    169.00   12.90
+   48.50    173.00   12.70
+   48.55    200.00   14.10
+   48.60    163.00   12.40
+   48.65    173.00   13.10
+   48.70    187.00   13.30
+   48.75    177.00   13.30
+   48.80    200.00   13.80
+   48.85    171.00   13.00
+   48.90    192.00   13.50
+   48.95    178.00   13.30
+   49.00    169.00   12.70
+   49.05    160.00   12.70
+   49.10    182.00   13.20
+   49.15    173.00   13.20
+   49.20    170.00   12.80
+   49.25    181.00   13.60
+   49.30    170.00   12.90
+   49.35    164.00   13.00
+   49.40    166.00   12.70
+   49.45    174.00   13.40
+   49.50    173.00   13.10
+   49.55    137.00   11.90
+   49.60    166.00   12.80
+   49.65    194.00   14.20
+   49.70    160.00   12.60
+   49.75    152.00   12.50
+   49.80    180.00   13.30
+   49.85    160.00   12.90
+   49.90    149.00   12.20
+   49.95    172.00   13.40
+   50.00    170.00   13.00
+   50.05    175.00   13.50
+   50.10    162.00   12.70
+   50.15    168.00   13.20
+   50.20    186.00   13.60
+   50.25    179.00   13.60
+   50.30    165.00   12.70
+   50.35    155.00   12.60
+   50.40    170.00   12.90
+   50.45    162.00   12.80
+   50.50    157.00   12.30
+   50.55    173.00   13.20
+   50.60    149.00   12.00
+   50.65    167.00   13.00
+   50.70    165.00   12.60
+   50.75    157.00   12.50
+   50.80    177.00   13.00
+   50.85    187.00   13.60
+   50.90    155.00   12.10
+   50.95    194.00   13.70
+   51.00    147.00   11.70
+   51.05    169.00   12.80
+   51.10    166.00   12.40
+   51.15    193.00   13.60
+   51.20    168.00   12.40
+   51.25    188.00   13.40
+   51.30    182.00   12.80
+   51.35    180.00   13.10
+   51.40    177.00   12.70
+   51.45    188.00   13.30
+   51.50    187.00   13.00
+   51.55    178.00   12.90
+   51.60    177.00   12.60
+   51.65    184.00   13.10
+   51.70    172.00   12.40
+   51.75    188.00   13.30
+   51.80    194.00   13.20
+   51.85    179.00   12.90
+   51.90    176.00   12.50
+   51.95    180.00   12.90
+   52.00    169.00   12.20
+   52.05    178.00   12.90
+   52.10    165.00   12.10
+   52.15    149.00   11.70
+   52.20    168.00   12.20
+   52.25    157.00   12.10
+   52.30    151.00   11.60
+   52.35    181.00   13.00
+   52.40    172.00   12.40
+   52.45    178.00   12.90
+   52.50    179.00   12.60
+   52.55    171.00   12.60
+   52.60    129.00   10.70
+   52.65    180.00   13.00
+   52.70    154.00   11.70
+   52.75    182.00   13.10
+   52.80    166.00   12.20
+   52.85    156.00   12.10
+   52.90    164.00   12.10
+   52.95    166.00   12.50
+   53.00    176.00   12.50
+   53.05    182.00   13.10
+   53.10    173.00   12.50
+   53.15    160.00   12.30
+   53.20    169.00   12.30
+   53.25    162.00   12.30
+   53.30    164.00   12.10
+   53.35    165.00   12.40
+   53.40    177.00   12.60
+   53.45    173.00   12.80
+   53.50    158.00   11.90
+   53.55    164.00   12.40
+   53.60    175.00   12.50
+   53.65    166.00   12.50
+   53.70    161.00   12.00
+   53.75    167.00   12.50
+   53.80    136.00   11.00
+   53.85    167.00   12.50
+   53.90    152.00   11.70
+   53.95    159.00   12.20
+   54.00    172.00   12.40
+   54.05    179.00   12.90
+   54.10    169.00   12.20
+   54.15    165.00   12.40
+   54.20    166.00   12.10
+   54.25    162.00   12.30
+   54.30    175.00   12.40
+   54.35    162.00   12.30
+   54.40    145.00   11.40
+   54.45    148.00   11.70
+   54.50    157.00   11.80
+   54.55    176.00   12.80
+   54.60    162.00   12.00
+   54.65    153.00   12.00
+   54.70    178.00   12.60
+   54.75    147.00   11.80
+   54.80    146.00   11.50
+   54.85    170.00   12.70
+   54.90    155.00   11.80
+   54.95    170.00   12.70
+   55.00    142.00   11.30
+   55.05    154.00   12.10
+   55.10    150.00   11.70
+   55.15    145.00   11.80
+   55.20    151.00   11.80
+   55.25    162.00   12.50
+   55.30    153.00   11.90
+   55.35    170.00   12.90
+   55.40    153.00   11.90
+   55.45    156.00   12.40
+   55.50    163.00   12.40
+   55.55    149.00   12.20
+   55.60    135.00   11.30
+   55.65    158.00   12.60
+   55.70    144.00   11.70
+   55.75    152.00   12.40
+   55.80    165.00   12.70
+   55.85    164.00   13.00
+   55.90    175.00   13.10
+   55.95    150.00   12.40
+   56.00    168.00   12.90
+   56.05    159.00   12.90
+   56.10    187.00   13.60
+   56.15    170.00   13.30
+   56.20    159.00   12.60
+   56.25    148.00   12.50
+   56.30    159.00   12.60
+   56.35    174.00   13.50
+   56.40    195.00   14.00
+   56.45    219.00   15.10
+   56.50    216.00   14.70
+   56.55    271.00   16.80
+   56.60    337.00   18.30
+   56.65    417.00   20.80
+   56.70    390.00   19.70
+   56.75    414.00   20.70
+   56.80    388.00   19.60
+   56.85    317.00   18.10
+   56.90    307.00   17.40
+   56.95    250.00   16.00
+   57.00    205.00   14.20
+   57.05    167.00   13.00
+   57.10    179.00   13.20
+   57.15    159.00   12.70
+   57.20    170.00   12.80
+   57.25    168.00   13.00
+   57.30    180.00   13.10
+   57.35    144.00   12.00
+   57.40    178.00   13.00
+   57.45    203.00   14.20
+   57.50    159.00   12.30
+   57.55    165.00   12.80
+   57.60    164.00   12.40
+   57.65    135.00   11.60
+   57.70    157.00   12.20
+   57.75    162.00   12.70
+   57.80    175.00   12.90
+   57.85    161.00   12.60
+   57.90    174.00   12.80
+   57.95    187.00   13.70
+   58.00    164.00   12.50
+   58.05    188.00   13.70
+   58.10    163.00   12.40
+   58.15    177.00   13.30
+   58.20    181.00   13.10
+   58.25    156.00   12.50
+   58.30    163.00   12.40
+   58.35    190.00   13.80
+   58.40    162.00   12.40
+   58.45    186.00   13.70
+   58.50    169.00   12.70
+   58.55    160.00   12.70
+   58.60    171.00   12.80
+   58.65    160.00   12.60
+   58.70    174.00   12.90
+   58.75    163.00   12.70
+   58.80    180.00   13.10
+   58.85    176.00   13.20
+   58.90    174.00   12.80
+   58.95    177.00   13.30
+   59.00    186.00   13.30
+   59.05    157.00   12.40
+   59.10    188.00   13.30
+   59.15    162.00   12.60
+   59.20    160.00   12.20
+   59.25    196.00   13.90
+   59.30    178.00   12.90
+   59.35    188.00   13.50
+   59.40    161.00   12.30
+   59.45    157.00   12.30
+   59.50    183.00   13.00
+   59.55    169.00   12.80
+   59.60    150.00   11.80
+   59.65    195.00   13.70
+   59.70    175.00   12.70
+   59.75    160.00   12.40
+   59.80    168.00   12.40
+   59.85    191.00   13.50
+   59.90    181.00   12.80
+   59.95    168.00   12.70
+   60.00    181.00   12.80
+   60.05    158.00   12.20
+   60.10    160.00   12.00
+   60.15    151.00   12.00
+   60.20    171.00   12.40
+   60.25    167.00   12.60
+   60.30    160.00   12.00
+   60.35    157.00   12.10
+   60.40    172.00   12.40
+   60.45    140.00   11.50
+   60.50    172.00   12.40
+   60.55    150.00   11.90
+   60.60    179.00   12.70
+   60.65    153.00   12.00
+   60.70    170.00   12.40
+   60.75    184.00   13.10
+   60.80    158.00   11.90
+   60.85    177.00   12.90
+   60.90    159.00   12.00
+   60.95    157.00   12.20
+   61.00    168.00   12.30
+   61.05    154.00   12.00
+   61.10    170.00   12.40
+   61.15    147.00   11.80
+   61.20    161.00   12.10
+   61.25    175.00   12.90
+   61.30    170.00   12.40
+   61.35    153.00   12.10
+   61.40    165.00   12.30
+   61.45    164.00   12.50
+   61.50    174.00   12.60
+   61.55    160.00   12.40
+   61.60    188.00   13.20
+   61.65    182.00   13.30
+   61.70    197.00   13.50
+   61.75    163.00   12.60
+   61.80    176.00   12.80
+   61.85    157.00   12.40
+   61.90    166.00   12.40
+   61.95    173.00   13.10
+   62.00    167.00   12.50
+   62.05    175.00   13.20
+   62.10    143.00   11.60
+   62.15    148.00   12.10
+   62.20    178.00   13.00
+   62.25    180.00   13.40
+   62.30    141.00   11.60
+   62.35    202.00   14.30
+   62.40    172.00   12.80
+   62.45    169.00   13.00
+   62.50    143.00   11.80
+   62.55    146.00   12.20
+   62.60    169.00   12.80
+   62.65    146.00   12.30
+   62.70    156.00   12.30
+   62.75    147.00   12.30
+   62.80    158.00   12.40
+   62.85    178.00   13.50
+   62.90    163.00   12.60
+   62.95    168.00   13.10
+   63.00    164.00   12.60
+   63.05    180.00   13.60
+   63.10    189.00   13.60
+   63.15    164.00   12.90
+   63.20    181.00   13.20
+   63.25    179.00   13.50
+   63.30    147.00   11.90
+   63.35    179.00   13.50
+   63.40    150.00   12.00
+   63.45    168.00   12.90
+   63.50    156.00   12.20
+   63.55    181.00   13.40
+   63.60    170.00   12.70
+   63.65    181.00   13.30
+   63.70    184.00   13.10
+   63.75    153.00   12.20
+   63.80    166.00   12.40
+   63.85    166.00   12.60
+   63.90    169.00   12.50
+   63.95    175.00   12.90
+   64.00    157.00   12.00
+   64.05    165.00   12.40
+   64.10    169.00   12.30
+   64.15    164.00   12.40
+   64.20    181.00   12.80
+   64.25    189.00   13.30
+   64.30    179.00   12.60
+   64.35    157.00   12.10
+   64.40    189.00   13.00
+   64.45    167.00   12.50
+   64.50    178.00   12.50
+   64.55    144.00   11.60
+   64.60    180.00   12.60
+   64.65    182.00   12.90
+   64.70    199.00   13.20
+   64.75    172.00   12.60
+   64.80    191.00   12.90
+   64.85    166.00   12.30
+   64.90    157.00   11.70
+   64.95    197.00   13.50
+   65.00    204.00   13.40
+   65.05    183.00   13.00
+   65.10    189.00   12.90
+   65.15    189.00   13.20
+   65.20    170.00   12.20
+   65.25    188.00   13.20
+   65.30    176.00   12.40
+   65.35    172.00   12.60
+   65.40    182.00   12.70
+   65.45    205.00   13.80
+   65.50    191.00   13.00
+   65.55    192.00   13.30
+   65.60    190.00   12.90
+   65.65    194.00   13.40
+   65.70    212.00   13.70
+   65.75    221.00   14.30
+   65.80    227.00   14.20
+   65.85    227.00   14.60
+   65.90    239.00   14.60
+   65.95    261.00   15.60
+   66.00    301.00   16.40
+   66.05    409.00   19.60
+   66.10    559.00   22.30
+   66.15    820.00   27.80
+   66.20   1276.00   33.90
+   66.25   1776.00   41.00
+   66.30   2322.00   45.70
+   66.35   2880.00   52.20
+   66.40   3051.00   52.50
+   66.45   2980.00   53.10
+   66.50   2572.00   48.20
+   66.55   1961.00   43.20
+   66.60   1315.00   34.50
+   66.65    919.00   29.60
+   66.70    548.00   22.40
+   66.75    405.00   19.70
+   66.80    299.00   16.50
+   66.85    309.00   17.20
+   66.90    279.00   15.90
+   66.95    281.00   16.40
+   67.00    235.00   14.70
+   67.05    239.00   15.10
+   67.10    212.00   14.00
+   67.15    228.00   14.80
+   67.20    231.00   14.50
+   67.25    198.00   13.80
+   67.30    223.00   14.30
+   67.35    201.00   13.90
+   67.40    208.00   13.80
+   67.45    207.00   14.10
+   67.50    217.00   14.10
+   67.55    196.00   13.70
+   67.60    182.00   12.90
+   67.65    182.00   13.20
+   67.70    186.00   13.10
+   67.75    176.00   13.00
+   67.80    192.00   13.30
+   67.85    215.00   14.50
+   67.90    178.00   12.90
+   67.95    191.00   13.70
+   68.00    178.00   12.90
+   68.05    185.00   13.50
+   68.10    171.00   12.70
+   68.15    174.00   13.30
+   68.20    193.00   13.60
+   68.25    182.00   13.60
+   68.30    178.00   13.10
+   68.35    196.00   14.10
+   68.40    178.00   13.10
+   68.45    173.00   13.30
+   68.50    175.00   13.10
+   68.55    178.00   13.60
+   68.60    177.00   13.20
+   68.65    176.00   13.60
+   68.70    200.00   14.10
+   68.75    177.00   13.60
+   68.80    185.00   13.60
+   68.85    167.00   13.20
+   68.90    158.00   12.60
+   68.95    176.00   13.60
+   69.00    192.00   13.80
+   69.05    174.00   13.50
+   69.10    154.00   12.40
+   69.15    153.00   12.70
+   69.20    167.00   12.90
+   69.25    168.00   13.30
+   69.30    167.00   12.90
+   69.35    163.00   13.10
+   69.40    157.00   12.50
+   69.45    185.00   13.90
+   69.50    151.00   12.30
+   69.55    176.00   13.50
+   69.60    187.00   13.60
+   69.65    170.00   13.20
+   69.70    164.00   12.70
+   69.75    204.00   14.50
+   69.80    169.00   12.80
+   69.85    191.00   13.90
+   69.90    177.00   13.10
+   69.95    157.00   12.60
+   70.00    173.00   12.80
+   70.05    199.00   14.10
+   70.10    168.00   12.60
+   70.15    191.00   13.70
+   70.20    165.00   12.40
+   70.25    156.00   12.30
+   70.30    163.00   12.30
+   70.35    149.00   12.00
+   70.40    199.00   13.60
+   70.45    158.00   12.30
+   70.50    158.00   12.10
+   70.55    150.00   12.00
+   70.60    197.00   13.50
+   70.65    167.00   12.60
+   70.70    180.00   12.80
+   70.75    187.00   13.40
+   70.80    190.00   13.20
+   70.85    169.00   12.70
+   70.90    214.00   14.00
+   70.95    188.00   13.50
+   71.00    200.00   13.50
+   71.05    186.00   13.30
+   71.10    169.00   12.40
+   71.15    166.00   12.60
+   71.20    175.00   12.60
+   71.25    170.00   12.80
+   71.30    191.00   13.20
+   71.35    185.00   13.30
+   71.40    191.00   13.20
+   71.45    181.00   13.20
+   71.50    188.00   13.10
+   71.55    164.00   12.60
+   71.60    185.00   13.00
+   71.65    168.00   12.70
+   71.70    168.00   12.40
+   71.75    167.00   12.60
+   71.80    158.00   12.00
+   71.85    173.00   12.90
+   71.90    177.00   12.70
+   71.95    193.00   13.60
+   72.00    190.00   13.20
+   72.05    174.00   12.90
+   72.10    161.00   12.10
+   72.15    147.00   11.80
+   72.20    165.00   12.30
+   72.25    188.00   13.40
+   72.30    172.00   12.50
+   72.35    176.00   12.90
+   72.40    167.00   12.30
+   72.45    186.00   13.30
+   72.50    178.00   12.70
+   72.55    158.00   12.20
+   72.60    168.00   12.30
+   72.65    180.00   13.10
+   72.70    154.00   11.80
+   72.75    162.00   12.40
+   72.80    168.00   12.30
+   72.85    194.00   13.50
+   72.90    164.00   12.10
+   72.95    169.00   12.60
+   73.00    160.00   12.00
+   73.05    164.00   12.50
+   73.10    171.00   12.40
+   73.15    169.00   12.60
+   73.20    167.00   12.30
+   73.25    150.00   12.00
+   73.30    173.00   12.50
+   73.35    183.00   13.20
+   73.40    169.00   12.40
+   73.45    180.00   13.10
+   73.50    173.00   12.50
+   73.55    195.00   13.70
+   73.60    178.00   12.80
+   73.65    193.00   13.60
+   73.70    179.00   12.80
+   73.75    153.00   12.20
+   73.80    169.00   12.40
+   73.85    165.00   12.60
+   73.90    172.00   12.60
+   73.95    171.00   12.80
+   74.00    178.00   12.80
+   74.05    180.00   13.20
+   74.10    168.00   12.50
+   74.15    169.00   12.80
+   74.20    190.00   13.20
+   74.25    170.00   12.80
+   74.30    178.00   12.80
+   74.35    158.00   12.40
+   74.40    185.00   13.10
+   74.45    181.00   13.30
+   74.50    173.00   12.70
+   74.55    163.00   12.60
+   74.60    184.00   13.10
+   74.65    181.00   13.40
+   74.70    192.00   13.50
+   74.75    166.00   12.90
+   74.80    168.00   12.60
+   74.85    200.00   14.20
+   74.90    188.00   13.40
+   74.95    190.00   13.90
+   75.00    211.00   14.30
+   75.05    172.00   13.20
+   75.10    198.00   13.90
+   75.15    230.00   15.40
+   75.20    264.00   16.10
+   75.25    227.00   15.20
+   75.30    289.00   16.80
+   75.35    290.00   17.20
+   75.40    284.00   16.70
+   75.45    250.00   16.10
+   75.50    233.00   15.10
+   75.55    239.00   15.70
+   75.60    239.00   15.30
+   75.65    204.00   14.40
+   75.70    178.00   13.20
+   75.75    189.00   13.90
+   75.80    202.00   14.00
+   75.85    181.00   13.50
+   75.90    190.00   13.50
+   75.95    177.00   13.30
+   76.00    199.00   13.80
+   76.05    193.00   13.90
+   76.10    170.00   12.70
+   76.15    170.00   13.00
+   76.20    165.00   12.50
+   76.25    192.00   13.70
+   76.30    171.00   12.70
+   76.35    169.00   12.80
+   76.40    168.00   12.50
+   76.45    183.00   13.30
+   76.50    173.00   12.60
+   76.55    178.00   13.10
+   76.60    175.00   12.70
+   76.65    191.00   13.50
+   76.70    166.00   12.30
+   76.75    187.00   13.40
+   76.80    191.00   13.20
+   76.85    184.00   13.30
+   76.90    168.00   12.40
+   76.95    177.00   13.00
+   77.00    205.00   13.70
+   77.05    188.00   13.40
+   77.10    166.00   12.30
+   77.15    180.00   13.10
+   77.20    179.00   12.80
+   77.25    179.00   13.10
+   77.30    163.00   12.20
+   77.35    188.00   13.40
+   77.40    169.00   12.40
+   77.45    179.00   13.00
+   77.50    169.00   12.40
+   77.55    201.00   13.80
+   77.60    184.00   12.90
+   77.65    187.00   13.30
+   77.70    207.00   13.70
+   77.75    170.00   12.70
+   77.80    193.00   13.20
+   77.85    189.00   13.50
+   77.90    205.00   13.70
+   77.95    183.00   13.20
+   78.00    179.00   12.80
+   78.05    188.00   13.40
+   78.10    194.00   13.30
+   78.15    220.00   14.50
+   78.20    195.00   13.40
+   78.25    176.00   13.00
+   78.30    208.00   13.80
+   78.35    185.00   13.30
+   78.40    217.00   14.10
+   78.45    203.00   14.00
+   78.50    200.00   13.50
+   78.55    196.00   13.70
+   78.60    197.00   13.40
+   78.65    217.00   14.40
+   78.70    179.00   12.80
+   78.75    184.00   13.30
+   78.80    187.00   13.10
+   78.85    219.00   14.40
+   78.90    193.00   13.30
+   78.95    214.00   14.30
+   79.00    207.00   13.70
+   79.05    199.00   13.80
+   79.10    224.00   14.30
+   79.15    244.00   15.20
+   79.20    217.00   14.10
+   79.25    266.00   15.90
+   79.30    281.00   16.00
+   79.35    425.00   20.10
+   79.40    527.00   21.90
+   79.45    735.00   26.50
+   79.50   1057.00   31.10
+   79.55   1483.00   37.70
+   79.60   1955.00   42.20
+   79.65   2315.00   47.10
+   79.70   2552.00   48.30
+   79.75   2506.00   49.00
+   79.80   2261.00   45.50
+   79.85   1842.00   42.10
+   79.90   1328.00   34.90
+   79.95    911.00   29.60
+   80.00    592.00   23.40
+   80.05    430.00   20.40
+   80.10    312.00   17.00
+   80.15    284.00   16.60
+   80.20    285.00   16.20
+   80.25    247.00   15.50
+   80.30    250.00   15.20
+   80.35    231.00   15.00
+   80.40    272.00   15.90
+   80.45    235.00   15.20
+   80.50    188.00   13.20
+   80.55    223.00   14.80
+   80.60    218.00   14.30
+   80.65    221.00   14.80
+   80.70    210.00   14.10
+   80.75    199.00   14.00
+   80.80    207.00   14.00
+   80.85    208.00   14.40
+   80.90    178.00   13.00
+   80.95    194.00   14.00
+   81.00    202.00   13.90
+   81.05    226.00   15.10
+   81.10    209.00   14.20
+   81.15    194.00   14.10
+   81.20    179.00   13.20
+   81.25    183.00   13.70
+   81.30    187.00   13.50
+   81.35    198.00   14.30
+   81.40    198.00   14.00
+   81.45    209.00   14.70
+   81.50    187.00   13.60
+   81.55    211.00   14.90
+   81.60    198.00   14.10
+   81.65    164.00   13.10
+   81.70    200.00   14.10
+   81.75    212.00   14.90
+   81.80    197.00   14.00
+   81.85    191.00   14.20
+   81.90    195.00   14.00
+   81.95    217.00   15.10
+   82.00    189.00   13.80
+   82.05    182.00   13.80
+   82.10    174.00   13.20
+   82.15    182.00   13.80
+   82.20    199.00   14.00
+   82.25    179.00   13.60
+   82.30    197.00   13.90
+   82.35    228.00   15.30
+   82.40    170.00   12.90
+   82.45    203.00   14.40
+   82.50    232.00   15.10
+   82.55    178.00   13.50
+   82.60    216.00   14.50
+   82.65    205.00   14.30
+   82.70    185.00   13.30
+   82.75    212.00   14.60
+   82.80    199.00   13.70
+   82.85    169.00   12.90
+   82.90    165.00   12.50
+   82.95    203.00   14.10
+   83.00    215.00   14.20
+   83.05    199.00   13.90
+   83.10    200.00   13.60
+   83.15    174.00   12.90
+   83.20    192.00   13.30
+   83.25    206.00   14.10
+   83.30    191.00   13.20
+   83.35    203.00   13.90
+   83.40    210.00   13.90
+   83.45    194.00   13.60
+   83.50    245.00   14.90
+   83.55    242.00   15.10
+   83.60    255.00   15.20
+   83.65    310.00   17.10
+   83.70    408.00   19.20
+   83.75    498.00   21.70
+   83.80    729.00   25.60
+   83.85    934.00   29.60
+   83.90   1121.00   31.70
+   83.95   1320.00   35.20
+   84.00   1476.00   36.30
+   84.05   1276.00   34.60
+   84.10   1129.00   31.80
+   84.15    887.00   28.80
+   84.20    643.00   23.90
+   84.25    490.00   21.40
+   84.30    343.00   17.50
+   84.35    284.00   16.30
+   84.40    263.00   15.30
+   84.45    229.00   14.60
+   84.50    235.00   14.50
+   84.55    246.00   15.10
+   84.60    205.00   13.50
+   84.65    217.00   14.20
+   84.70    217.00   13.90
+   84.75    197.00   13.50
+   84.80    195.00   13.10
+   84.85    232.00   14.70
+   84.90    182.00   12.70
+   84.95    192.00   13.40
+   85.00    172.00   12.40
+   85.05    191.00   13.30
+   85.10    200.00   13.30
+   85.15    186.00   13.10
+   85.20    190.00   13.00
+   85.25    211.00   14.00
+   85.30    184.00   12.80
+   85.35    180.00   12.90
+   85.40    182.00   12.70
+   85.45    184.00   13.10
+   85.50    175.00   12.40
+   85.55    176.00   12.80
+   85.60    166.00   12.10
+   85.65    180.00   12.90
+   85.70    195.00   13.10
+   85.75    183.00   13.10
+   85.80    182.00   12.70
+   85.85    168.00   12.50
+   85.90    177.00   12.60
+   85.95    190.00   13.30
+   86.00    178.00   12.60
+   86.05    180.00   13.00
+   86.10    181.00   12.70
+   86.15    177.00   12.90
+   86.20    171.00   12.40
+   86.25    193.00   13.50
+   86.30    181.00   12.70
+   86.35    180.00   13.00
+   86.40    198.00   13.30
+   86.45    177.00   12.90
+   86.50    161.00   12.00
+   86.55    166.00   12.50
+   86.60    176.00   12.60
+   86.65    190.00   13.40
+   86.70    185.00   12.90
+   86.75    173.00   12.90
+   86.80    176.00   12.60
+   86.85    159.00   12.30
+   86.90    188.00   13.10
+   86.95    199.00   13.90
+   87.00    180.00   12.90
+   87.05    164.00   12.60
+   87.10    180.00   12.90
+   87.15    190.00   13.60
+   87.20    179.00   12.90
+   87.25    177.00   13.20
+   87.30    183.00   13.10
+   87.35    174.00   13.20
+   87.40    164.00   12.50
+   87.45    165.00   12.90
+   87.50    185.00   13.30
+   87.55    191.00   13.90
+   87.60    181.00   13.20
+   87.65    143.00   12.10
+   87.70    170.00   12.90
+   87.75    150.00   12.40
+   87.80    187.00   13.50
+   87.85    181.00   13.60
+   87.90    171.00   12.90
+   87.95    179.00   13.60
+   88.00    146.00   12.00
+   88.05    175.00   13.40
+   88.10    182.00   13.40
+   88.15    176.00   13.50
+   88.20    164.00   12.70
+   88.25    152.00   12.60
+   88.30    188.00   13.60
+   88.35    152.00   12.50
+   88.40    172.00   13.00
+   88.45    140.00   12.00
+   88.50    176.00   13.10
+   88.55    168.00   13.10
+   88.60    197.00   13.80
+   88.65    190.00   13.90
+   88.70    176.00   13.10
+   88.75    167.00   13.00
+   88.80    182.00   13.30
+   88.85    175.00   13.20
+   88.90    154.00   12.10
+   88.95    168.00   12.90
+   89.00    187.00   13.30
+   89.05    163.00   12.70
+   89.10    173.00   12.80
+   89.15    161.00   12.50
+   89.20    170.00   12.60
+   89.25    178.00   13.10
+   89.30    174.00   12.70
+   89.35    172.00   12.80
+   89.40    167.00   12.40
+   89.45    168.00   12.60
+   89.50    164.00   12.20
+   89.55    183.00   13.10
+   89.60    141.00   11.30
+   89.65    173.00   12.80
+   89.70    190.00   13.10
+   89.75    180.00   13.00
+   89.80    162.00   12.10
+   89.85    166.00   12.50
+   89.90    164.00   12.10
+   89.95    166.00   12.50
+   90.00    170.00   12.40
+   90.05    176.00   12.90
+   90.10    181.00   12.80
+   90.15    175.00   12.90
+   90.20    161.00   12.10
+   90.25    170.00   12.70
+   90.30    166.00   12.30
+   90.35    175.00   12.90
+   90.40    171.00   12.50
+   90.45    172.00   12.80
+   90.50    183.00   12.90
+   90.55    165.00   12.50
+   90.60    181.00   12.80
+   90.65    168.00   12.70
+   90.70    179.00   12.70
+   90.75    157.00   12.20
+   90.80    172.00   12.50
+   90.85    187.00   13.30
+   90.90    181.00   12.80
+   90.95    163.00   12.40
+   91.00    163.00   12.10
+   91.05    166.00   12.50
+   91.10    161.00   12.00
+   91.15    167.00   12.50
+   91.20    148.00   11.50
+   91.25    175.00   12.80
+   91.30    195.00   13.20
+   91.35    181.00   13.00
+   91.40    173.00   12.50
+   91.45    160.00   12.30
+   91.50    180.00   12.70
+   91.55    183.00   13.10
+   91.60    156.00   11.90
+   91.65    163.00   12.40
+   91.70    175.00   12.50
+   91.75    189.00   13.30
+   91.80    181.00   12.70
+   91.85    186.00   13.20
+   91.90    184.00   12.80
+   91.95    187.00   13.20
+   92.00    191.00   13.10
+   92.05    203.00   13.70
+   92.10    194.00   13.10
+   92.15    237.00   14.80
+   92.20    242.00   14.60
+   92.25    307.00   16.90
+   92.30    299.00   16.30
+   92.35    340.00   17.70
+   92.40    357.00   17.70
+   92.45    354.00   18.10
+   92.50    370.00   18.00
+   92.55    375.00   18.60
+   92.60    303.00   16.30
+   92.65    264.00   15.60
+   92.70    243.00   14.60
+   92.75    207.00   13.90
+   92.80    199.00   13.20
+   92.85    180.00   12.90
+   92.90    202.00   13.30
+   92.95    188.00   13.20
+   93.00    183.00   12.70
+   93.05    170.00   12.60
+   93.10    180.00   12.60
+   93.15    182.00   13.10
+   93.20    186.00   12.90
+   93.25    196.00   13.60
+   93.30    177.00   12.60
+   93.35    198.00   13.70
+   93.40    182.00   12.80
+   93.45    183.00   13.20
+   93.50    184.00   12.90
+   93.55    181.00   13.20
+   93.60    190.00   13.20
+   93.65    176.00   13.10
+   93.70    197.00   13.50
+   93.75    174.00   13.10
+   93.80    159.00   12.20
+   93.85    171.00   13.00
+   93.90    159.00   12.20
+   93.95    170.00   13.00
+   94.00    172.00   12.70
+   94.05    159.00   12.60
+   94.10    160.00   12.30
+   94.15    173.00   13.20
+   94.20    147.00   11.90
+   94.25    143.00   12.00
+   94.30    150.00   12.00
+   94.35    155.00   12.50
+   94.40    160.00   12.40
+   94.45    155.00   12.60
+   94.50    176.00   13.00
+   94.55    198.00   14.20
+   94.60    179.00   13.20
+   94.65    161.00   12.80
+   94.70    175.00   13.10
+   94.75    157.00   12.70
+   94.80    173.00   13.00
+   94.85    168.00   13.10
+   94.90    171.00   12.90
+   94.95    173.00   13.20
+   95.00    183.00   13.30
+   95.05    148.00   12.20
+   95.10    160.00   12.40
+   95.15    171.00   13.10
+   95.20    167.00   12.60
+   95.25    195.00   13.90
+   95.30    175.00   12.90
+   95.35    200.00   14.10
+   95.40    176.00   12.90
+   95.45    175.00   13.10
+   95.50    194.00   13.50
+   95.55    190.00   13.60
+   95.60    154.00   12.00
+   95.65    166.00   12.70
+   95.70    164.00   12.30
+   95.75    166.00   12.60
+   95.80    162.00   12.20
+   95.85    183.00   13.20
+   95.90    149.00   11.60
+   95.95    171.00   12.80
+   96.00    165.00   12.30
+   96.05    181.00   13.10
+   96.10    188.00   13.00
+   96.15    184.00   13.20
+   96.20    162.00   12.10
+   96.25    163.00   12.40
+   96.30    165.00   12.20
+   96.35    183.00   13.10
+   96.40    182.00   12.80
+   96.45    156.00   12.10
+   96.50    159.00   11.90
+   96.55    139.00   11.40
+   96.60    165.00   12.10
+   96.65    164.00   12.40
+   96.70    184.00   12.80
+   96.75    159.00   12.10
+   96.80    159.00   11.90
+   96.85    155.00   12.00
+   96.90    162.00   12.00
+   96.95    157.00   12.00
+   97.00    160.00   11.90
+   97.05    168.00   12.50
+   97.10    168.00   12.20
+   97.15    151.00   11.80
+   97.20    162.00   11.90
+   97.25    163.00   12.20
+   97.30    166.00   12.10
+   97.35    161.00   12.20
+   97.40    158.00   11.80
+   97.45    151.00   11.80
+   97.50    163.00   12.00
+   97.55    179.00   12.80
+   97.60    166.00   12.10
+   97.65    155.00   11.90
+   97.70    160.00   11.80
+   97.75    152.00   11.80
+   97.80    184.00   12.70
+   97.85    175.00   12.60
+   97.90    161.00   11.80
+   97.95    166.00   12.30
+   98.00    150.00   11.40
+   98.05    179.00   12.80
+   98.10    184.00   12.70
+   98.15    151.00   11.80
+   98.20    173.00   12.30
+   98.25    164.00   12.30
+   98.30    178.00   12.50
+   98.35    176.00   12.80
+   98.40    162.00   11.90
+   98.45    173.00   12.70
+   98.50    154.00   11.60
+   98.55    184.00   13.10
+   98.60    142.00   11.20
+   98.65    184.00   13.00
+   98.70    156.00   11.70
+   98.75    177.00   12.80
+   98.80    163.00   12.00
+   98.85    173.00   12.70
+   98.90    180.00   12.70
+   98.95    181.00   13.00
+   99.00    165.00   12.10
+   99.05    177.00   12.90
+   99.10    155.00   11.80
+   99.15    147.00   11.70
+   99.20    163.00   12.10
+   99.25    172.00   12.70
+   99.30    145.00   11.40
+   99.35    156.00   12.10
+   99.40    161.00   12.00
+   99.45    189.00   13.50
+   99.50    182.00   12.90
+   99.55    172.00   12.80
+   99.60    176.00   12.70
+   99.65    166.00   12.60
+   99.70    190.00   13.20
+   99.75    154.00   12.20
+   99.80    198.00   13.50
+   99.85    152.00   12.20
+   99.90    160.00   12.20
+   99.95    174.00   13.00
+  100.00    187.00   13.20
+  100.05    178.00   13.20
+  100.10    149.00   11.80
+  100.15    171.00   13.00
+  100.20    185.00   13.20
+  100.25    207.00   14.40
+  100.30    184.00   13.20
+  100.35    187.00   13.70
+  100.40    231.00   14.90
+  100.45    226.00   15.10
+  100.50    203.00   14.00
+  100.55    214.00   14.80
+  100.60    279.00   16.50
+  100.65    319.00   18.10
+  100.70    397.00   19.70
+  100.75    435.00   21.20
+  100.80    539.00   23.00
+  100.85    665.00   26.30
+  100.90    724.00   26.80
+  100.95    723.00   27.50
+  101.00    783.00   27.90
+  101.05    719.00   27.50
+  101.10    585.00   24.20
+  101.15    465.00   22.10
+  101.20    371.00   19.30
+  101.25    328.00   18.50
+  101.30    277.00   16.70
+  101.35    248.00   16.10
+  101.40    209.00   14.40
+  101.45    221.00   15.10
+  101.50    198.00   14.00
+  101.55    203.00   14.50
+  101.60    188.00   13.60
+  101.65    207.00   14.50
+  101.70    195.00   13.80
+  101.75    170.00   13.10
+  101.80    192.00   13.60
+  101.85    172.00   13.10
+  101.90    185.00   13.30
+  101.95    183.00   13.40
+  102.00    211.00   14.10
+  102.05    147.00   12.00
+  102.10    176.00   12.80
+  102.15    186.00   13.40
+  102.20    171.00   12.60
+  102.25    169.00   12.70
+  102.30    192.00   13.20
+  102.35    215.00   14.30
+  102.40    146.00   11.50
+  102.45    169.00   12.60
+  102.50    188.00   13.10
+  102.55    175.00   12.80
+  102.60    165.00   12.20
+  102.65    184.00   13.10
+  102.70    172.00   12.40
+  102.75    179.00   13.00
+  102.80    163.00   12.10
+  102.85    167.00   12.50
+  102.90    179.00   12.70
+  102.95    171.00   12.70
+  103.00    181.00   12.70
+  103.05    171.00   12.70
+  103.10    180.00   12.70
+  103.15    173.00   12.80
+  103.20    167.00   12.20
+  103.25    186.00   13.20
+  103.30    176.00   12.50
+  103.35    191.00   13.40
+  103.40    170.00   12.30
+  103.45    167.00   12.50
+  103.50    165.00   12.10
+  103.55    182.00   13.00
+  103.60    173.00   12.40
+  103.65    186.00   13.20
+  103.70    161.00   12.00
+  103.75    166.00   12.40
+  103.80    157.00   11.80
+  103.85    170.00   12.50
+  103.90    183.00   12.70
+  103.95    179.00   12.90
+  104.00    164.00   12.00
+  104.05    169.00   12.50
+  104.10    161.00   11.90
+  104.15    156.00   12.00
+  104.20    163.00   12.00
+  104.25    174.00   12.70
+  104.30    161.00   11.90
+  104.35    169.00   12.50
+  104.40    158.00   11.80
+  104.45    180.00   12.90
+  104.50    171.00   12.30
+  104.55    165.00   12.30
+  104.60    163.00   12.00
+  104.65    172.00   12.60
+  104.70    164.00   12.00
+  104.75    174.00   12.60
+  104.80    178.00   12.50
+  104.85    154.00   11.90
+  104.90    176.00   12.40
+  104.95    142.00   11.40
+  105.00    163.00   12.00
+  105.05    177.00   12.80
+  105.10    194.00   13.00
+  105.15    176.00   12.70
+  105.20    207.00   13.50
+  105.25    158.00   12.10
+  105.30    151.00   11.50
+  105.35    183.00   13.00
+  105.40    159.00   11.80
+  105.45    179.00   12.90
+  105.50    170.00   12.20
+  105.55    192.00   13.30
+  105.60    160.00   11.90
+  105.65    168.00   12.40
+  105.70    183.00   12.70
+  105.75    163.00   12.30
+  105.80    162.00   11.90
+  105.85    182.00   12.90
+  105.90    154.00   11.60
+  105.95    180.00   12.90
+  106.00    168.00   12.20
+  106.05    166.00   12.40
+  106.10    155.00   11.70
+  106.15    190.00   13.30
+  106.20    165.00   12.10
+  106.25    163.00   12.30
+  106.30    183.00   12.80
+  106.35    165.00   12.50
+  106.40    173.00   12.50
+  106.45    163.00   12.50
+  106.50    151.00   11.70
+  106.55    198.00   13.80
+  106.60    165.00   12.20
+  106.65    157.00   12.30
+  106.70    159.00   12.10
+  106.75    177.00   13.10
+  106.80    156.00   12.00
+  106.85    182.00   13.40
+  106.90    181.00   13.00
+  106.95    158.00   12.50
+  107.00    176.00   12.80
+  107.05    163.00   12.70
+  107.10    156.00   12.10
+  107.15    213.00   14.60
+  107.20    172.00   12.80
+  107.25    170.00   13.00
+  107.30    168.00   12.60
+  107.35    169.00   13.00
+  107.40    169.00   12.70
+  107.45    168.00   13.00
+  107.50    155.00   12.10
+  107.55    164.00   12.80
+  107.60    168.00   12.70
+  107.65    144.00   12.00
+  107.70    166.00   12.60
+  107.75    172.00   13.10
+  107.80    156.00   12.20
+  107.85    154.00   12.40
+  107.90    143.00   11.60
+  107.95    152.00   12.30
+  108.00    174.00   12.80
+  108.05    168.00   12.80
+  108.10    164.00   12.40
+  108.15    160.00   12.50
+  108.20    176.00   12.80
+  108.25    174.00   13.00
+  108.30    175.00   12.70
+  108.35    163.00   12.60
+  108.40    169.00   12.50
+  108.45    180.00   13.10
+  108.50    159.00   12.00
+  108.55    173.00   12.80
+  108.60    148.00   11.60
+  108.65    169.00   12.60
+  108.70    167.00   12.30
+  108.75    168.00   12.50
+  108.80    175.00   12.50
+  108.85    163.00   12.30
+  108.90    164.00   12.10
+  108.95    189.00   13.30
+  109.00    192.00   13.10
+  109.05    181.00   13.00
+  109.10    202.00   13.40
+  109.15    190.00   13.30
+  109.20    163.00   12.00
+  109.25    216.00   14.10
+  109.30    220.00   14.00
+  109.35    230.00   14.60
+  109.40    255.00   15.00
+  109.45    253.00   15.30
+  109.50    273.00   15.50
+  109.55    296.00   16.50
+  109.60    300.00   16.30
+  109.65    331.00   17.50
+  109.70    347.00   17.50
+  109.75    349.00   18.00
+  109.80    341.00   17.40
+  109.85    332.00   17.50
+  109.90    298.00   16.20
+  109.95    259.00   15.50
+  110.00    227.00   14.10
+  110.05    203.00   13.70
+  110.10    222.00   14.00
+  110.15    175.00   12.70
+  110.20    183.00   12.70
+  110.25    197.00   13.50
+  110.30    176.00   12.40
+  110.35    179.00   12.90
+  110.40    176.00   12.50
+  110.45    178.00   12.80
+  110.50    210.00   13.60
+  110.55    181.00   13.00
+  110.60    167.00   12.20
+  110.65    165.00   12.40
+  110.70    172.00   12.30
+  110.75    175.00   12.80
+  110.80    177.00   12.50
+  110.85    194.00   13.40
+  110.90    171.00   12.30
+  110.95    177.00   12.80
+  111.00    188.00   12.90
+  111.05    175.00   12.80
+  111.10    194.00   13.10
+  111.15    179.00   12.90
+  111.20    171.00   12.30
+  111.25    165.00   12.40
+  111.30    183.00   12.70
+  111.35    184.00   13.00
+  111.40    187.00   12.90
+  111.45    178.00   12.80
+  111.50    172.00   12.30
+  111.55    179.00   12.90
+  111.60    205.00   13.40
+  111.65    168.00   12.50
+  111.70    161.00   11.90
+  111.75    182.00   13.00
+  111.80    167.00   12.20
+  111.85    193.00   13.40
+  111.90    188.00   12.90
+  111.95    204.00   13.80
+  112.00    179.00   12.60
+  112.05    176.00   12.80
+  112.10    185.00   12.80
+  112.15    174.00   12.70
+  112.20    175.00   12.50
+  112.25    198.00   13.60
+  112.30    199.00   13.30
+  112.35    207.00   13.90
+  112.40    204.00   13.50
+  112.45    180.00   13.00
+  112.50    137.00   11.10
+  112.55    179.00   13.00
+  112.60    183.00   12.80
+  112.65    166.00   12.60
+  112.70    166.00   12.30
+  112.75    189.00   13.40
+  112.80    181.00   12.80
+  112.85    194.00   13.60
+  112.90    171.00   12.50
+  112.95    202.00   13.90
+  113.00    216.00   14.10
+  113.05    198.00   14.00
+  113.10    189.00   13.30
+  113.15    170.00   13.00
+  113.20    182.00   13.10
+  113.25    195.00   14.00
+  113.30    177.00   13.00
+  113.35    180.00   13.50
+  113.40    195.00   13.70
+  113.45    201.00   14.30
+  113.50    203.00   14.00
+  113.55    200.00   14.30
+  113.60    209.00   14.20
+  113.65    231.00   15.40
+  113.70    281.00   16.60
+  113.75    287.00   17.20
+  113.80    324.00   17.80
+  113.85    395.00   20.20
+  113.90    457.00   21.20
+  113.95    580.00   24.40
+  114.00    685.00   26.00
+  114.05    873.00   30.00
+  114.10    964.00   30.80
+  114.15   1126.00   34.00
+  114.20   1266.00   35.20
+  114.25   1307.00   36.50
+  114.30   1221.00   34.50
+  114.35   1096.00   33.30
+  114.40    978.00   30.70
+  114.45    792.00   28.20
+  114.50    600.00   24.00
+  114.55    487.00   22.00
+  114.60    358.00   18.50
+  114.65    279.00   16.60
+  114.70    265.00   15.80
+  114.75    258.00   15.90
+  114.80    244.00   15.10
+  114.85    226.00   14.80
+  114.90    227.00   14.50
+  114.95    188.00   13.50
+  115.00    195.00   13.40
+  115.05    211.00   14.20
+  115.10    205.00   13.70
+  115.15    198.00   13.70
+  115.20    218.00   14.00
+  115.25    200.00   13.70
+  115.30    200.00   13.40
+  115.35    188.00   13.30
+  115.40    209.00   13.70
+  115.45    184.00   13.10
+  115.50    186.00   12.90
+  115.55    202.00   13.70
+  115.60    183.00   12.70
+  115.65    187.00   13.10
+  115.70    182.00   12.60
+  115.75    185.00   13.10
+  115.80    213.00   13.70
+  115.85    177.00   12.80
+  115.90    199.00   13.20
+  115.95    185.00   13.00
+  116.00    184.00   12.70
+  116.05    191.00   13.30
+  116.10    173.00   12.30
+  116.15    196.00   13.50
+  116.20    201.00   13.30
+  116.25    173.00   12.70
+  116.30    178.00   12.60
+  116.35    161.00   12.30
+  116.40    208.00   13.60
+  116.45    183.00   13.10
+  116.50    183.00   12.80
+  116.55    173.00   12.80
+  116.60    184.00   12.80
+  116.65    215.00   14.20
+  116.70    201.00   13.40
+  116.75    193.00   13.40
+  116.80    190.00   13.00
+  116.85    216.00   14.20
+  116.90    195.00   13.10
+  116.95    203.00   13.80
+  117.00    183.00   12.80
+  117.05    203.00   13.70
+  117.10    187.00   12.90
+  117.15    216.00   14.20
+  117.20    191.00   13.00
+  117.25    189.00   13.30
+  117.30    189.00   13.00
+  117.35    226.00   14.50
+  117.40    185.00   12.90
+  117.45    194.00   13.50
+  117.50    185.00   12.80
+  117.55    213.00   14.10
+  117.60    197.00   13.30
+  117.65    198.00   14.50
+  117.70    168.00   13.00
+  117.75    209.00   14.90
+  117.80    185.00   13.70
+  117.85    208.00   14.90
+  117.90    213.00   14.70
+  117.95    203.00   14.70
+  118.00    225.00   15.10
+  118.05    214.00   15.10
+  118.10    233.00   15.40
+  118.15    245.00   16.20
+  118.20    236.00   15.50
+  118.25    245.00   16.20
+  118.30    305.00   17.60
+  118.35    287.00   17.10
+  118.40    317.00   17.40
+  118.45    421.00   20.60
+  118.50    422.00   20.10
+  118.55    590.00   24.40
+  118.60    701.00   26.80
+  118.65    861.00   28.60
+  118.70   1054.00   31.00
+  118.75   1232.00   34.30
+  118.80   1483.00   36.80
+  118.85   1694.00   40.30
+  118.90   1819.00   40.80
+  118.95   1845.00   42.30
+  119.00   1866.00   41.50
+  119.05   1726.00   41.00
+  119.10   1492.00   37.20
+  119.15   1232.00   34.80
+  119.20    971.00   30.10
+  119.25    753.00   27.20
+  119.30    626.00   24.20
+  119.35    487.00   21.90
+  119.40    409.00   19.60
+  119.45    342.00   18.50
+  119.50    307.00   17.10
+  119.55    296.00   17.20
+  119.60    231.00   14.90
+  119.65    246.00   15.80
+  119.70    220.00   14.50
+  119.75    255.00   16.10
+  119.80    214.00   14.40
+  119.85    247.00   15.90
+  119.90    238.00   15.20
+  119.95    218.00   15.00
+  120.00    222.00   14.70
+  120.05    218.00   15.00
+  120.10    253.00   15.80
+  120.15    197.00   14.30
+  120.20    190.00   13.60
+  120.25    221.00   15.10
+  120.30    204.00   14.20
+  120.35    206.00   14.60
+  120.40    189.00   13.60
+  120.45    231.00   15.40
+  120.50    190.00   13.60
+  120.55    191.00   13.90
+  120.60    211.00   14.30
+  120.65    204.00   14.30
+  120.70    200.00   13.90
+  120.75    199.00   14.10
+  120.80    190.00   13.50
+  120.85    195.00   13.90
+  120.90    179.00   13.00
+  120.95    189.00   13.60
+  121.00    190.00   13.30
+  121.05    195.00   13.80
+  121.10    193.00   13.40
+  121.15    173.00   12.80
+  121.20    183.00   13.00
+  121.25    181.00   13.10
+  121.30    203.00   13.50
+  121.35    177.00   12.90
+  121.40    201.00   13.40
+  121.45    179.00   12.90
+  121.50    179.00   12.60
+  121.55    194.00   13.40
+  121.60    158.00   11.90
+  121.65    195.00   13.40
+  121.70    201.00   13.40
+  121.75    192.00   13.40
+  121.80    189.00   13.00
+  121.85    186.00   13.10
+  121.90    170.00   12.30
+  121.95    166.00   12.40
+  122.00    185.00   12.80
+  122.05    197.00   13.60
+  122.10    177.00   12.60
+  122.15    198.00   13.60
+  122.20    174.00   12.50
+  122.25    171.00   12.60
+  122.30    190.00   13.00
+  122.35    214.00   14.20
+  122.40    189.00   13.00
+  122.45    174.00   12.80
+  122.50    171.00   12.40
+  122.55    163.00   12.40
+  122.60    174.00   12.40
+  122.65    177.00   12.80
+  122.70    180.00   12.60
+  122.75    186.00   13.10
+  122.80    190.00   13.00
+  122.85    170.00   12.60
+  122.90    175.00   12.50
+  122.95    194.00   13.40
+  123.00    175.00   12.50
+  123.05    194.00   13.40
+  123.10    189.00   12.90
+  123.15    222.00   14.30
+  123.20    178.00   12.50
+  123.25    158.00   12.10
+  123.30    191.00   13.00
+  123.35    184.00   13.00
+  123.40    190.00   12.90
+  123.45    183.00   13.00
+  123.50    178.00   12.50
+  123.55    204.00   13.70
+  123.60    192.00   13.00
+  123.65    200.00   13.50
+  123.70    182.00   12.60
+  123.75    171.00   12.50
+  123.80    186.00   12.70
+  123.85    197.00   13.40
+  123.90    174.00   12.30
+  123.95    167.00   12.30
+  124.00    178.00   12.40
+  124.05    198.00   13.40
+  124.10    205.00   13.30
+  124.15    216.00   14.00
+  124.20    200.00   13.20
+  124.25    204.00   13.60
+  124.30    190.00   12.80
+  124.35    188.00   13.10
+  124.40    191.00   12.90
+  124.45    186.00   13.00
+  124.50    175.00   12.30
+  124.55    175.00   12.60
+  124.60    174.00   12.30
+  124.65    194.00   13.30
+  124.70    181.00   12.50
+  124.75    161.00   12.10
+  124.80    186.00   12.70
+  124.85    200.00   13.50
+  124.90    168.00   12.10
+  124.95    177.00   12.70
+  125.00    188.00   12.80
+  125.05    177.00   12.70
+  125.10    163.00   11.90
+  125.15    175.00   12.70
+  125.20    188.00   12.80
+  125.25    176.00   12.80
+  125.30    172.00   12.30
+  125.35    172.00   12.60
+  125.40    181.00   12.70
+  125.45    186.00   13.20
+  125.50    181.00   12.70
+  125.55    193.00   13.40
+  125.60    177.00   12.60
+  125.65    176.00   12.90
+  125.70    194.00   13.20
+  125.75    179.00   13.00
+  125.80    147.00   11.50
+  125.85    186.00   13.30
+  125.90    182.00   12.90
+  125.95    165.00   12.70
+  126.00    164.00   12.30
+  126.05    199.00   13.90
+  126.10    167.00   12.40
+  126.15    184.00   13.40
+  126.20    203.00   13.80
+  126.25    190.00   13.70
+  126.30    182.00   13.10
+  126.35    180.00   13.40
+  126.40    179.00   13.00
+  126.45    179.00   13.40
+  126.50    170.00   12.70
+  126.55    176.00   13.30
+  126.60    178.00   13.10
+  126.65    185.00   13.70
+  126.70    193.00   13.60
+  126.75    192.00   14.00
+  126.80    198.00   13.80
+  126.85    195.00   14.00
+  126.90    165.00   12.60
+  126.95    189.00   13.80
+  127.00    175.00   13.00
+  127.05    176.00   13.30
+  127.10    184.00   13.30
+  127.15    179.00   13.40
+  127.20    187.00   13.40
+  127.25    176.00   13.20
+  127.30    191.00   13.50
+  127.35    194.00   13.90
+  127.40    177.00   12.90
+  127.45    177.00   13.20
+  127.50    180.00   13.00
+  127.55    158.00   12.40
+  127.60    193.00   13.40
+  127.65    177.00   13.10
+  127.70    185.00   13.10
+  127.75    178.00   13.10
+  127.80    184.00   13.00
+  127.85    188.00   13.40
+  127.90    182.00   12.90
+  127.95    190.00   13.50
+  128.00    191.00   13.20
+  128.05    165.00   12.50
+  128.10    174.00   12.50
+  128.15    158.00   12.20
+  128.20    197.00   13.30
+  128.25    183.00   13.10
+  128.30    196.00   13.30
+  128.35    166.00   12.50
+  128.40    218.00   14.00
+  128.45    206.00   13.80
+  128.50    184.00   12.80
+  128.55    176.00   12.70
+  128.60    198.00   13.20
+  128.65    215.00   14.10
+  128.70    179.00   12.60
+  128.75    192.00   13.30
+  128.80    201.00   13.30
+  128.85    221.00   14.20
+  128.90    227.00   14.10
+  128.95    229.00   14.40
+  129.00    254.00   14.90
+  129.05    256.00   15.30
+  129.10    272.00   15.40
+  129.15    239.00   14.80
+  129.20    228.00   14.10
+  129.25    255.00   15.20
+  129.30    213.00   13.60
+  129.35    203.00   13.60
+  129.40    228.00   14.10
+  129.45    220.00   14.10
+  129.50    185.00   12.60
+  129.55    192.00   13.20
+  129.60    187.00   12.70
+  129.65    182.00   12.80
+  129.70    209.00   13.40
+  129.75    173.00   12.50
+  129.80    202.00   13.20
+  129.85    178.00   12.70
+  129.90    189.00   12.80
+  129.95    177.00   12.60
+  130.00    177.00   12.30
+  130.05    190.00   13.10
+  130.10    178.00   12.40
+  130.15    177.00   12.60
+  130.20    164.00   11.90
+  130.25    185.00   12.90
+  130.30    153.00   11.40
+  130.35    174.00   12.50
+  130.40    197.00   13.00
+  130.45    192.00   13.10
+  130.50    174.00   12.20
+  130.55    177.00   12.60
+  130.60    172.00   12.10
+  130.65    173.00   12.50
+  130.70    178.00   12.40
+  130.75    180.00   12.80
+  130.80    203.00   13.20
+  130.85    192.00   13.20
+  130.90    184.00   12.60
+  130.95    197.00   13.30
+  131.00    169.00   12.10
+  131.05    187.00   13.00
+  131.10    175.00   12.30
+  131.15    177.00   12.60
+  131.20    199.00   13.10
+  131.25    180.00   12.80
+  131.30    203.00   13.20
+  131.35    175.00   12.60
+  131.40    183.00   12.50
+  131.45    192.00   13.20
+  131.50    174.00   12.30
+  131.55    180.00   12.80
+  131.60    179.00   12.50
+  131.65    191.00   13.20
+  131.70    182.00   12.60
+  131.75    174.00   12.60
+  131.80    191.00   12.90
+  131.85    195.00   13.40
+  131.90    171.00   12.30
+  131.95    198.00   13.60
+  132.00    193.00   13.10
+  132.05    175.00   12.80
+  132.10    207.00   13.60
+  132.15    189.00   13.40
+  132.20    174.00   12.50
+  132.25    196.00   13.70
+  132.30    175.00   12.60
+  132.35    196.00   13.80
+  132.40    183.00   13.00
+  132.45    198.00   13.80
+  132.50    196.00   13.40
+  132.55    169.00   12.90
+  132.60    189.00   13.30
+  132.65    171.00   13.00
+  132.70    193.00   13.50
+  132.75    170.00   13.00
+  132.80    175.00   12.90
+  132.85    166.00   12.90
+  132.90    188.00   13.40
+  132.95    186.00   13.70
+  133.00    165.00   12.60
+  133.05    201.00   14.20
+  133.10    182.00   13.20
+  133.15    151.00   12.40
+  133.20    156.00   12.20
+  133.25    187.00   13.70
+  133.30    153.00   12.10
+  133.35    193.00   14.00
+  133.40    200.00   13.90
+  133.45    165.00   12.90
+  133.50    172.00   12.90
+  133.55    162.00   12.70
+  133.60    165.00   12.50
+  133.65    218.00   14.70
+  133.70    197.00   13.60
+  133.75    206.00   14.20
+  133.80    186.00   13.20
+  133.85    162.00   12.50
+  133.90    176.00   12.80
+  133.95    174.00   12.90
+  134.00    196.00   13.40
+  134.05    174.00   12.90
+  134.10    177.00   12.70
+  134.15    183.00   13.10
+  134.20    184.00   12.90
+  134.25    185.00   13.10
+  134.30    200.00   13.40
+  134.35    175.00   12.70
+  134.40    190.00   13.00
+  134.45    195.00   13.40
+  134.50    192.00   13.00
+  134.55    171.00   12.50
+  134.60    194.00   13.00
+  134.65    190.00   13.10
+  134.70    165.00   12.00
+  134.75    192.00   13.20
+  134.80    160.00   11.70
+  134.85    192.00   13.10
+  134.90    181.00   12.50
+  134.95    208.00   13.70
+  135.00    179.00   12.40
+  135.05    172.00   12.40
+  135.10    183.00   12.50
+  135.15    187.00   12.90
+  135.20    185.00   12.50
+  135.25    182.00   12.70
+  135.30    184.00   12.50
+  135.35    163.00   11.90
+  135.40    201.00   13.00
+  135.45    189.00   12.80
+  135.50    204.00   13.10
+  135.55    178.00   12.50
+  135.60    178.00   12.20
+  135.65    193.00   13.00
+  135.70    215.00   13.40
+  135.75    203.00   13.30
+  135.80    216.00   13.40
+  135.85    165.00   12.10
+  135.90    196.00   12.80
+  135.95    178.00   12.50
+  136.00    170.00   11.90
+  136.05    173.00   12.40
+  136.10    188.00   12.60
+  136.15    176.00   12.50
+  136.20    186.00   12.50
+  136.25    189.00   12.90
+  136.30    166.00   11.80
+  136.35    177.00   12.50
+  136.40    169.00   11.90
+  136.45    171.00   12.30
+  136.50    194.00   12.80
+  136.55    187.00   12.90
+  136.60    162.00   11.70
+  136.65    160.00   11.90
+  136.70    183.00   12.40
+  136.75    150.00   11.50
+  136.80    180.00   12.40
+  136.85    194.00   13.20
+  136.90    185.00   12.60
+  136.95    158.00   11.90
+  137.00    193.00   12.90
+  137.05    165.00   12.20
+  137.10    178.00   12.30
+  137.15    183.00   12.90
+  137.20    180.00   12.40
+  137.25    176.00   12.70
+  137.30    183.00   12.60
+  137.35    189.00   13.20
+  137.40    180.00   12.50
+  137.45    160.00   12.20
+  137.50    202.00   13.30
+  137.55    201.00   13.60
+  137.60    173.00   12.30
+  137.65    176.00   12.80
+  137.70    195.00   13.10
+  137.75    197.00   13.50
+  137.80    186.00   12.80
+  137.85    183.00   13.00
+  137.90    175.00   12.40
+  137.95    178.00   12.80
+  138.00    190.00   12.90
+  138.05    174.00   12.70
+  138.10    163.00   12.00
+  138.15    190.00   13.30
+  138.20    169.00   12.20
+  138.25    198.00   13.60
+  138.30    199.00   13.30
+  138.35    184.00   13.10
+  138.40    216.00   13.90
+  138.45    183.00   13.10
+  138.50    200.00   13.40
+  138.55    186.00   13.30
+  138.60    177.00   12.70
+  138.65    186.00   13.40
+  138.70    193.00   13.30
+  138.75    200.00   14.00
+  138.80    180.00   12.90
+  138.85    178.00   13.20
+  138.90    198.00   13.60
+  138.95    236.00   15.30
+  139.00    203.00   13.80
+  139.05    207.00   14.30
+  139.10    190.00   13.40
+  139.15    171.00   13.10
+  139.20    203.00   13.90
+  139.25    203.00   14.20
+  139.30    198.00   13.70
+  139.35    200.00   14.20
+  139.40    187.00   13.30
+  139.45    214.00   14.70
+  139.50    198.00   13.70
+  139.55    220.00   14.80
+  139.60    196.00   13.70
+  139.65    239.00   15.50
+  139.70    212.00   14.20
+  139.75    219.00   14.80
+  139.80    248.00   15.40
+  139.85    220.00   14.80
+  139.90    241.00   15.10
+  139.95    245.00   15.50
+  140.00    269.00   15.90
+  140.05    294.00   17.00
+  140.10    323.00   17.40
+  140.15    302.00   17.20
+  140.20    312.00   17.10
+  140.25    371.00   18.90
+  140.30    420.00   19.70
+  140.35    516.00   22.30
+  140.40    596.00   23.40
+  140.45    644.00   24.70
+  140.50    711.00   25.40
+  140.55    833.00   28.10
+  140.60    895.00   28.40
+  140.65   1010.00   30.70
+  140.70   1058.00   30.80
+  140.75   1183.00   33.10
+  140.80   1278.00   33.70
+  140.85   1298.00   34.60
+  140.90   1419.00   35.40
+  140.95   1381.00   35.60
+  141.00   1299.00   33.80
+  141.05   1371.00   35.40
+  141.10   1273.00   33.30
+  141.15   1131.00   32.10
+  141.20    992.00   29.40
+  141.25    918.00   28.90
+  141.30    832.00   26.90
+  141.35    655.00   24.50
+  141.40    629.00   23.50
+  141.45    522.00   21.90
+  141.50    472.00   20.30
+  141.55    409.00   19.30
+  141.60    371.00   18.00
+  141.65    325.00   17.30
+  141.70    306.00   16.30
+  141.75    270.00   15.70
+  141.80    238.00   14.40
+  141.85    231.00   14.50
+  141.90    232.00   14.20
+  141.95    223.00   14.30
+  142.00    221.00   13.90
+  142.05    244.00   14.90
+  142.10    228.00   14.10
+  142.15    212.00   13.90
+  142.20    226.00   14.00
+  142.25    197.00   13.40
+  142.30    204.00   13.30
+  142.35    189.00   13.10
+  142.40    201.00   13.20
+  142.45    226.00   14.30
+  142.50    210.00   13.50
+  142.55    213.00   13.90
+  142.60    202.00   13.30
+  142.65    206.00   13.70
+  142.70    189.00   12.80
+  142.75    213.00   13.90
+  142.80    193.00   12.90
+  142.85    206.00   13.70
+  142.90    204.00   13.30
+  142.95    188.00   13.10
+  143.00    221.00   13.80
+  143.05    203.00   13.60
+  143.10    192.00   12.90
+  143.15    197.00   13.40
+  143.20    187.00   12.70
+  143.25    206.00   13.70
+  143.30    197.00   13.10
+  143.35    182.00   12.80
+  143.40    186.00   12.70
+  143.45    228.00   14.40
+  143.50    201.00   13.20
+  143.55    176.00   12.60
+  143.60    193.00   12.90
+  143.65    200.00   13.50
+  143.70    189.00   12.80
+  143.75    198.00   13.40
+  143.80    188.00   12.80
+  143.85    169.00   12.40
+  143.90    183.00   12.60
+  143.95    198.00   13.40
+  144.00    156.00   11.60
+  144.05    172.00   12.50
+  144.10    190.00   12.80
+  144.15    166.00   12.30
+  144.20    163.00   11.90
+  144.25    184.00   13.00
+  144.30    182.00   12.60
+  144.35    173.00   12.60
+  144.40    182.00   12.60
+  144.45    183.00   13.00
+  144.50    186.00   12.80
+  144.55    195.00   13.40
+  144.60    204.00   13.40
+  144.65    179.00   13.00
+  144.70    192.00   13.10
+  144.75    213.00   14.10
+  144.80    187.00   12.90
+  144.85    194.00   13.50
+  144.90    185.00   12.90
+  144.95    183.00   13.20
+  145.00    192.00   13.20
+  145.05    201.00   13.90
+  145.10    211.00   13.90
+  145.15    163.00   12.50
+  145.20    202.00   13.60
+  145.25    197.00   13.80
+  145.30    183.00   13.00
+  145.35    177.00   13.20
+  145.40    188.00   13.20
+  145.45    158.00   12.50
+  145.50    184.00   13.20
+  145.55    162.00   12.70
+  145.60    169.00   12.70
+  145.65    171.00   13.10
+  145.70    188.00   13.40
+  145.75    167.00   13.00
+  145.80    182.00   13.20
+  145.85    197.00   14.10
+  145.90    179.00   13.10
+  145.95    172.00   13.20
+  146.00    163.00   12.50
+  146.05    172.00   13.10
+  146.10    178.00   13.00
+  146.15    179.00   13.40
+  146.20    171.00   12.80
+  146.25    189.00   13.70
+  146.30    190.00   13.40
+  146.35    185.00   13.50
+  146.40    169.00   12.60
+  146.45    165.00   12.70
+  146.50    185.00   13.10
+  146.55    158.00   12.40
+  146.60    190.00   13.30
+  146.65    165.00   12.60
+  146.70    173.00   12.60
+  146.75    206.00   14.10
+  146.80    170.00   12.50
+  146.85    193.00   13.60
+  146.90    167.00   12.30
+  146.95    182.00   13.10
+  147.00    191.00   13.20
+  147.05    175.00   12.90
+  147.10    184.00   12.90
+  147.15    163.00   12.40
+  147.20    174.00   12.50
+  147.25    176.00   12.90
+  147.30    163.00   12.10
+  147.35    174.00   12.80
+  147.40    155.00   11.80
+  147.45    153.00   12.00
+  147.50    190.00   13.00
+  147.55    190.00   13.30
+  147.60    169.00   12.30
+  147.65    189.00   13.30
+  147.70    177.00   12.60
+  147.75    167.00   12.50
+  147.80    163.00   12.00
+  147.85    196.00   13.50
+  147.90    175.00   12.50
+  147.95    146.00   11.60
+  148.00    170.00   12.20
+  148.05    179.00   12.90
+  148.10    182.00   12.60
+  148.15    175.00   12.70
+  148.20    171.00   12.30
+  148.25    201.00   13.60
+  148.30    181.00   12.60
+  148.35    152.00   11.80
+  148.40    194.00   13.00
+  148.45    160.00   12.20
+  148.50    179.00   12.50
+  148.55    181.00   12.90
+  148.60    175.00   12.40
+  148.65    178.00   12.80
+  148.70    186.00   12.80
+  148.75    195.00   13.40
+  148.80    166.00   12.00
+  148.85    184.00   13.00
+  148.90    215.00   13.70
+  148.95    183.00   12.90
+  149.00    184.00   12.60
+  149.05    174.00   12.60
+  149.10    175.00   12.30
+  149.15    171.00   12.50
+  149.20    166.00   12.00
+  149.25    188.00   13.00
+  149.30    165.00   11.90
+  149.35    184.00   12.90
+  149.40    181.00   12.60
+  149.45    174.00   12.60
+  149.50    178.00   12.40
+  149.55    191.00   13.20
+  149.60    181.00   12.50
+  149.65    174.00   12.60
+  149.70    180.00   12.50
+  149.75    177.00   12.70
+  149.80    164.00   11.90
+  149.85    203.00   13.60
+  149.90    178.00   12.40
+  149.95    162.00   12.20
+  150.00    192.00   12.90
+  150.05    164.00   12.20
+  150.10    151.00   11.40
+  150.15    170.00   12.50
+  150.20    166.00   12.00
+  150.25    194.00   13.30
+  150.30    168.00   12.10
+  150.35    173.00   12.50
+  150.40    175.00   12.30
+  150.45    193.00   13.30
+  150.50    177.00   12.40
+  150.55    185.00   13.00
+  150.60    178.00   12.40
+  150.65    178.00   12.70
+  150.70    179.00   12.50
+  150.75    180.00   12.90
+  150.80    169.00   12.20
+  150.85    177.00   12.80
+  150.90    159.00   11.80
+  150.95    167.00   12.40
+  151.00    180.00   12.60
+  151.05    158.00   12.20
+  151.10    173.00   12.40
+  151.15    172.00   12.70
+  151.20    163.00   12.10
+  151.25    168.00   12.60
+  151.30    166.00   12.20
+  151.35    179.00   13.00
+  151.40    159.00   12.00
+  151.45    173.00   12.90
+  151.50    170.00   12.40
+  151.55    151.00   12.10
+  151.60    174.00   12.60
+  151.65    182.00   13.20
+  151.70    182.00   12.90
+  151.75    172.00   12.90
+  151.80    157.00   12.00
+  151.85    156.00   12.30
+  151.90    168.00   12.50
+  151.95    194.00   13.80
+  152.00    177.00   12.80
+  152.05    170.00   12.90
+  152.10    169.00   12.60
+  152.15    173.00   13.00
+  152.20    161.00   12.30
+  152.25    169.00   12.90
+  152.30    167.00   12.50
+  152.35    194.00   13.80
+  152.40    150.00   11.90
+  152.45    159.00   12.50
+  152.50    181.00   13.10
+  152.55    180.00   13.30
+  152.60    193.00   13.40
+  152.65    192.00   13.70
+  152.70    152.00   11.90
+  152.75    159.00   12.50
+  152.80    147.00   11.70
+  152.85    190.00   13.60
+  152.90    167.00   12.40
+  152.95    193.00   13.60
+  153.00    159.00   12.10
+  153.05    195.00   13.60
+  153.10    172.00   12.50
+  153.15    148.00   11.90
+  153.20    174.00   12.50
+  153.25    194.00   13.50
+  153.30    159.00   11.90
+  153.35    190.00   13.30
+  153.40    181.00   12.70
+  153.45    159.00   12.10
+  153.50    168.00   12.20
+  153.55    175.00   12.70
+  153.60    184.00   12.70
+  153.65    200.00   13.50
+  153.70    161.00   11.90
+  153.75    162.00   12.10
+  153.80    152.00   11.50
+  153.85    177.00   12.70
+  153.90    173.00   12.20
+  153.95    184.00   12.90
+  154.00    169.00   12.10
+  154.05    163.00   12.10
+  154.10    177.00   12.40
+  154.15    171.00   12.50
+  154.20    180.00   12.50
+  154.25    201.00   13.40
+  154.30    206.00   13.30
+  154.35    181.00   12.70
+  154.40    170.00   12.00
+  154.45    177.00   12.60
+  154.50    196.00   12.90
+  154.55    201.00   13.40
+  154.60    161.00   11.70
+  154.65    179.00   12.60
+  154.70    185.00   12.50
+  154.75    167.00   12.10
+  154.80    162.00   11.70
+  154.85    178.00   12.60
+  154.90    203.00   13.10
+  154.95    193.00   13.10
+  155.00    164.00   11.70
+  155.05    191.00   13.00
+  155.10    173.00   12.10
+  155.15    165.00   12.00
+  155.20    178.00   12.20
+  155.25    196.00   13.20
+  155.30    188.00   12.50
+  155.35    183.00   12.70
+  155.40    188.00   12.60
+  155.45    166.00   12.10
+  155.50    189.00   12.60
+  155.55    175.00   12.40
+  155.60    173.00   12.00
+  155.65    201.00   13.30
+  155.70    177.00   12.20
+  155.75    202.00   13.30
+  155.80    169.00   11.90
+  155.85    198.00   13.20
+  155.90    191.00   12.70
+  155.95    207.00   13.50
+  156.00    226.00   13.80
+  156.05    184.00   12.80
+  156.10    218.00   13.50
+  156.15    215.00   13.80
+  156.20    239.00   14.20
+  156.25    292.00   16.10
+  156.30    251.00   14.60
+  156.35    255.00   15.10
+  156.40    244.00   14.40
+  156.45    259.00   15.20
+  156.50    260.00   14.90
+  156.55    294.00   16.30
+  156.60    303.00   16.10
+  156.65    282.00   15.90
+  156.70    312.00   16.40
+  156.75    317.00   16.90
+  156.80    342.00   17.20
+  156.85    338.00   17.50
+  156.90    351.00   17.40
+  156.95    359.00   18.10
+  157.00    394.00   18.50
+  157.05    316.00   17.00
+  157.10    379.00   18.20
+  157.15    359.00   18.20
+  157.20    404.00   18.80
+  157.25    381.00   18.80
+  157.30    359.00   17.80
+  157.35    364.00   18.40
+  157.40    347.00   17.60
+  157.45    328.00   17.50
+  157.50    344.00   17.50
+  157.55    320.00   17.40
+  157.60    333.00   17.40
+  157.65    319.00   17.50
+  157.70    289.00   16.30
+  157.75    284.00   16.60
+  157.80    283.00   16.20
+  157.85    305.00   17.20
+  157.90    281.00   16.20
+  157.95    244.00   15.60
+  158.00    253.00   15.40
+  158.05    245.00   15.60
+  158.10    210.00   14.10
+  158.15    201.00   14.20
+  158.20    226.00   14.70
+  158.25    206.00   14.40
+  158.30    218.00   14.40
+  158.35    201.00   14.30
+  158.40    226.00   14.70
+  158.45    201.00   14.20
+  158.50    210.00   14.20
+  158.55    207.00   14.40
+  158.60    176.00   13.00
+  158.65    172.00   13.10
+  158.70    173.00   12.90
+  158.75    195.00   13.90
+  158.80    168.00   12.70
+  158.85    177.00   13.30
+  158.90    186.00   13.30
+  158.95    170.00   13.00
+  159.00    190.00   13.40
+  159.05    175.00   13.10
+  159.10    191.00   13.40
+  159.15    164.00   12.70
+  159.20    189.00   13.30
+  159.25    176.00   13.10
+  159.30    175.00   12.80
+  159.35    162.00   12.50
+  159.40    184.00   13.00
+  159.45    163.00   12.50
+  159.50    179.00   12.80
+  159.55    194.00   13.60
+  159.60    165.00   12.20
+  159.65    180.00   13.00
+  159.70    174.00   12.60
+  159.75    180.00   13.00
+  159.80    179.00   12.60
+  159.85    189.00   13.30
+  159.90    185.00   12.90
+  159.95    151.00   11.80
+  160.00    176.00   12.50
+  160.05    165.00   12.30
+  160.10    163.00   12.00
+  160.15    184.00   13.00
+  160.20    157.00   11.70
+  160.25    166.00   12.30
+  160.30    160.00   11.80
+  160.35    183.00   12.90
+  160.40    167.00   12.10
+  160.45    180.00   12.80
+  160.50    183.00   12.60
+  160.55    163.00   12.20
+  160.60    178.00   12.40
+  160.65    179.00   12.80
+  160.70    161.00   11.80
+  160.75    168.00   12.40
+  160.80    173.00   12.30
+  160.85    202.00   13.60
+  160.90    145.00   11.30
+  160.95    162.00   12.20
+  161.00    180.00   12.50
+  161.05    186.00   13.10
+  161.10    166.00   12.10
+  161.15    177.00   12.70
+  161.20    194.00   13.10
+  161.25    177.00   12.80
+  161.30    178.00   12.50
+  161.35    190.00   13.20
+  161.40    160.00   11.90
+  161.45    173.00   12.60
+  161.50    191.00   12.90
+  161.55    161.00   12.20
+  161.60    181.00   12.60
+  161.65    152.00   11.80
+  161.70    195.00   13.00
+  161.75    171.00   12.50
+  161.80    188.00   12.80
+  161.85    164.00   12.20
+  161.90    185.00   12.70
+  161.95    173.00   12.60
+  162.00    162.00   11.90
+  162.05    166.00   12.30
+  162.10    201.00   13.20
+  162.15    173.00   12.60
+  162.20    172.00   12.20
+  162.25    181.00   12.80
+  162.30    159.00   11.70
+  162.35    185.00   13.00
+  162.40    170.00   12.10
+  162.45    200.00   13.50
+  162.50    196.00   13.00
+  162.55    176.00   12.60
+  162.60    197.00   13.00
+  162.65    176.00   12.60
+  162.70    181.00   12.50
+  162.75    176.00   12.60
+  162.80    184.00   12.60
+  162.85    179.00   12.70
+  162.90    165.00   11.90
+  162.95    146.00   11.50
+  163.00    165.00   11.90
+  163.05    151.00   11.70
+  163.10    164.00   11.90
+  163.15    179.00   12.80
+  163.20    186.00   12.70
+  163.25    182.00   13.00
+  163.30    168.00   12.20
+  163.35    193.00   13.50
+  163.40    177.00   12.60
+  163.45    180.00   13.10
+  163.50    171.00   12.40
+  163.55    207.00   14.10
+  163.60    180.00   12.90
+  163.65    159.00   12.40
+  163.70    165.00   12.40
+  163.75    178.00   13.20
+  163.80    150.00   11.80
+  163.85    177.00   13.20
+  163.90    174.00   12.80
+  163.95    180.00   13.40
+  164.00    184.00   13.20
+  164.05    166.00   13.60
+  164.10    182.00   13.90
+  164.15    188.00   15.60
+  164.20    186.00   15.00
+  164.25    152.00   15.20
+  164.30    200.00   16.90
+  164.35    177.00   18.00
+  164.40    202.00   18.50
+  164.45    178.00   20.40
+  164.50    153.00   18.00
+  164.55    197.00   25.30
+  164.60    153.00   20.70
+  164.65    173.00   30.10
+  164.70    187.00   27.90
+  164.75    175.00   38.20
+  164.80    168.00   30.90
+  164.85    109.00   41.20
diff --git a/tutorials/ed-1.py b/tutorials/ed-1.py
index 38bacfda..51e4e8e6 100644
--- a/tutorials/ed-1.py
+++ b/tutorials/ed-1.py
@@ -2,8 +2,8 @@
 # # Structure Refinement: LBCO, HRPT
 #
 # This minimalistic example is designed to show how Rietveld refinement
-# of a crystal structure can be performed when both the sample model and
-# experiment are defined using CIF files.
+# can be performed when both the crystal structure and experiment
+# parameters are defined using CIF files.
 #
 # For this example, constant-wavelength neutron powder diffraction data
 # for La0.5Ba0.5CoO3 from HRPT at PSI is used.
@@ -33,14 +33,14 @@
 project = ed.Project()
 
 # %% [markdown]
-# ## Step 2: Define Sample Model
+# ## Step 2: Define Crystal Structure
 
 # %%
 # Download CIF file from repository
-model_path = ed.download_data(id=1, destination='data')
+structure_path = ed.download_data(id=1, destination='data')
 
 # %%
-project.sample_models.add(cif_path=model_path)
+project.structures.add_from_cif_path(structure_path)
 
 # %% [markdown]
 # ## Step 3: Define Experiment
@@ -50,7 +50,7 @@
 expt_path = ed.download_data(id=2, destination='data')
 
 # %%
-project.experiments.add(cif_path=expt_path)
+project.experiments.add_from_cif_path(expt_path)
 
 # %% [markdown]
 # ## Step 4: Perform Analysis
diff --git a/tutorials/ed-10.py b/tutorials/ed-10.py
index f3253429..1cad89ab 100644
--- a/tutorials/ed-10.py
+++ b/tutorials/ed-10.py
@@ -21,16 +21,16 @@
 project = ed.Project()
 
 # %% [markdown]
-# ## Add Sample Model
+# ## Add Structure
 
 # %%
-project.sample_models.add(name='ni')
+project.structures.create(name='ni')
 
 # %%
-project.sample_models['ni'].space_group.name_h_m = 'F m -3 m'
-project.sample_models['ni'].space_group.it_coordinate_system_code = '1'
-project.sample_models['ni'].cell.length_a = 3.52387
-project.sample_models['ni'].atom_sites.add(
+project.structures['ni'].space_group.name_h_m = 'F m -3 m'
+project.structures['ni'].space_group.it_coordinate_system_code = '1'
+project.structures['ni'].cell.length_a = 3.52387
+project.structures['ni'].atom_sites.create(
     label='Ni',
     type_symbol='Ni',
     fract_x=0.0,
@@ -47,7 +47,7 @@
 data_path = ed.download_data(id=6, destination='data')
 
 # %%
-project.experiments.add(
+project.experiments.add_from_data_path(
     name='pdf',
     data_path=data_path,
     sample_form='powder',
@@ -57,7 +57,7 @@
 )
 
 # %%
-project.experiments['pdf'].linked_phases.add(id='ni', scale=1.0)
+project.experiments['pdf'].linked_phases.create(id='ni', scale=1.0)
 project.experiments['pdf'].peak.damp_q = 0
 project.experiments['pdf'].peak.broad_q = 0.03
 project.experiments['pdf'].peak.cutoff_q = 27.0
@@ -69,8 +69,8 @@
 # ## Select Fitting Parameters
 
 # %%
-project.sample_models['ni'].cell.length_a.free = True
-project.sample_models['ni'].atom_sites['Ni'].b_iso.free = True
+project.structures['ni'].cell.length_a.free = True
+project.structures['ni'].atom_sites['Ni'].b_iso.free = True
 
 # %%
 project.experiments['pdf'].linked_phases['ni'].scale.free = True
@@ -81,7 +81,6 @@
 # ## Run Fitting
 
 # %%
-project.analysis.current_calculator = 'pdffit'
 project.analysis.fit()
 project.analysis.show_fit_results()
 
diff --git a/tutorials/ed-11.py b/tutorials/ed-11.py
index 4a5b3176..a16dbec7 100644
--- a/tutorials/ed-11.py
+++ b/tutorials/ed-11.py
@@ -30,17 +30,17 @@
 project.plotter.x_max = 40
 
 # %% [markdown]
-# ## Add Sample Model
+# ## Add Structure
 
 # %%
-project.sample_models.add(name='si')
+project.structures.create(name='si')
 
 # %%
-sample_model = project.sample_models['si']
-sample_model.space_group.name_h_m.value = 'F d -3 m'
-sample_model.space_group.it_coordinate_system_code = '1'
-sample_model.cell.length_a = 5.43146
-sample_model.atom_sites.add(
+structure = project.structures['si']
+structure.space_group.name_h_m.value = 'F d -3 m'
+structure.space_group.it_coordinate_system_code = '1'
+structure.cell.length_a = 5.43146
+structure.atom_sites.create(
     label='Si',
     type_symbol='Si',
     fract_x=0,
@@ -57,7 +57,7 @@
 data_path = ed.download_data(id=5, destination='data')
 
 # %%
-project.experiments.add(
+project.experiments.add_from_data_path(
     name='nomad',
     data_path=data_path,
     sample_form='powder',
@@ -68,7 +68,7 @@
 
 # %%
 experiment = project.experiments['nomad']
-experiment.linked_phases.add(id='si', scale=1.0)
+experiment.linked_phases.create(id='si', scale=1.0)
 experiment.peak.damp_q = 0.02
 experiment.peak.broad_q = 0.03
 experiment.peak.cutoff_q = 35.0
@@ -80,8 +80,8 @@
 # ## Select Fitting Parameters
 
 # %%
-project.sample_models['si'].cell.length_a.free = True
-project.sample_models['si'].atom_sites['Si'].b_iso.free = True
+project.structures['si'].cell.length_a.free = True
+project.structures['si'].atom_sites['Si'].b_iso.free = True
 experiment.linked_phases['si'].scale.free = True
 
 # %%
@@ -94,7 +94,6 @@
 # ## Run Fitting
 
 # %%
-project.analysis.current_calculator = 'pdffit'
 project.analysis.fit()
 project.analysis.show_fit_results()
 
diff --git a/tutorials/ed-12.py b/tutorials/ed-12.py
index c40cc5ad..b6701709 100644
--- a/tutorials/ed-12.py
+++ b/tutorials/ed-12.py
@@ -34,16 +34,16 @@
 project.plotter.x_max = 30.0
 
 # %% [markdown]
-# ## Add Sample Model
+# ## Add Structure
 
 # %%
-project.sample_models.add(name='nacl')
+project.structures.create(name='nacl')
 
 # %%
-project.sample_models['nacl'].space_group.name_h_m = 'F m -3 m'
-project.sample_models['nacl'].space_group.it_coordinate_system_code = '1'
-project.sample_models['nacl'].cell.length_a = 5.62
-project.sample_models['nacl'].atom_sites.add(
+project.structures['nacl'].space_group.name_h_m = 'F m -3 m'
+project.structures['nacl'].space_group.it_coordinate_system_code = '1'
+project.structures['nacl'].cell.length_a = 5.62
+project.structures['nacl'].atom_sites.create(
     label='Na',
     type_symbol='Na',
     fract_x=0,
@@ -52,7 +52,7 @@
     wyckoff_letter='a',
     b_iso=1.0,
 )
-project.sample_models['nacl'].atom_sites.add(
+project.structures['nacl'].atom_sites.create(
     label='Cl',
     type_symbol='Cl',
     fract_x=0.5,
@@ -69,7 +69,7 @@
 data_path = ed.download_data(id=4, destination='data')
 
 # %%
-project.experiments.add(
+project.experiments.add_from_data_path(
     name='xray_pdf',
     data_path=data_path,
     sample_form='powder',
@@ -96,15 +96,15 @@
 project.experiments['xray_pdf'].peak.damp_particle_diameter = 0
 
 # %%
-project.experiments['xray_pdf'].linked_phases.add(id='nacl', scale=0.5)
+project.experiments['xray_pdf'].linked_phases.create(id='nacl', scale=0.5)
 
 # %% [markdown]
 # ## Select Fitting Parameters
 
 # %%
-project.sample_models['nacl'].cell.length_a.free = True
-project.sample_models['nacl'].atom_sites['Na'].b_iso.free = True
-project.sample_models['nacl'].atom_sites['Cl'].b_iso.free = True
+project.structures['nacl'].cell.length_a.free = True
+project.structures['nacl'].atom_sites['Na'].b_iso.free = True
+project.structures['nacl'].atom_sites['Cl'].b_iso.free = True
 
 # %%
 project.experiments['xray_pdf'].linked_phases['nacl'].scale.free = True
@@ -115,7 +115,6 @@
 # ## Run Fitting
 
 # %%
-project.analysis.current_calculator = 'pdffit'
 project.analysis.fit()
 project.analysis.show_fit_results()
 
diff --git a/tutorials/ed-13.py b/tutorials/ed-13.py
index 1411ba42..e29aad49 100644
--- a/tutorials/ed-13.py
+++ b/tutorials/ed-13.py
@@ -52,11 +52,11 @@
 #
 # In EasyDiffraction, a project serves as a container for all
 # information related to the analysis of a specific experiment or set of
-# experiments. It enables you to organize your data, experiments, sample
-# models, and fitting parameters in a structured manner. You can think
-# of it as a folder containing all the essential details about your
-# analysis. The project also allows us to visualize both the measured
-# and calculated diffraction patterns, among other things.
+# experiments. It enables you to organize your data, experiments,
+# crystal structures, and fitting parameters in an organized manner. You
+# can think of it as a folder containing all the essential details about
+# your analysis. The project also allows us to visualize both the
+# measured and calculated diffraction patterns, among other things.
 
 # %% [markdown] tags=["doc-link"]
 # 📖 See
@@ -120,7 +120,7 @@
 # for more details about different types of experiments.
 
 # %%
-project_1.experiments.add(
+project_1.experiments.add_from_data_path(
     name='sim_si',
     data_path=si_xye_path,
     sample_form='powder',
@@ -185,8 +185,8 @@
 # for more details about excluding regions from the measured data.
 
 # %%
-project_1.experiments['sim_si'].excluded_regions.add(id='1', start=0, end=55000)
-project_1.experiments['sim_si'].excluded_regions.add(id='2', start=105500, end=200000)
+project_1.experiments['sim_si'].excluded_regions.create(id='1', start=0, end=55000)
+project_1.experiments['sim_si'].excluded_regions.create(id='2', start=105500, end=200000)
 
 # %% [markdown]
 # To visualize the effect of excluding the high TOF region, we can plot
@@ -355,25 +355,25 @@
 
 # %%
 project_1.experiments['sim_si'].background_type = 'line-segment'
-project_1.experiments['sim_si'].background.add(id='1', x=50000, y=0.01)
-project_1.experiments['sim_si'].background.add(id='2', x=60000, y=0.01)
-project_1.experiments['sim_si'].background.add(id='3', x=70000, y=0.01)
-project_1.experiments['sim_si'].background.add(id='4', x=80000, y=0.01)
-project_1.experiments['sim_si'].background.add(id='5', x=90000, y=0.01)
-project_1.experiments['sim_si'].background.add(id='6', x=100000, y=0.01)
-project_1.experiments['sim_si'].background.add(id='7', x=110000, y=0.01)
+project_1.experiments['sim_si'].background.create(id='1', x=50000, y=0.01)
+project_1.experiments['sim_si'].background.create(id='2', x=60000, y=0.01)
+project_1.experiments['sim_si'].background.create(id='3', x=70000, y=0.01)
+project_1.experiments['sim_si'].background.create(id='4', x=80000, y=0.01)
+project_1.experiments['sim_si'].background.create(id='5', x=90000, y=0.01)
+project_1.experiments['sim_si'].background.create(id='6', x=100000, y=0.01)
+project_1.experiments['sim_si'].background.create(id='7', x=110000, y=0.01)
 
 # %% [markdown]
-# ### 🧩 Create a Sample Model – Si
+# ### 🧩 Create a Structure – Si
 #
-# After setting up the experiment, we need to create a sample model that
+# After setting up the experiment, we need to create a structure that
 # describes the crystal structure of the sample being analyzed.
 #
-# In this case, we will create a sample model for silicon (Si) with a
-# cubic crystal structure. The sample model contains information about
+# In this case, we will create a structure for silicon (Si) with a
+# cubic crystal structure. The structure contains information about
 # the space group, lattice parameters, atomic positions of the atoms in
 # the unit cell, atom types, occupancies and atomic displacement
-# parameters. The sample model is essential for the fitting process, as
+# parameters. The structure is essential for the fitting process, as
 # it is used to calculate the expected diffraction pattern.
 #
 # EasyDiffraction refines the crystal structure of the sample, but does
@@ -435,54 +435,54 @@
 
 # %% [markdown]
 # As with adding the experiment in the previous step, we will create a
-# default sample model and then modify its parameters to match the Si
+# default structure and then modify its parameters to match the Si
 # structure.
 
 # %% [markdown] tags=["doc-link"]
 # 📖 See
-# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/model/)
-# for more details about sample models and their purpose in the data
+# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/)
+# for more details about structures and their purpose in the data
 # analysis workflow.
 
 # %% [markdown]
-# #### Add Sample Model
+# #### Add Structure
 
 # %%
-project_1.sample_models.add(name='si')
+project_1.structures.create(name='si')
 
 # %% [markdown]
 # #### Set Space Group
 
 # %% [markdown] tags=["doc-link"]
 # 📖 See
-# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/model/#space-group-category)
+# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/#space-group-category)
 # for more details about the space group.
 
 # %%
-project_1.sample_models['si'].space_group.name_h_m = 'F d -3 m'
-project_1.sample_models['si'].space_group.it_coordinate_system_code = '2'
+project_1.structures['si'].space_group.name_h_m = 'F d -3 m'
+project_1.structures['si'].space_group.it_coordinate_system_code = '2'
 
 # %% [markdown]
 # #### Set Lattice Parameters
 
 # %% [markdown] tags=["doc-link"]
 # 📖 See
-# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/model/#cell-category)
+# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/#cell-category)
 # for more details about the unit cell parameters.
 
 # %%
-project_1.sample_models['si'].cell.length_a = 5.43
+project_1.structures['si'].cell.length_a = 5.43
 
 # %% [markdown]
 # #### Set Atom Sites
 
 # %% [markdown] tags=["doc-link"]
 # 📖 See
-# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/model/#atom-sites-category)
+# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/#atom-sites-category)
 # for more details about the atom sites category.
 
 # %%
-project_1.sample_models['si'].atom_sites.add(
+project_1.structures['si'].atom_sites.create(
     label='Si',
     type_symbol='Si',
     fract_x=0,
@@ -493,29 +493,29 @@
 )
 
 # %% [markdown]
-# ### 🔗 Assign Sample Model to Experiment
+# ### 🔗 Assign Structure to Experiment
 #
-# Now we need to assign, or link, this sample model to the experiment
+# Now we need to assign, or link, this structure to the experiment
 # created above. This linked crystallographic phase will be used to
 # calculate the expected diffraction pattern based on the crystal
-# structure defined in the sample model.
+# structure defined in the structure.
 
 # %% [markdown] tags=["doc-link"]
 # 📖 See
 # [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#linked-phases-category)
-# for more details about linking a sample model to an experiment.
+# for more details about linking a structure to an experiment.
 
 # %%
-project_1.experiments['sim_si'].linked_phases.add(id='si', scale=1.0)
+project_1.experiments['sim_si'].linked_phases.create(id='si', scale=1.0)
 
 # %% [markdown]
 # ### 🚀 Analyze and Fit the Data
 #
-# After setting up the experiment and sample model, we can now analyze
+# After setting up the experiment and structure, we can now analyze
 # the measured diffraction pattern and perform the fit. Building on the
 # analogies from the EasyScience library and the previous notebooks, we
 # can say that all the parameters we introduced earlier — those defining
-# the sample model (crystal structure parameters) and the experiment
+# the structure (crystal structure parameters) and the experiment
 # (instrument, background, and peak profile parameters) — together form
 # the complete set of parameters that can be refined during the fitting
 # process.
@@ -528,12 +528,12 @@
 # %% [markdown] **Reminder:**
 #
 # The fitting process involves comparing the measured diffraction
-# pattern with the calculated diffraction pattern based on the sample
-# model and instrument parameters. The goal is to adjust the parameters
-# of the sample model and the experiment to minimize the difference
-# between the measured and calculated diffraction patterns. This is done
-# by refining the parameters of the sample model and the instrument
-# settings to achieve a better fit.
+# pattern with the calculated diffraction pattern based on the crystal
+# structure and instrument parameters. The goal is to adjust the
+# parameters of the structure and the experiment to minimize the
+# difference between the measured and calculated diffraction patterns.
+# This is done by refining the parameters of the structure and the
+# instrument settings to achieve a better fit.
 
 # %% [markdown] tags=["doc-link"]
 # 📖 See
@@ -593,7 +593,7 @@
 #
 # Before performing the fit, we can visually compare the measured
 # diffraction pattern with the calculated diffraction pattern based on
-# the initial parameters of the sample model and the instrument. This
+# the initial parameters of the structure and the instrument. This
 # provides an indication of how well the initial parameters match the
 # measured data. The `plot_meas_vs_calc` method of the project allows
 # this comparison.
@@ -689,7 +689,7 @@
 #
 # Before moving on, we can save the project to disk for later use. This
 # will preserve the entire project structure, including experiments,
-# sample models, and fitting results. The project is saved into a
+# structures, and fitting results. The project is saved into a
 # directory specified by the `dir_path` attribute of the project object.
 
 # %%
@@ -753,7 +753,7 @@
 # reduced data file is missing.
 lbco_xye_path = ed.download_data(id=18, destination=data_dir)
 
-project_2.experiments.add(
+project_2.experiments.add_from_data_path(
     name='sim_lbco',
     data_path=lbco_xye_path,
     sample_form='powder',
@@ -783,8 +783,8 @@
 # %% tags=["solution", "hide-input"]
 project_2.plot_meas(expt_name='sim_lbco')
 
-project_2.experiments['sim_lbco'].excluded_regions.add(id='1', start=0, end=55000)
-project_2.experiments['sim_lbco'].excluded_regions.add(id='2', start=105500, end=200000)
+project_2.experiments['sim_lbco'].excluded_regions.create(id='1', start=0, end=55000)
+project_2.experiments['sim_lbco'].excluded_regions.create(id='2', start=105500, end=200000)
 
 project_2.plot_meas(expt_name='sim_lbco')
 
@@ -861,20 +861,20 @@
 
 # %% tags=["solution", "hide-input"]
 project_2.experiments['sim_lbco'].background_type = 'line-segment'
-project_2.experiments['sim_lbco'].background.add(id='1', x=50000, y=0.2)
-project_2.experiments['sim_lbco'].background.add(id='2', x=60000, y=0.2)
-project_2.experiments['sim_lbco'].background.add(id='3', x=70000, y=0.2)
-project_2.experiments['sim_lbco'].background.add(id='4', x=80000, y=0.2)
-project_2.experiments['sim_lbco'].background.add(id='5', x=90000, y=0.2)
-project_2.experiments['sim_lbco'].background.add(id='6', x=100000, y=0.2)
-project_2.experiments['sim_lbco'].background.add(id='7', x=110000, y=0.2)
+project_2.experiments['sim_lbco'].background.create(id='1', x=50000, y=0.2)
+project_2.experiments['sim_lbco'].background.create(id='2', x=60000, y=0.2)
+project_2.experiments['sim_lbco'].background.create(id='3', x=70000, y=0.2)
+project_2.experiments['sim_lbco'].background.create(id='4', x=80000, y=0.2)
+project_2.experiments['sim_lbco'].background.create(id='5', x=90000, y=0.2)
+project_2.experiments['sim_lbco'].background.create(id='6', x=100000, y=0.2)
+project_2.experiments['sim_lbco'].background.create(id='7', x=110000, y=0.2)
 
 # %% [markdown]
-# ### 🧩 Exercise 3: Define a Sample Model – LBCO
+# ### 🧩 Exercise 3: Define a Structure – LBCO
 #
-# The LBSO structure is not as simple as the Si model, as it contains
+# The LBSO structure is not as simple as the Si one, as it contains
 # multiple atoms in the unit cell. It is not in COD, so we give you the
-# structural parameters in CIF format to create the sample model.
+# structural parameters in CIF format to create the structure.
 #
 # Note that those parameters are not necessarily the most accurate ones,
 # but they are a good starting point for the fit. The aim of the study
@@ -914,7 +914,7 @@
 # Note that the `occupancy` of the La and Ba atoms is 0.5
 # and those atoms are located in the same position (0, 0, 0) in the unit
 # cell. This means that an extra attribute `occupancy` needs to be set
-# for those atoms later in the sample model.
+# for those atoms later in the structure.
 #
 # We model the La/Ba site using the virtual crystal approximation. In
 # this approach, the scattering is taken as a weighted average of La and
@@ -936,9 +936,9 @@
 #    of the random case and the extra peaks of the ordered case.
 
 # %% [markdown]
-# #### Exercise 3.1: Create Sample Model
+# #### Exercise 3.1: Create Structure
 #
-# Add a sample model for LBCO to the project. The sample model
+# Add a structure for LBCO to the project. The structure
 # parameters will be set in the next exercises.
 
 # %% [markdown]
@@ -946,19 +946,19 @@
 
 # %% [markdown] tags=["dmsc-school-hint"]
 # You can use the same approach as in the previous part of the notebook,
-# but this time you need to use the model name corresponding to the LBCO
+# but this time you need to use the name corresponding to the LBCO
 # structure, e.g. 'lbco'.
 
 # %% [markdown]
 # **Solution:**
 
 # %% tags=["solution", "hide-input"]
-project_2.sample_models.add(name='lbco')
+project_2.structures.create(name='lbco')
 
 # %% [markdown]
 # #### Exercise 3.2: Set Space Group
 #
-# Set the space group for the LBCO sample model.
+# Set the space group for the LBCO structure.
 
 # %% [markdown]
 # **Hint:**
@@ -971,13 +971,13 @@
 # **Solution:**
 
 # %% tags=["solution", "hide-input"]
-project_2.sample_models['lbco'].space_group.name_h_m = 'P m -3 m'
-project_2.sample_models['lbco'].space_group.it_coordinate_system_code = '1'
+project_2.structures['lbco'].space_group.name_h_m = 'P m -3 m'
+project_2.structures['lbco'].space_group.it_coordinate_system_code = '1'
 
 # %% [markdown]
 # #### Exercise 3.3: Set Lattice Parameters
 #
-# Set the lattice parameters for the LBCO sample model.
+# Set the lattice parameters for the LBCO structure.
 
 # %% [markdown]
 # **Hint:**
@@ -989,25 +989,25 @@
 # **Solution:**
 
 # %% tags=["solution", "hide-input"]
-project_2.sample_models['lbco'].cell.length_a = 3.88
+project_2.structures['lbco'].cell.length_a = 3.88
 
 # %% [markdown]
 # #### Exercise 3.4: Set Atom Sites
 #
-# Set the atom sites for the LBCO sample model.
+# Set the atom sites for the LBCO structure.
 
 # %% [markdown]
 # **Hint:**
 
 # %% [markdown] tags=["dmsc-school-hint"]
 # Use the atom sites from the CIF data. You can use the `add` method of
-# the `atom_sites` attribute of the sample model to add the atom sites.
+# the `atom_sites` attribute of the structure to add the atom sites.
 
 # %% [markdown]
 # **Solution:**
 
 # %% tags=["solution", "hide-input"]
-project_2.sample_models['lbco'].atom_sites.add(
+project_2.structures['lbco'].atom_sites.create(
     label='La',
     type_symbol='La',
     fract_x=0,
@@ -1017,7 +1017,7 @@
     b_iso=0.95,
     occupancy=0.5,
 )
-project_2.sample_models['lbco'].atom_sites.add(
+project_2.structures['lbco'].atom_sites.create(
     label='Ba',
     type_symbol='Ba',
     fract_x=0,
@@ -1027,7 +1027,7 @@
     b_iso=0.95,
     occupancy=0.5,
 )
-project_2.sample_models['lbco'].atom_sites.add(
+project_2.structures['lbco'].atom_sites.create(
     label='Co',
     type_symbol='Co',
     fract_x=0.5,
@@ -1036,7 +1036,7 @@
     wyckoff_letter='b',
     b_iso=0.80,
 )
-project_2.sample_models['lbco'].atom_sites.add(
+project_2.structures['lbco'].atom_sites.create(
     label='O',
     type_symbol='O',
     fract_x=0,
@@ -1047,22 +1047,22 @@
 )
 
 # %% [markdown]
-# ### 🔗 Exercise 4: Assign Sample Model to Experiment
+# ### 🔗 Exercise 4: Assign Structure to Experiment
 #
-# Now assign the LBCO sample model to the experiment created above.
+# Now assign the LBCO structure to the experiment created above.
 
 # %% [markdown]
 # **Hint:**
 
 # %% [markdown] tags=["dmsc-school-hint"]
-# Use the `linked_phases` attribute of the experiment to link the sample
-# model.
+# Use the `linked_phases` attribute of the experiment to link the
+# crystal structure.
 
 # %% [markdown]
 # **Solution:**
 
 # %% tags=["solution", "hide-input"]
-project_2.experiments['sim_lbco'].linked_phases.add(id='lbco', scale=1.0)
+project_2.experiments['sim_lbco'].linked_phases.create(id='lbco', scale=1.0)
 
 # %% [markdown]
 # ### 🚀 Exercise 5: Analyze and Fit the Data
@@ -1176,7 +1176,7 @@
 # **Solution:**
 
 # %% tags=["solution", "hide-input"]
-project_2.sample_models['lbco'].cell.length_a.free = True
+project_2.structures['lbco'].cell.length_a.free = True
 
 project_2.analysis.fit()
 project_2.analysis.show_fit_results()
@@ -1348,14 +1348,14 @@
 # confirm this hypothesis.
 
 # %% tags=["solution", "hide-input"]
-project_1.plot_meas_vs_calc(expt_name='sim_si',  x='d_spacing', x_min=1, x_max=1.7)
+project_1.plot_meas_vs_calc(expt_name='sim_si', x='d_spacing', x_min=1, x_max=1.7)
 project_2.plot_meas_vs_calc(expt_name='sim_lbco', x='d_spacing', x_min=1, x_max=1.7)
 
 # %% [markdown]
-# #### Exercise 5.10: Create a Second Sample Model – Si as Impurity
+# #### Exercise 5.10: Create a Second Structure – Si as Impurity
 #
-# Create a second sample model for the Si phase, which is the impurity
-# phase identified in the previous step. Link this sample model to the
+# Create a second structure for the Si phase, which is the impurity
+# phase identified in the previous step. Link this structure to the
 # LBCO experiment.
 
 # %% [markdown]
@@ -1363,7 +1363,7 @@
 
 # %% [markdown] tags=["dmsc-school-hint"]
 # You can use the same approach as in the previous part of the notebook,
-# but this time you need to create a sample model for Si and link it to
+# but this time you need to create a structure for Si and link it to
 # the LBCO experiment.
 
 # %% [markdown]
@@ -1371,15 +1371,15 @@
 
 # %% tags=["solution", "hide-input"]
 # Set Space Group
-project_2.sample_models.add(name='si')
-project_2.sample_models['si'].space_group.name_h_m = 'F d -3 m'
-project_2.sample_models['si'].space_group.it_coordinate_system_code = '2'
+project_2.structures.create(name='si')
+project_2.structures['si'].space_group.name_h_m = 'F d -3 m'
+project_2.structures['si'].space_group.it_coordinate_system_code = '2'
 
 # Set Lattice Parameters
-project_2.sample_models['si'].cell.length_a = 5.43
+project_2.structures['si'].cell.length_a = 5.43
 
 # Set Atom Sites
-project_2.sample_models['si'].atom_sites.add(
+project_2.structures['si'].atom_sites.create(
     label='Si',
     type_symbol='Si',
     fract_x=0,
@@ -1389,8 +1389,8 @@
     b_iso=0.89,
 )
 
-# Assign Sample Model to Experiment
-project_2.experiments['sim_lbco'].linked_phases.add(id='si', scale=1.0)
+# Assign Structure to Experiment
+project_2.experiments['sim_lbco'].linked_phases.create(id='si', scale=1.0)
 
 # %% [markdown]
 # #### Exercise 5.11: Refine the Scale of the Si Phase
@@ -1443,7 +1443,7 @@
 #
 # To review the analysis results, you can generate and print a summary
 # report using the `show_report()` method, as demonstrated in the cell
-# below. The report includes parameters related to the sample model and
+# below. The report includes parameters related to the structure and
 # the experiment, such as the refined unit cell parameter `a` of LBCO.
 #
 # Information about the crystal or magnetic structure, along with
diff --git a/tutorials/ed-14.py b/tutorials/ed-14.py
index 65d8e98c..eaa3da2a 100644
--- a/tutorials/ed-14.py
+++ b/tutorials/ed-14.py
@@ -18,29 +18,29 @@
 project = ed.Project()
 
 # %% [markdown]
-# ## Step 2: Define Sample Model
+# ## Step 2: Define Structure
 
 # %%
 # Download CIF file from repository
-model_path = ed.download_data(id=20, destination='data')
+structure_path = ed.download_data(id=20, destination='data')
 
 # %%
-project.sample_models.add(cif_path=model_path)
+project.structures.add_from_cif_path(structure_path)
 
 # %%
-project.sample_models.show_names()
+project.structures.show_names()
 
 # %%
-sample_model = project.sample_models['tbti']
+structure = project.structures['tbti']
 
 # %%
-sample_model.atom_sites['Tb'].b_iso.value = 0.0
-sample_model.atom_sites['Ti'].b_iso.value = 0.0
-sample_model.atom_sites['O1'].b_iso.value = 0.0
-sample_model.atom_sites['O2'].b_iso.value = 0.0
+structure.atom_sites['Tb'].b_iso = 0.0
+structure.atom_sites['Ti'].b_iso = 0.0
+structure.atom_sites['O1'].b_iso = 0.0
+structure.atom_sites['O2'].b_iso = 0.0
 
 # %%
-sample_model.show_as_cif()
+structure.show_as_cif()
 
 # %% [markdown]
 # ## Step 3: Define Experiment
@@ -49,7 +49,7 @@
 data_path = ed.download_data(id=19, destination='data')
 
 # %%
-project.experiments.add(
+project.experiments.add_from_data_path(
     name='heidi',
     data_path=data_path,
     sample_form='single crystal',
diff --git a/tutorials/ed-15.py b/tutorials/ed-15.py
index c3e178ff..4ae4933a 100644
--- a/tutorials/ed-15.py
+++ b/tutorials/ed-15.py
@@ -18,23 +18,23 @@
 project = ed.Project()
 
 # %% [markdown]
-# ## Step 2: Define Sample Model
+# ## Step 2: Define Structure
 
 # %%
 # Download CIF file from repository
-model_path = ed.download_data(id=21, destination='data')
+structure_path = ed.download_data(id=21, destination='data')
 
 # %%
-project.sample_models.add(cif_path=model_path)
+project.structures.add_from_cif_path(structure_path)
 
 # %%
-project.sample_models.show_names()
+project.structures.show_names()
 
 # %%
-sample_model = project.sample_models['taurine']
+structure = project.structures['taurine']
 
 # %%
-# sample_model.show_as_cif()
+# structure.show_as_cif()
 
 # %% [markdown]
 # ## Step 3: Define Experiment
@@ -43,7 +43,7 @@
 data_path = ed.download_data(id=22, destination='data')
 
 # %%
-project.experiments.add(
+project.experiments.add_from_data_path(
     name='senju',
     data_path=data_path,
     sample_form='single crystal',
diff --git a/tutorials/ed-16.py b/tutorials/ed-16.py
new file mode 100644
index 00000000..e57f8449
--- /dev/null
+++ b/tutorials/ed-16.py
@@ -0,0 +1,259 @@
+# %% [markdown]
+# # Joint Refinement: Si, Bragg + PDF
+#
+# This example demonstrates a joint refinement of the Si crystal
+# structure combining Bragg diffraction and pair distribution function
+# (PDF) analysis. The Bragg experiment uses time-of-flight neutron
+# powder diffraction data from SEPD at Argonne, while the PDF
+# experiment uses data from NOMAD at SNS. A single shared Si structure
+# is refined simultaneously against both datasets.
+
+# %% [markdown]
+# ## Import Library
+
+# %%
+from easydiffraction import ExperimentFactory
+from easydiffraction import Project
+from easydiffraction import StructureFactory
+from easydiffraction import download_data
+
+# %% [markdown]
+# ## Define Structure
+#
+# A single Si structure is shared between the Bragg and PDF
+# experiments. Structural parameters refined against both datasets
+# simultaneously.
+#
+# #### Create Structure
+
+# %%
+structure = StructureFactory.from_scratch(name='si')
+
+# %% [markdown]
+# #### Set Space Group
+
+# %%
+structure.space_group.name_h_m = 'F d -3 m'
+structure.space_group.it_coordinate_system_code = '1'
+
+# %% [markdown]
+# #### Set Unit Cell
+
+# %%
+structure.cell.length_a = 5.42
+
+# %% [markdown]
+# #### Set Atom Sites
+
+# %%
+structure.atom_sites.create(
+    label='Si',
+    type_symbol='Si',
+    fract_x=0,
+    fract_y=0,
+    fract_z=0,
+    wyckoff_letter='a',
+    b_iso=0.2,
+)
+
+# %% [markdown]
+# ## Define Experiments
+#
+# Two experiments are defined: one for Bragg diffraction and one for
+# PDF analysis. Both are linked to the same Si structure.
+#
+# ### Experiment 1: Bragg (SEPD, TOF)
+#
+# #### Download Data
+
+# %%
+bragg_data_path = download_data(id=7, destination='data')
+
+# %% [markdown]
+# #### Create Experiment
+
+# %%
+bragg_expt = ExperimentFactory.from_data_path(
+    name='sepd', data_path=bragg_data_path, beam_mode='time-of-flight'
+)
+
+# %% [markdown]
+# #### Set Instrument
+
+# %%
+bragg_expt.instrument.setup_twotheta_bank = 144.845
+bragg_expt.instrument.calib_d_to_tof_offset = -9.2
+bragg_expt.instrument.calib_d_to_tof_linear = 7476.91
+bragg_expt.instrument.calib_d_to_tof_quad = -1.54
+
+# %% [markdown]
+# #### Set Peak Profile
+
+# %%
+bragg_expt.peak_profile_type = 'pseudo-voigt * ikeda-carpenter'
+bragg_expt.peak.broad_gauss_sigma_0 = 5.0
+bragg_expt.peak.broad_gauss_sigma_1 = 45.0
+bragg_expt.peak.broad_gauss_sigma_2 = 1.0
+bragg_expt.peak.broad_mix_beta_0 = 0.04221
+bragg_expt.peak.broad_mix_beta_1 = 0.00946
+bragg_expt.peak.asym_alpha_0 = 0.0
+bragg_expt.peak.asym_alpha_1 = 0.5971
+
+# %% [markdown]
+# #### Set Background
+
+# %%
+bragg_expt.background_type = 'line-segment'
+for x in range(0, 35000, 5000):
+    bragg_expt.background.create(id=str(x), x=x, y=200)
+
+# %% [markdown]
+# #### Set Linked Phases
+
+# %%
+bragg_expt.linked_phases.create(id='si', scale=13.0)
+
+# %% [markdown]
+# ### Experiment 2: PDF (NOMAD, TOF)
+#
+# #### Download Data
+
+# %%
+pdf_data_path = download_data(id=5, destination='data')
+
+# %% [markdown]
+# #### Create Experiment
+
+# %%
+pdf_expt = ExperimentFactory.from_data_path(
+    name='nomad',
+    data_path=pdf_data_path,
+    beam_mode='time-of-flight',
+    scattering_type='total',
+)
+
+# %% [markdown]
+# #### Set Peak Profile (PDF Parameters)
+
+# %%
+pdf_expt.peak.damp_q = 0.02
+pdf_expt.peak.broad_q = 0.02
+pdf_expt.peak.cutoff_q = 35.0
+pdf_expt.peak.sharp_delta_1 = 0.001
+pdf_expt.peak.sharp_delta_2 = 4.0
+pdf_expt.peak.damp_particle_diameter = 0
+
+# %% [markdown]
+# #### Set Linked Phases
+
+# %%
+pdf_expt.linked_phases.create(id='si', scale=1.0)
+
+# %% [markdown]
+# ## Define Project
+#
+# The project object manages the shared structure, both experiments,
+# and the analysis.
+#
+# #### Create Project
+
+# %%
+project = Project()
+
+# %% [markdown]
+# #### Add Structure
+
+# %%
+project.structures.add(structure)
+
+# %% [markdown]
+# #### Add Experiments
+
+# %%
+project.experiments.add(bragg_expt)
+project.experiments.add(pdf_expt)
+
+# %% [markdown]
+# ## Perform Analysis
+#
+# This section shows the joint analysis process. The calculator is
+# auto-resolved per experiment: CrysPy for Bragg, PDFfit for PDF.
+#
+# #### Set Fit Mode and Weights
+
+# %%
+project.analysis.fit_mode.mode = 'joint'
+project.analysis.joint_fit_experiments.create(id='sepd', weight=0.7)
+project.analysis.joint_fit_experiments.create(id='nomad', weight=0.3)
+
+# %% [markdown]
+# #### Set Minimizer
+
+# %%
+project.analysis.current_minimizer = 'lmfit'
+
+# %% [markdown]
+# #### Plot Measured vs Calculated (Before Fit)
+
+# %%
+project.plot_meas_vs_calc(expt_name='sepd', show_residual=False)
+
+# %%
+project.plot_meas_vs_calc(expt_name='nomad', show_residual=False)
+
+# %% [markdown]
+# #### Set Fitting Parameters
+#
+# Shared structural parameters are refined against both datasets
+# simultaneously.
+
+# %%
+structure.cell.length_a.free = True
+structure.atom_sites['Si'].b_iso.free = True
+
+# %% [markdown]
+# Bragg experiment parameters.
+
+# %%
+bragg_expt.linked_phases['si'].scale.free = True
+bragg_expt.instrument.calib_d_to_tof_offset.free = True
+bragg_expt.peak.broad_gauss_sigma_0.free = True
+bragg_expt.peak.broad_gauss_sigma_1.free = True
+bragg_expt.peak.broad_gauss_sigma_2.free = True
+for point in bragg_expt.background:
+    point.y.free = True
+
+# %% [markdown]
+# PDF experiment parameters.
+
+# %%
+pdf_expt.linked_phases['si'].scale.free = True
+pdf_expt.peak.damp_q.free = True
+pdf_expt.peak.broad_q.free = True
+pdf_expt.peak.sharp_delta_1.free = True
+pdf_expt.peak.sharp_delta_2.free = True
+
+# %% [markdown]
+# #### Show Free Parameters
+
+# %%
+project.analysis.show_free_params()
+
+# %% [markdown]
+# #### Run Fitting
+
+# %%
+project.analysis.fit()
+project.analysis.show_fit_results()
+
+# %% [markdown]
+# #### Plot Measured vs Calculated (After Fit)
+
+# %%
+project.plot_meas_vs_calc(expt_name='sepd', show_residual=False)
+
+# %%
+project.plot_meas_vs_calc(expt_name='nomad', show_residual=False)
+
+
+# %%
diff --git a/tutorials/ed-2.py b/tutorials/ed-2.py
index 4390b65b..3c8d033e 100644
--- a/tutorials/ed-2.py
+++ b/tutorials/ed-2.py
@@ -2,9 +2,9 @@
 # # Structure Refinement: LBCO, HRPT
 #
 # This minimalistic example is designed to show how Rietveld refinement
-# of a crystal structure can be performed when both the sample model and
-# experiment are defined directly in code. Only the experimentally
-# measured data is loaded from an external file.
+# can be performed when both the crystal structure and experiment are
+# defined directly in code. Only the experimentally measured data is
+# loaded from an external file.
 #
 # For this example, constant-wavelength neutron powder diffraction data
 # for La0.5Ba0.5CoO3 from HRPT at PSI is used.
@@ -33,23 +33,23 @@
 project = ed.Project()
 
 # %% [markdown]
-# ## Step 2: Define Sample Model
+# ## Step 2: Define Structure
 
 # %%
-project.sample_models.add(name='lbco')
+project.structures.create(name='lbco')
 
 # %%
-sample_model = project.sample_models['lbco']
+structure = project.structures['lbco']
 
 # %%
-sample_model.space_group.name_h_m = 'P m -3 m'
-sample_model.space_group.it_coordinate_system_code = '1'
+structure.space_group.name_h_m = 'P m -3 m'
+structure.space_group.it_coordinate_system_code = '1'
 
 # %%
-sample_model.cell.length_a = 3.88
+structure.cell.length_a = 3.88
 
 # %%
-sample_model.atom_sites.add(
+structure.atom_sites.create(
     label='La',
     type_symbol='La',
     fract_x=0,
@@ -59,7 +59,7 @@
     b_iso=0.5,
     occupancy=0.5,
 )
-sample_model.atom_sites.add(
+structure.atom_sites.create(
     label='Ba',
     type_symbol='Ba',
     fract_x=0,
@@ -69,7 +69,7 @@
     b_iso=0.5,
     occupancy=0.5,
 )
-sample_model.atom_sites.add(
+structure.atom_sites.create(
     label='Co',
     type_symbol='Co',
     fract_x=0.5,
@@ -78,7 +78,7 @@
     wyckoff_letter='b',
     b_iso=0.5,
 )
-sample_model.atom_sites.add(
+structure.atom_sites.create(
     label='O',
     type_symbol='O',
     fract_x=0,
@@ -95,7 +95,7 @@
 data_path = ed.download_data(id=3, destination='data')
 
 # %%
-project.experiments.add(
+project.experiments.add_from_data_path(
     name='hrpt',
     data_path=data_path,
     sample_form='powder',
@@ -117,29 +117,29 @@
 experiment.peak.broad_lorentz_y = 0.1
 
 # %%
-experiment.background.add(id='1', x=10, y=170)
-experiment.background.add(id='2', x=30, y=170)
-experiment.background.add(id='3', x=50, y=170)
-experiment.background.add(id='4', x=110, y=170)
-experiment.background.add(id='5', x=165, y=170)
+experiment.background.create(id='1', x=10, y=170)
+experiment.background.create(id='2', x=30, y=170)
+experiment.background.create(id='3', x=50, y=170)
+experiment.background.create(id='4', x=110, y=170)
+experiment.background.create(id='5', x=165, y=170)
 
 # %%
-experiment.excluded_regions.add(id='1', start=0, end=5)
-experiment.excluded_regions.add(id='2', start=165, end=180)
+experiment.excluded_regions.create(id='1', start=0, end=5)
+experiment.excluded_regions.create(id='2', start=165, end=180)
 
 # %%
-experiment.linked_phases.add(id='lbco', scale=10.0)
+experiment.linked_phases.create(id='lbco', scale=10.0)
 
 # %% [markdown]
 # ## Step 4: Perform Analysis
 
 # %%
-sample_model.cell.length_a.free = True
+structure.cell.length_a.free = True
 
-sample_model.atom_sites['La'].b_iso.free = True
-sample_model.atom_sites['Ba'].b_iso.free = True
-sample_model.atom_sites['Co'].b_iso.free = True
-sample_model.atom_sites['O'].b_iso.free = True
+structure.atom_sites['La'].b_iso.free = True
+structure.atom_sites['Ba'].b_iso.free = True
+structure.atom_sites['Co'].b_iso.free = True
+structure.atom_sites['O'].b_iso.free = True
 
 # %%
 experiment.instrument.calib_twotheta_offset.free = True
diff --git a/tutorials/ed-3.py b/tutorials/ed-3.py
index c0aefa1b..051faae0 100644
--- a/tutorials/ed-3.py
+++ b/tutorials/ed-3.py
@@ -8,12 +8,13 @@
 #
 # It is intended for users with minimal programming experience who want
 # to learn how to perform standard crystal structure fitting using
-# diffraction data. This script covers creating a project, adding sample
-# models and experiments, performing analysis, and refining parameters.
+# diffraction data. This script covers creating a project, adding
+# crystal structures and experiments, performing analysis, and refining
+# parameters.
 #
 # Only a single import of `easydiffraction` is required, and all
 # operations are performed through high-level components of the
-# `project` object, such as `project.sample_models`,
+# `project` object, such as `project.structures`,
 # `project.experiments`, and `project.analysis`. The `project` object is
 # the main container for all information.
 
@@ -84,26 +85,27 @@
 # project.plotter.engine = 'plotly'
 
 # %% [markdown]
-# ## Step 2: Define Sample Model
+# ## Step 2: Define Structure
 #
-# This section shows how to add sample models and modify their
+# This section shows how to add structures and modify their
 # parameters.
 
 # %% [markdown]
-# #### Add Sample Model
+# #### Add Structure
 
 # %%
-project.sample_models.add(name='lbco')
+project.structures.create(name='lbco')
 
 # %% [markdown]
-# #### Show Defined Sample Models
+# #### Show Defined Structures
 #
-# Show the names of the models added. These names are used to access the
-# model using the syntax: `project.sample_models['model_name']`. All
-# model parameters can be accessed via the `project` object.
+# Show the names of the crystal structures added. These names are used
+# to access the structure using the syntax:
+# `project.structures[name]`. All structure parameters can be accessed
+# via the `project` object.
 
 # %%
-project.sample_models.show_names()
+project.structures.show_names()
 
 # %% [markdown]
 # #### Set Space Group
@@ -111,8 +113,8 @@
 # Modify the default space group parameters.
 
 # %%
-project.sample_models['lbco'].space_group.name_h_m = 'P m -3 m'
-project.sample_models['lbco'].space_group.it_coordinate_system_code = '1'
+project.structures['lbco'].space_group.name_h_m = 'P m -3 m'
+project.structures['lbco'].space_group.it_coordinate_system_code = '1'
 
 # %% [markdown]
 # #### Set Unit Cell
@@ -120,15 +122,15 @@
 # Modify the default unit cell parameters.
 
 # %%
-project.sample_models['lbco'].cell.length_a = 3.88
+project.structures['lbco'].cell.length_a = 3.88
 
 # %% [markdown]
 # #### Set Atom Sites
 #
-# Add atom sites to the sample model.
+# Add atom sites to the structure.
 
 # %%
-project.sample_models['lbco'].atom_sites.add(
+project.structures['lbco'].atom_sites.create(
     label='La',
     type_symbol='La',
     fract_x=0,
@@ -138,7 +140,7 @@
     b_iso=0.5,
     occupancy=0.5,
 )
-project.sample_models['lbco'].atom_sites.add(
+project.structures['lbco'].atom_sites.create(
     label='Ba',
     type_symbol='Ba',
     fract_x=0,
@@ -148,7 +150,7 @@
     b_iso=0.5,
     occupancy=0.5,
 )
-project.sample_models['lbco'].atom_sites.add(
+project.structures['lbco'].atom_sites.create(
     label='Co',
     type_symbol='Co',
     fract_x=0.5,
@@ -157,7 +159,7 @@
     wyckoff_letter='b',
     b_iso=0.5,
 )
-project.sample_models['lbco'].atom_sites.add(
+project.structures['lbco'].atom_sites.create(
     label='O',
     type_symbol='O',
     fract_x=0,
@@ -168,21 +170,21 @@
 )
 
 # %% [markdown]
-# #### Show Sample Model as CIF
+# #### Show Structure as CIF
 
 # %%
-project.sample_models['lbco'].show_as_cif()
+project.structures['lbco'].show_as_cif()
 
 # %% [markdown]
-# #### Show Sample Model Structure
+# #### Show Structure Structure
 
 # %%
-project.sample_models['lbco'].show_structure()
+project.structures['lbco'].show()
 
 # %% [markdown]
 # #### Save Project State
 #
-# Save the project state after adding the sample model. This ensures
+# Save the project state after adding the structure. This ensures
 # that all changes are stored and can be accessed later. The project
 # state is saved in the directory specified during project creation.
 
@@ -193,7 +195,7 @@
 # ## Step 3: Define Experiment
 #
 # This section shows how to add experiments, configure their parameters,
-# and link the sample models defined in the previous step.
+# and link the structures defined in the previous step.
 
 # %% [markdown]
 # #### Download Measured Data
@@ -207,7 +209,7 @@
 # #### Add Diffraction Experiment
 
 # %%
-project.experiments.add(
+project.experiments.add_from_data_path(
     name='hrpt',
     data_path=data_path,
     sample_form='powder',
@@ -291,11 +293,11 @@
 # Add background points.
 
 # %%
-project.experiments['hrpt'].background.add(id='10', x=10, y=170)
-project.experiments['hrpt'].background.add(id='30', x=30, y=170)
-project.experiments['hrpt'].background.add(id='50', x=50, y=170)
-project.experiments['hrpt'].background.add(id='110', x=110, y=170)
-project.experiments['hrpt'].background.add(id='165', x=165, y=170)
+project.experiments['hrpt'].background.create(id='10', x=10, y=170)
+project.experiments['hrpt'].background.create(id='30', x=30, y=170)
+project.experiments['hrpt'].background.create(id='50', x=50, y=170)
+project.experiments['hrpt'].background.create(id='110', x=110, y=170)
+project.experiments['hrpt'].background.create(id='165', x=165, y=170)
 
 # %% [markdown]
 # Show current background points.
@@ -306,10 +308,10 @@
 # %% [markdown]
 # #### Set Linked Phases
 #
-# Link the sample model defined in the previous step to the experiment.
+# Link the structure defined in the previous step to the experiment.
 
 # %%
-project.experiments['hrpt'].linked_phases.add(id='lbco', scale=10.0)
+project.experiments['hrpt'].linked_phases.create(id='lbco', scale=10.0)
 
 # %% [markdown]
 # #### Show Experiment as CIF
@@ -331,22 +333,22 @@
 #
 # #### Set Calculator
 #
-# Show supported calculation engines.
+# Show supported calculation engines for this experiment.
 
 # %%
-project.analysis.show_supported_calculators()
+project.experiments['hrpt'].show_supported_calculator_types()
 
 # %% [markdown]
-# Show current calculation engine.
+# Show current calculation engine for this experiment.
 
 # %%
-project.analysis.show_current_calculator()
+project.experiments['hrpt'].show_current_calculator_type()
 
 # %% [markdown]
 # Select the desired calculation engine.
 
 # %%
-project.analysis.current_calculator = 'cryspy'
+project.experiments['hrpt'].calculator_type = 'cryspy'
 
 # %% [markdown]
 # #### Show Calculated Data
@@ -395,19 +397,19 @@
 # Show supported fit modes.
 
 # %%
-project.analysis.show_available_fit_modes()
+project.analysis.show_supported_fit_mode_types()
 
 # %% [markdown]
 # Show current fit mode.
 
 # %%
-project.analysis.show_current_fit_mode()
+project.analysis.show_current_fit_mode_type()
 
 # %% [markdown]
 # Select desired fit mode.
 
 # %%
-project.analysis.fit_mode = 'single'
+project.analysis.fit_mode.mode = 'single'
 
 # %% [markdown]
 # #### Set Minimizer
@@ -427,15 +429,15 @@
 # Select desired fitting engine.
 
 # %%
-project.analysis.current_minimizer = 'lmfit (leastsq)'
+project.analysis.current_minimizer = 'lmfit'
 
 # %% [markdown]
 # ### Perform Fit 1/5
 #
-# Set sample model parameters to be refined.
+# Set structure parameters to be refined.
 
 # %%
-project.sample_models['lbco'].cell.length_a.free = True
+project.structures['lbco'].cell.length_a.free = True
 
 # %% [markdown]
 # Set experiment parameters to be refined.
@@ -522,10 +524,10 @@
 # Set more parameters to be refined.
 
 # %%
-project.sample_models['lbco'].atom_sites['La'].b_iso.free = True
-project.sample_models['lbco'].atom_sites['Ba'].b_iso.free = True
-project.sample_models['lbco'].atom_sites['Co'].b_iso.free = True
-project.sample_models['lbco'].atom_sites['O'].b_iso.free = True
+project.structures['lbco'].atom_sites['La'].b_iso.free = True
+project.structures['lbco'].atom_sites['Ba'].b_iso.free = True
+project.structures['lbco'].atom_sites['Co'].b_iso.free = True
+project.structures['lbco'].atom_sites['O'].b_iso.free = True
 
 # %% [markdown]
 # Show free parameters after selection.
@@ -563,20 +565,20 @@
 # Set aliases for parameters.
 
 # %%
-project.analysis.aliases.add(
+project.analysis.aliases.create(
     label='biso_La',
-    param_uid=project.sample_models['lbco'].atom_sites['La'].b_iso.uid,
+    param_uid=project.structures['lbco'].atom_sites['La'].b_iso.uid,
 )
-project.analysis.aliases.add(
+project.analysis.aliases.create(
     label='biso_Ba',
-    param_uid=project.sample_models['lbco'].atom_sites['Ba'].b_iso.uid,
+    param_uid=project.structures['lbco'].atom_sites['Ba'].b_iso.uid,
 )
 
 # %% [markdown]
 # Set constraints.
 
 # %%
-project.analysis.constraints.add(lhs_alias='biso_Ba', rhs_expr='biso_La')
+project.analysis.constraints.create(lhs_alias='biso_Ba', rhs_expr='biso_La')
 
 # %% [markdown]
 # Show defined constraints.
@@ -632,20 +634,20 @@
 # Set more aliases for parameters.
 
 # %%
-project.analysis.aliases.add(
+project.analysis.aliases.create(
     label='occ_La',
-    param_uid=project.sample_models['lbco'].atom_sites['La'].occupancy.uid,
+    param_uid=project.structures['lbco'].atom_sites['La'].occupancy.uid,
 )
-project.analysis.aliases.add(
+project.analysis.aliases.create(
     label='occ_Ba',
-    param_uid=project.sample_models['lbco'].atom_sites['Ba'].occupancy.uid,
+    param_uid=project.structures['lbco'].atom_sites['Ba'].occupancy.uid,
 )
 
 # %% [markdown]
 # Set more constraints.
 
 # %%
-project.analysis.constraints.add(
+project.analysis.constraints.create(
     lhs_alias='occ_Ba',
     rhs_expr='1 - occ_La',
 )
@@ -663,10 +665,10 @@
 project.analysis.apply_constraints()
 
 # %% [markdown]
-# Set sample model parameters to be refined.
+# Set structure parameters to be refined.
 
 # %%
-project.sample_models['lbco'].atom_sites['La'].occupancy.free = True
+project.structures['lbco'].atom_sites['La'].occupancy.free = True
 
 # %% [markdown]
 # Show free parameters after selection.
diff --git a/tutorials/ed-4.py b/tutorials/ed-4.py
index ce857a2a..3275deab 100644
--- a/tutorials/ed-4.py
+++ b/tutorials/ed-4.py
@@ -2,7 +2,7 @@
 # # Structure Refinement: PbSO4, NPD + XRD
 #
 # This example demonstrates a more advanced use of the EasyDiffraction
-# library by explicitly creating and configuring sample models and
+# library by explicitly creating and configuring structures and
 # experiments before adding them to a project. It could be more suitable
 # for users who are interested in creating custom workflows. This
 # tutorial provides minimal explanation and is intended for users
@@ -17,39 +17,39 @@
 # %%
 from easydiffraction import ExperimentFactory
 from easydiffraction import Project
-from easydiffraction import SampleModelFactory
+from easydiffraction import StructureFactory
 from easydiffraction import download_data
 
 # %% [markdown]
-# ## Define Sample Model
+# ## Define Structure
 #
-# This section shows how to add sample models and modify their
+# This section shows how to add structures and modify their
 # parameters.
 #
-# #### Create Sample Model
+# #### Create Structure
 
 # %%
-model = SampleModelFactory.create(name='pbso4')
+structure = StructureFactory.from_scratch(name='pbso4')
 
 # %% [markdown]
 # #### Set Space Group
 
 # %%
-model.space_group.name_h_m = 'P n m a'
+structure.space_group.name_h_m = 'P n m a'
 
 # %% [markdown]
 # #### Set Unit Cell
 
 # %%
-model.cell.length_a = 8.47
-model.cell.length_b = 5.39
-model.cell.length_c = 6.95
+structure.cell.length_a = 8.47
+structure.cell.length_b = 5.39
+structure.cell.length_c = 6.95
 
 # %% [markdown]
 # #### Set Atom Sites
 
 # %%
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Pb',
     type_symbol='Pb',
     fract_x=0.1876,
@@ -58,7 +58,7 @@
     wyckoff_letter='c',
     b_iso=1.37,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='S',
     type_symbol='S',
     fract_x=0.0654,
@@ -67,7 +67,7 @@
     wyckoff_letter='c',
     b_iso=0.3777,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='O1',
     type_symbol='O',
     fract_x=0.9082,
@@ -76,7 +76,7 @@
     wyckoff_letter='c',
     b_iso=1.9764,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='O2',
     type_symbol='O',
     fract_x=0.1935,
@@ -85,7 +85,7 @@
     wyckoff_letter='c',
     b_iso=1.4456,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='O3',
     type_symbol='O',
     fract_x=0.0811,
@@ -100,7 +100,7 @@
 # ## Define Experiments
 #
 # This section shows how to add experiments, configure their parameters,
-# and link the sample models defined in the previous step.
+# and link the structures defined in the previous step.
 #
 # ### Experiment 1: npd
 #
@@ -113,7 +113,7 @@
 # #### Create Experiment
 
 # %%
-expt1 = ExperimentFactory.create(
+expt1 = ExperimentFactory.from_data_path(
     name='npd',
     data_path=data_path1,
     radiation_probe='neutron',
@@ -159,13 +159,13 @@
     ('7', 120.0, 244.4525),
     ('8', 153.0, 226.0595),
 ]:
-    expt1.background.add(id=id, x=x, y=y)
+    expt1.background.create(id=id, x=x, y=y)
 
 # %% [markdown]
 # #### Set Linked Phases
 
 # %%
-expt1.linked_phases.add(id='pbso4', scale=1.5)
+expt1.linked_phases.create(id='pbso4', scale=1.5)
 
 # %% [markdown]
 # ### Experiment 2: xrd
@@ -179,7 +179,7 @@
 # #### Create Experiment
 
 # %%
-expt2 = ExperimentFactory.create(
+expt2 = ExperimentFactory.from_data_path(
     name='xrd',
     data_path=data_path2,
     radiation_probe='xray',
@@ -209,7 +209,7 @@
 # Select background type.
 
 # %%
-expt2.background_type = 'chebyshev polynomial'
+expt2.background_type = 'chebyshev'
 
 # %% [markdown]
 # Add background points.
@@ -223,18 +223,18 @@
     ('5', 4, 54.552),
     ('6', 5, -20.661),
 ]:
-    expt2.background.add(id=id, order=x, coef=y)
+    expt2.background.create(id=id, order=x, coef=y)
 
 # %% [markdown]
 # #### Set Linked Phases
 
 # %%
-expt2.linked_phases.add(id='pbso4', scale=0.001)
+expt2.linked_phases.create(id='pbso4', scale=0.001)
 
 # %% [markdown]
 # ## Define Project
 #
-# The project object is used to manage sample models, experiments, and
+# The project object is used to manage structures, experiments, and
 # analysis.
 #
 # #### Create Project
@@ -243,17 +243,17 @@
 project = Project()
 
 # %% [markdown]
-# #### Add Sample Model
+# #### Add Structure
 
 # %%
-project.sample_models.add(sample_model=model)
+project.structures.add(structure)
 
 # %% [markdown]
 # #### Add Experiments
 
 # %%
-project.experiments.add(experiment=expt1)
-project.experiments.add(experiment=expt2)
+project.experiments.add(expt1)
+project.experiments.add(expt2)
 
 # %% [markdown]
 # ## Perform Analysis
@@ -261,32 +261,26 @@
 # This section outlines the analysis process, including how to configure
 # calculation and fitting engines.
 #
-# #### Set Calculator
-
-# %%
-project.analysis.current_calculator = 'cryspy'
-
-# %% [markdown]
 # #### Set Fit Mode
 
 # %%
-project.analysis.fit_mode = 'joint'
+project.analysis.fit_mode.mode = 'joint'
 
 # %% [markdown]
 # #### Set Minimizer
 
 # %%
-project.analysis.current_minimizer = 'lmfit (leastsq)'
+project.analysis.current_minimizer = 'lmfit'
 
 # %% [markdown]
 # #### Set Fitting Parameters
 #
-# Set sample model parameters to be optimized.
+# Set structure parameters to be optimized.
 
 # %%
-model.cell.length_a.free = True
-model.cell.length_b.free = True
-model.cell.length_c.free = True
+structure.cell.length_a.free = True
+structure.cell.length_b.free = True
+structure.cell.length_c.free = True
 
 # %% [markdown]
 # Set experiment parameters to be optimized.
diff --git a/tutorials/ed-5.py b/tutorials/ed-5.py
index 0e46baa8..f7a30da2 100644
--- a/tutorials/ed-5.py
+++ b/tutorials/ed-5.py
@@ -11,40 +11,40 @@
 # %%
 from easydiffraction import ExperimentFactory
 from easydiffraction import Project
-from easydiffraction import SampleModelFactory
+from easydiffraction import StructureFactory
 from easydiffraction import download_data
 
 # %% [markdown]
-# ## Define Sample Model
+# ## Define Structure
 #
-# This section shows how to add sample models and modify their
+# This section shows how to add structures and modify their
 # parameters.
 #
-# #### Create Sample Model
+# #### Create Structure
 
 # %%
-model = SampleModelFactory.create(name='cosio')
+structure = StructureFactory.from_scratch(name='cosio')
 
 # %% [markdown]
 # #### Set Space Group
 
 # %%
-model.space_group.name_h_m = 'P n m a'
-model.space_group.it_coordinate_system_code = 'abc'
+structure.space_group.name_h_m = 'P n m a'
+structure.space_group.it_coordinate_system_code = 'abc'
 
 # %% [markdown]
 # #### Set Unit Cell
 
 # %%
-model.cell.length_a = 10.3
-model.cell.length_b = 6.0
-model.cell.length_c = 4.8
+structure.cell.length_a = 10.3
+structure.cell.length_b = 6.0
+structure.cell.length_c = 4.8
 
 # %% [markdown]
 # #### Set Atom Sites
 
 # %%
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Co1',
     type_symbol='Co',
     fract_x=0,
@@ -53,7 +53,7 @@
     wyckoff_letter='a',
     b_iso=0.5,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Co2',
     type_symbol='Co',
     fract_x=0.279,
@@ -62,7 +62,7 @@
     wyckoff_letter='c',
     b_iso=0.5,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Si',
     type_symbol='Si',
     fract_x=0.094,
@@ -71,7 +71,7 @@
     wyckoff_letter='c',
     b_iso=0.5,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='O1',
     type_symbol='O',
     fract_x=0.091,
@@ -80,7 +80,7 @@
     wyckoff_letter='c',
     b_iso=0.5,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='O2',
     type_symbol='O',
     fract_x=0.448,
@@ -89,7 +89,7 @@
     wyckoff_letter='c',
     b_iso=0.5,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='O3',
     type_symbol='O',
     fract_x=0.164,
@@ -103,7 +103,7 @@
 # ## Define Experiment
 #
 # This section shows how to add experiments, configure their parameters,
-# and link the sample models defined in the previous step.
+# and link the structures defined in the previous step.
 #
 # #### Download Measured Data
 
@@ -114,7 +114,7 @@
 # #### Create Experiment
 
 # %%
-expt = ExperimentFactory.create(name='d20', data_path=data_path)
+expt = ExperimentFactory.from_data_path(name='d20', data_path=data_path)
 
 # %% [markdown]
 # #### Set Instrument
@@ -135,31 +135,31 @@
 # #### Set Background
 
 # %%
-expt.background.add(id='1', x=8, y=500)
-expt.background.add(id='2', x=9, y=500)
-expt.background.add(id='3', x=10, y=500)
-expt.background.add(id='4', x=11, y=500)
-expt.background.add(id='5', x=12, y=500)
-expt.background.add(id='6', x=15, y=500)
-expt.background.add(id='7', x=25, y=500)
-expt.background.add(id='8', x=30, y=500)
-expt.background.add(id='9', x=50, y=500)
-expt.background.add(id='10', x=70, y=500)
-expt.background.add(id='11', x=90, y=500)
-expt.background.add(id='12', x=110, y=500)
-expt.background.add(id='13', x=130, y=500)
-expt.background.add(id='14', x=150, y=500)
+expt.background.create(id='1', x=8, y=500)
+expt.background.create(id='2', x=9, y=500)
+expt.background.create(id='3', x=10, y=500)
+expt.background.create(id='4', x=11, y=500)
+expt.background.create(id='5', x=12, y=500)
+expt.background.create(id='6', x=15, y=500)
+expt.background.create(id='7', x=25, y=500)
+expt.background.create(id='8', x=30, y=500)
+expt.background.create(id='9', x=50, y=500)
+expt.background.create(id='10', x=70, y=500)
+expt.background.create(id='11', x=90, y=500)
+expt.background.create(id='12', x=110, y=500)
+expt.background.create(id='13', x=130, y=500)
+expt.background.create(id='14', x=150, y=500)
 
 # %% [markdown]
 # #### Set Linked Phases
 
 # %%
-expt.linked_phases.add(id='cosio', scale=1.0)
+expt.linked_phases.create(id='cosio', scale=1.0)
 
 # %% [markdown]
 # ## Define Project
 #
-# The project object is used to manage the sample model, experiment, and
+# The project object is used to manage the structure, experiment, and
 # analysis.
 #
 # #### Create Project
@@ -176,16 +176,16 @@
 # project.plotter.engine = 'plotly'
 
 # %% [markdown]
-# #### Add Sample Model
+# #### Add Structure
 
 # %%
-project.sample_models.add(sample_model=model)
+project.structures.add(structure)
 
 # %% [markdown]
 # #### Add Experiment
 
 # %%
-project.experiments.add(experiment=expt)
+project.experiments.add(expt)
 
 # %% [markdown]
 # ## Perform Analysis
@@ -193,16 +193,10 @@
 # This section shows the analysis process, including how to set up
 # calculation and fitting engines.
 #
-# #### Set Calculator
-
-# %%
-project.analysis.current_calculator = 'cryspy'
-
-# %% [markdown]
 # #### Set Minimizer
 
 # %%
-project.analysis.current_minimizer = 'lmfit (leastsq)'
+project.analysis.current_minimizer = 'lmfit'
 
 # %% [markdown]
 # #### Plot Measured vs Calculated
@@ -217,28 +211,28 @@
 # #### Set Free Parameters
 
 # %%
-model.cell.length_a.free = True
-model.cell.length_b.free = True
-model.cell.length_c.free = True
-
-model.atom_sites['Co2'].fract_x.free = True
-model.atom_sites['Co2'].fract_z.free = True
-model.atom_sites['Si'].fract_x.free = True
-model.atom_sites['Si'].fract_z.free = True
-model.atom_sites['O1'].fract_x.free = True
-model.atom_sites['O1'].fract_z.free = True
-model.atom_sites['O2'].fract_x.free = True
-model.atom_sites['O2'].fract_z.free = True
-model.atom_sites['O3'].fract_x.free = True
-model.atom_sites['O3'].fract_y.free = True
-model.atom_sites['O3'].fract_z.free = True
-
-model.atom_sites['Co1'].b_iso.free = True
-model.atom_sites['Co2'].b_iso.free = True
-model.atom_sites['Si'].b_iso.free = True
-model.atom_sites['O1'].b_iso.free = True
-model.atom_sites['O2'].b_iso.free = True
-model.atom_sites['O3'].b_iso.free = True
+structure.cell.length_a.free = True
+structure.cell.length_b.free = True
+structure.cell.length_c.free = True
+
+structure.atom_sites['Co2'].fract_x.free = True
+structure.atom_sites['Co2'].fract_z.free = True
+structure.atom_sites['Si'].fract_x.free = True
+structure.atom_sites['Si'].fract_z.free = True
+structure.atom_sites['O1'].fract_x.free = True
+structure.atom_sites['O1'].fract_z.free = True
+structure.atom_sites['O2'].fract_x.free = True
+structure.atom_sites['O2'].fract_z.free = True
+structure.atom_sites['O3'].fract_x.free = True
+structure.atom_sites['O3'].fract_y.free = True
+structure.atom_sites['O3'].fract_z.free = True
+
+structure.atom_sites['Co1'].b_iso.free = True
+structure.atom_sites['Co2'].b_iso.free = True
+structure.atom_sites['Si'].b_iso.free = True
+structure.atom_sites['O1'].b_iso.free = True
+structure.atom_sites['O2'].b_iso.free = True
+structure.atom_sites['O3'].b_iso.free = True
 
 # %%
 expt.linked_phases['cosio'].scale.free = True
@@ -259,20 +253,20 @@
 # Set aliases for parameters.
 
 # %%
-project.analysis.aliases.add(
+project.analysis.aliases.create(
     label='biso_Co1',
-    param_uid=project.sample_models['cosio'].atom_sites['Co1'].b_iso.uid,
+    param_uid=project.structures['cosio'].atom_sites['Co1'].b_iso.uid,
 )
-project.analysis.aliases.add(
+project.analysis.aliases.create(
     label='biso_Co2',
-    param_uid=project.sample_models['cosio'].atom_sites['Co2'].b_iso.uid,
+    param_uid=project.structures['cosio'].atom_sites['Co2'].b_iso.uid,
 )
 
 # %% [markdown]
 # Set constraints.
 
 # %%
-project.analysis.constraints.add(
+project.analysis.constraints.create(
     lhs_alias='biso_Co2',
     rhs_expr='biso_Co1',
 )
diff --git a/tutorials/ed-6.py b/tutorials/ed-6.py
index 106a088c..e0339c91 100644
--- a/tutorials/ed-6.py
+++ b/tutorials/ed-6.py
@@ -11,40 +11,40 @@
 # %%
 from easydiffraction import ExperimentFactory
 from easydiffraction import Project
-from easydiffraction import SampleModelFactory
+from easydiffraction import StructureFactory
 from easydiffraction import download_data
 
 # %% [markdown]
-# ## Define Sample Model
+# ## Define Structure
 #
-# This section shows how to add sample models and modify their
+# This section shows how to add structures and modify their
 # parameters.
 #
-# #### Create Sample Model
+# #### Create Structure
 
 # %%
-model = SampleModelFactory.create(name='hs')
+structure = StructureFactory.from_scratch(name='hs')
 
 # %% [markdown]
 # #### Set Space Group
 
 # %%
-model.space_group.name_h_m = 'R -3 m'
-model.space_group.it_coordinate_system_code = 'h'
+structure.space_group.name_h_m = 'R -3 m'
+structure.space_group.it_coordinate_system_code = 'h'
 
 # %% [markdown]
 # #### Set Unit Cell
 
 
 # %%
-model.cell.length_a = 6.9
-model.cell.length_c = 14.1
+structure.cell.length_a = 6.9
+structure.cell.length_c = 14.1
 
 # %% [markdown]
 # #### Set Atom Sites
 
 # %%
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Zn',
     type_symbol='Zn',
     fract_x=0,
@@ -53,7 +53,7 @@
     wyckoff_letter='b',
     b_iso=0.5,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Cu',
     type_symbol='Cu',
     fract_x=0.5,
@@ -62,7 +62,7 @@
     wyckoff_letter='e',
     b_iso=0.5,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='O',
     type_symbol='O',
     fract_x=0.21,
@@ -71,7 +71,7 @@
     wyckoff_letter='h',
     b_iso=0.5,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Cl',
     type_symbol='Cl',
     fract_x=0,
@@ -80,7 +80,7 @@
     wyckoff_letter='c',
     b_iso=0.5,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='H',
     type_symbol='2H',
     fract_x=0.13,
@@ -94,7 +94,7 @@
 # ## Define Experiment
 #
 # This section shows how to add experiments, configure their parameters,
-# and link the sample models defined in the previous step.
+# and link the structures defined in the previous step.
 #
 # #### Download Measured Data
 
@@ -105,7 +105,7 @@
 # #### Create Experiment
 
 # %%
-expt = ExperimentFactory.create(name='hrpt', data_path=data_path)
+expt = ExperimentFactory.from_data_path(name='hrpt', data_path=data_path)
 
 # %% [markdown]
 # #### Set Instrument
@@ -128,26 +128,26 @@
 # #### Set Background
 
 # %%
-expt.background.add(id='1', x=4.4196, y=500)
-expt.background.add(id='2', x=6.6207, y=500)
-expt.background.add(id='3', x=10.4918, y=500)
-expt.background.add(id='4', x=15.4634, y=500)
-expt.background.add(id='5', x=45.6041, y=500)
-expt.background.add(id='6', x=74.6844, y=500)
-expt.background.add(id='7', x=103.4187, y=500)
-expt.background.add(id='8', x=121.6311, y=500)
-expt.background.add(id='9', x=159.4116, y=500)
+expt.background.create(id='1', x=4.4196, y=500)
+expt.background.create(id='2', x=6.6207, y=500)
+expt.background.create(id='3', x=10.4918, y=500)
+expt.background.create(id='4', x=15.4634, y=500)
+expt.background.create(id='5', x=45.6041, y=500)
+expt.background.create(id='6', x=74.6844, y=500)
+expt.background.create(id='7', x=103.4187, y=500)
+expt.background.create(id='8', x=121.6311, y=500)
+expt.background.create(id='9', x=159.4116, y=500)
 
 # %% [markdown]
 # #### Set Linked Phases
 
 # %%
-expt.linked_phases.add(id='hs', scale=0.5)
+expt.linked_phases.create(id='hs', scale=0.5)
 
 # %% [markdown]
 # ## Define Project
 #
-# The project object is used to manage the sample model, experiment, and
+# The project object is used to manage the structure, experiment, and
 # analysis.
 #
 # #### Create Project
@@ -164,16 +164,16 @@
 # project.plotter.engine = 'plotly'
 
 # %% [markdown]
-# #### Add Sample Model
+# #### Add Structure
 
 # %%
-project.sample_models.add(sample_model=model)
+project.structures.add(structure)
 
 # %% [markdown]
 # #### Add Experiment
 
 # %%
-project.experiments.add(experiment=expt)
+project.experiments.add(expt)
 
 # %% [markdown]
 # ## Perform Analysis
@@ -181,16 +181,10 @@
 # This section shows the analysis process, including how to set up
 # calculation and fitting engines.
 #
-# #### Set Calculator
-
-# %%
-project.analysis.current_calculator = 'cryspy'
-
-# %% [markdown]
 # #### Set Minimizer
 
 # %%
-project.analysis.current_minimizer = 'lmfit (leastsq)'
+project.analysis.current_minimizer = 'lmfit'
 
 # %% [markdown]
 # #### Plot Measured vs Calculated
@@ -207,8 +201,8 @@
 # Set parameters to be refined.
 
 # %%
-model.cell.length_a.free = True
-model.cell.length_c.free = True
+structure.cell.length_a.free = True
+structure.cell.length_c.free = True
 
 expt.linked_phases['hs'].scale.free = True
 expt.instrument.calib_twotheta_offset.free = True
@@ -281,11 +275,11 @@
 # Set more parameters to be refined.
 
 # %%
-model.atom_sites['O'].fract_x.free = True
-model.atom_sites['O'].fract_z.free = True
-model.atom_sites['Cl'].fract_z.free = True
-model.atom_sites['H'].fract_x.free = True
-model.atom_sites['H'].fract_z.free = True
+structure.atom_sites['O'].fract_x.free = True
+structure.atom_sites['O'].fract_z.free = True
+structure.atom_sites['Cl'].fract_z.free = True
+structure.atom_sites['H'].fract_x.free = True
+structure.atom_sites['H'].fract_z.free = True
 
 # %% [markdown]
 # Show free parameters after selection.
@@ -317,11 +311,11 @@
 # Set more parameters to be refined.
 
 # %%
-model.atom_sites['Zn'].b_iso.free = True
-model.atom_sites['Cu'].b_iso.free = True
-model.atom_sites['O'].b_iso.free = True
-model.atom_sites['Cl'].b_iso.free = True
-model.atom_sites['H'].b_iso.free = True
+structure.atom_sites['Zn'].b_iso.free = True
+structure.atom_sites['Cu'].b_iso.free = True
+structure.atom_sites['O'].b_iso.free = True
+structure.atom_sites['Cl'].b_iso.free = True
+structure.atom_sites['H'].b_iso.free = True
 
 # %% [markdown]
 # Show free parameters after selection.
diff --git a/tutorials/ed-7.py b/tutorials/ed-7.py
index 1e12be88..cd719154 100644
--- a/tutorials/ed-7.py
+++ b/tutorials/ed-7.py
@@ -11,38 +11,38 @@
 # %%
 from easydiffraction import ExperimentFactory
 from easydiffraction import Project
-from easydiffraction import SampleModelFactory
+from easydiffraction import StructureFactory
 from easydiffraction import download_data
 
 # %% [markdown]
-# ## Define Sample Model
+# ## Define Structure
 #
-# This section shows how to add sample models and modify their
+# This section shows how to add structures and modify their
 # parameters.
 #
-# #### Create Sample Model
+# #### Create Structure
 
 # %%
-model = SampleModelFactory.create(name='si')
+structure = StructureFactory.from_scratch(name='si')
 
 # %% [markdown]
 # #### Set Space Group
 
 # %%
-model.space_group.name_h_m = 'F d -3 m'
-model.space_group.it_coordinate_system_code = '2'
+structure.space_group.name_h_m = 'F d -3 m'
+structure.space_group.it_coordinate_system_code = '2'
 
 # %% [markdown]
 # #### Set Unit Cell
 
 # %%
-model.cell.length_a = 5.431
+structure.cell.length_a = 5.431
 
 # %% [markdown]
 # #### Set Atom Sites
 
 # %%
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Si',
     type_symbol='Si',
     fract_x=0.125,
@@ -55,7 +55,7 @@
 # ## Define Experiment
 #
 # This section shows how to add experiments, configure their
-# parameters, and link the sample models defined in the previous step.
+# parameters, and link the structures defined in the previous step.
 #
 # #### Download Measured Data
 
@@ -66,7 +66,9 @@
 # #### Create Experiment
 
 # %%
-expt = ExperimentFactory.create(name='sepd', data_path=data_path, beam_mode='time-of-flight')
+expt = ExperimentFactory.from_data_path(
+    name='sepd', data_path=data_path, beam_mode='time-of-flight'
+)
 
 # %% [markdown]
 # #### Set Instrument
@@ -101,18 +103,18 @@
 # %%
 expt.background_type = 'line-segment'
 for x in range(0, 35000, 5000):
-    expt.background.add(id=str(x), x=x, y=200)
+    expt.background.create(id=str(x), x=x, y=200)
 
 # %% [markdown]
 # #### Set Linked Phases
 
 # %%
-expt.linked_phases.add(id='si', scale=10.0)
+expt.linked_phases.create(id='si', scale=10.0)
 
 # %% [markdown]
 # ## Define Project
 #
-# The project object is used to manage the sample model, experiment, and
+# The project object is used to manage the structure, experiment, and
 # analysis.
 #
 # #### Create Project
@@ -121,16 +123,16 @@
 project = Project()
 
 # %% [markdown]
-# #### Add Sample Model
+# #### Add Structure
 
 # %%
-project.sample_models.add(sample_model=model)
+project.structures.add(structure)
 
 # %% [markdown]
 # #### Add Experiment
 
 # %%
-project.experiments.add(experiment=expt)
+project.experiments.add(expt)
 
 # %% [markdown]
 # ## Perform Analysis
@@ -138,16 +140,10 @@
 # This section shows the analysis process, including how to set up
 # calculation and fitting engines.
 #
-# #### Set Calculator
-
-# %%
-project.analysis.current_calculator = 'cryspy'
-
-# %% [markdown]
 # #### Set Minimizer
 
 # %%
-project.analysis.current_minimizer = 'lmfit (leastsq)'
+project.analysis.current_minimizer = 'lmfit'
 
 # %% [markdown]
 # #### Plot Measured vs Calculated
@@ -162,7 +158,7 @@
 # Set parameters to be refined.
 
 # %%
-model.cell.length_a.free = True
+structure.cell.length_a.free = True
 
 expt.linked_phases['si'].scale.free = True
 expt.instrument.calib_d_to_tof_offset.free = True
@@ -265,7 +261,7 @@
 # Set more parameters to be refined.
 
 # %%
-model.atom_sites['Si'].b_iso.free = True
+structure.atom_sites['Si'].b_iso.free = True
 
 # %% [markdown]
 # Show free parameters after selection.
diff --git a/tutorials/ed-8.py b/tutorials/ed-8.py
index 2aa85227..b8cdf0bd 100644
--- a/tutorials/ed-8.py
+++ b/tutorials/ed-8.py
@@ -14,38 +14,38 @@
 # %%
 from easydiffraction import ExperimentFactory
 from easydiffraction import Project
-from easydiffraction import SampleModelFactory
+from easydiffraction import StructureFactory
 from easydiffraction import download_data
 
 # %% [markdown]
-# ## Define Sample Model
+# ## Define Structure
 #
-# This section covers how to add sample models and modify their
+# This section covers how to add structures and modify their
 # parameters.
 #
-# #### Create Sample Model
+# #### Create Structure
 
 # %%
-model = SampleModelFactory.create(name='ncaf')
+structure = StructureFactory.from_scratch(name='ncaf')
 
 # %% [markdown]
 # #### Set Space Group
 
 # %%
-model.space_group.name_h_m = 'I 21 3'
-model.space_group.it_coordinate_system_code = '1'
+structure.space_group.name_h_m = 'I 21 3'
+structure.space_group.it_coordinate_system_code = '1'
 
 # %% [markdown]
 # #### Set Unit Cell
 
 # %%
-model.cell.length_a = 10.250256
+structure.cell.length_a = 10.250256
 
 # %% [markdown]
 # #### Set Atom Sites
 
 # %%
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Ca',
     type_symbol='Ca',
     fract_x=0.4663,
@@ -54,7 +54,7 @@
     wyckoff_letter='b',
     b_iso=0.92,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Al',
     type_symbol='Al',
     fract_x=0.2521,
@@ -63,7 +63,7 @@
     wyckoff_letter='a',
     b_iso=0.73,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='Na',
     type_symbol='Na',
     fract_x=0.0851,
@@ -72,7 +72,7 @@
     wyckoff_letter='a',
     b_iso=2.08,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='F1',
     type_symbol='F',
     fract_x=0.1377,
@@ -81,7 +81,7 @@
     wyckoff_letter='c',
     b_iso=0.90,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='F2',
     type_symbol='F',
     fract_x=0.3625,
@@ -90,7 +90,7 @@
     wyckoff_letter='c',
     b_iso=1.37,
 )
-model.atom_sites.add(
+structure.atom_sites.create(
     label='F3',
     type_symbol='F',
     fract_x=0.4612,
@@ -104,7 +104,7 @@
 # ## Define Experiment
 #
 # This section shows how to add experiments, configure their parameters,
-# and link the sample models defined in the previous step.
+# and link the structures defined in the previous step.
 #
 # #### Download Measured Data
 
@@ -118,14 +118,14 @@
 # #### Create Experiment
 
 # %%
-expt56 = ExperimentFactory.create(
+expt56 = ExperimentFactory.from_data_path(
     name='wish_5_6',
     data_path=data_path56,
     beam_mode='time-of-flight',
 )
 
 # %%
-expt47 = ExperimentFactory.create(
+expt47 = ExperimentFactory.from_data_path(
     name='wish_4_7',
     data_path=data_path47,
     beam_mode='time-of-flight',
@@ -205,7 +205,7 @@
     ],
     start=1,
 ):
-    expt56.background.add(id=str(idx), x=x, y=y)
+    expt56.background.create(id=str(idx), x=x, y=y)
 
 # %%
 expt47.background_type = 'line-segment'
@@ -241,32 +241,32 @@
     ],
     start=1,
 ):
-    expt47.background.add(id=str(idx), x=x, y=y)
+    expt47.background.create(id=str(idx), x=x, y=y)
 
 # %% [markdown]
 # #### Set Linked Phases
 
 # %%
-expt56.linked_phases.add(id='ncaf', scale=1.0)
+expt56.linked_phases.create(id='ncaf', scale=1.0)
 
 # %%
-expt47.linked_phases.add(id='ncaf', scale=2.0)
+expt47.linked_phases.create(id='ncaf', scale=2.0)
 
 # %% [markdown]
 # #### Set Excluded Regions
 
 # %%
-expt56.excluded_regions.add(id='1', start=0, end=10010)
-expt56.excluded_regions.add(id='2', start=100010, end=200000)
+expt56.excluded_regions.create(id='1', start=0, end=10010)
+expt56.excluded_regions.create(id='2', start=100010, end=200000)
 
 # %%
-expt47.excluded_regions.add(id='1', start=0, end=10006)
-expt47.excluded_regions.add(id='2', start=100004, end=200000)
+expt47.excluded_regions.create(id='1', start=0, end=10006)
+expt47.excluded_regions.create(id='2', start=100004, end=200000)
 
 # %% [markdown]
 # ## Define Project
 #
-# The project object is used to manage the sample model, experiments,
+# The project object is used to manage the structure, experiments,
 # and analysis
 #
 # #### Create Project
@@ -283,17 +283,17 @@
 # project.plotter.engine = 'plotly'
 
 # %% [markdown]
-# #### Add Sample Model
+# #### Add Structure
 
 # %%
-project.sample_models.add(sample_model=model)
+project.structures.add(structure)
 
 # %% [markdown]
 # #### Add Experiment
 
 # %%
-project.experiments.add(experiment=expt56)
-project.experiments.add(experiment=expt47)
+project.experiments.add(expt56)
+project.experiments.add(expt47)
 
 # %% [markdown]
 # ## Perform Analysis
@@ -301,33 +301,27 @@
 # This section shows the analysis process, including how to set up
 # calculation and fitting engines.
 #
-# #### Set Calculator
-
-# %%
-project.analysis.current_calculator = 'cryspy'
-
-# %% [markdown]
 # #### Set Minimizer
 
 # %%
-project.analysis.current_minimizer = 'lmfit (leastsq)'
+project.analysis.current_minimizer = 'lmfit'
 
 # %% [markdown]
 # #### Set Fit Mode
 
 # %%
-project.analysis.fit_mode = 'joint'
+project.analysis.fit_mode.mode = 'joint'
 
 # %% [markdown]
 # #### Set Free Parameters
 
 # %%
-model.atom_sites['Ca'].b_iso.free = True
-model.atom_sites['Al'].b_iso.free = True
-model.atom_sites['Na'].b_iso.free = True
-model.atom_sites['F1'].b_iso.free = True
-model.atom_sites['F2'].b_iso.free = True
-model.atom_sites['F3'].b_iso.free = True
+structure.atom_sites['Ca'].b_iso.free = True
+structure.atom_sites['Al'].b_iso.free = True
+structure.atom_sites['Na'].b_iso.free = True
+structure.atom_sites['F1'].b_iso.free = True
+structure.atom_sites['F2'].b_iso.free = True
+structure.atom_sites['F3'].b_iso.free = True
 
 # %%
 expt56.linked_phases['ncaf'].scale.free = True
diff --git a/tutorials/ed-9.py b/tutorials/ed-9.py
index a5fe1647..34da9359 100644
--- a/tutorials/ed-9.py
+++ b/tutorials/ed-9.py
@@ -11,38 +11,38 @@
 # %%
 from easydiffraction import ExperimentFactory
 from easydiffraction import Project
-from easydiffraction import SampleModelFactory
+from easydiffraction import StructureFactory
 from easydiffraction import download_data
 
 # %% [markdown]
-# ## Define Sample Models
+# ## Define Structures
 #
-# This section shows how to add sample models and modify their
+# This section shows how to add structures and modify their
 # parameters.
 #
-# ### Create Sample Model 1: LBCO
+# ### Create Structure 1: LBCO
 
 # %%
-model_1 = SampleModelFactory.create(name='lbco')
+structure_1 = StructureFactory.from_scratch(name='lbco')
 
 # %% [markdown]
 # #### Set Space Group
 
 # %%
-model_1.space_group.name_h_m = 'P m -3 m'
-model_1.space_group.it_coordinate_system_code = '1'
+structure_1.space_group.name_h_m = 'P m -3 m'
+structure_1.space_group.it_coordinate_system_code = '1'
 
 # %% [markdown]
 # #### Set Unit Cell
 
 # %%
-model_1.cell.length_a = 3.8909
+structure_1.cell.length_a = 3.8909
 
 # %% [markdown]
 # #### Set Atom Sites
 
 # %%
-model_1.atom_sites.add(
+structure_1.atom_sites.create(
     label='La',
     type_symbol='La',
     fract_x=0,
@@ -52,7 +52,7 @@
     b_iso=0.2,
     occupancy=0.5,
 )
-model_1.atom_sites.add(
+structure_1.atom_sites.create(
     label='Ba',
     type_symbol='Ba',
     fract_x=0,
@@ -62,7 +62,7 @@
     b_iso=0.2,
     occupancy=0.5,
 )
-model_1.atom_sites.add(
+structure_1.atom_sites.create(
     label='Co',
     type_symbol='Co',
     fract_x=0.5,
@@ -71,7 +71,7 @@
     wyckoff_letter='b',
     b_iso=0.2567,
 )
-model_1.atom_sites.add(
+structure_1.atom_sites.create(
     label='O',
     type_symbol='O',
     fract_x=0,
@@ -82,29 +82,29 @@
 )
 
 # %% [markdown]
-# ### Create Sample Model 2: Si
+# ### Create Structure 2: Si
 
 # %%
-model_2 = SampleModelFactory.create(name='si')
+structure_2 = StructureFactory.from_scratch(name='si')
 
 # %% [markdown]
 # #### Set Space Group
 
 # %%
-model_2.space_group.name_h_m = 'F d -3 m'
-model_2.space_group.it_coordinate_system_code = '2'
+structure_2.space_group.name_h_m = 'F d -3 m'
+structure_2.space_group.it_coordinate_system_code = '2'
 
 # %% [markdown]
 # #### Set Unit Cell
 
 # %%
-model_2.cell.length_a = 5.43146
+structure_2.cell.length_a = 5.43146
 
 # %% [markdown]
 # #### Set Atom Sites
 
 # %%
-model_2.atom_sites.add(
+structure_2.atom_sites.create(
     label='Si',
     type_symbol='Si',
     fract_x=0.0,
@@ -118,7 +118,7 @@
 # ## Define Experiment
 #
 # This section shows how to add experiments, configure their parameters,
-# and link the sample models defined in the previous step.
+# and link the structures defined in the previous step.
 #
 # #### Download Data
 
@@ -129,7 +129,7 @@
 # #### Create Experiment
 
 # %%
-experiment = ExperimentFactory.create(
+experiment = ExperimentFactory.from_data_path(
     name='mcstas',
     data_path=data_path,
     sample_form='powder',
@@ -173,31 +173,31 @@
 # Add background points.
 
 # %%
-experiment.background.add(id='1', x=45000, y=0.2)
-experiment.background.add(id='2', x=50000, y=0.2)
-experiment.background.add(id='3', x=55000, y=0.2)
-experiment.background.add(id='4', x=65000, y=0.2)
-experiment.background.add(id='5', x=70000, y=0.2)
-experiment.background.add(id='6', x=75000, y=0.2)
-experiment.background.add(id='7', x=80000, y=0.2)
-experiment.background.add(id='8', x=85000, y=0.2)
-experiment.background.add(id='9', x=90000, y=0.2)
-experiment.background.add(id='10', x=95000, y=0.2)
-experiment.background.add(id='11', x=100000, y=0.2)
-experiment.background.add(id='12', x=105000, y=0.2)
-experiment.background.add(id='13', x=110000, y=0.2)
+experiment.background.create(id='1', x=45000, y=0.2)
+experiment.background.create(id='2', x=50000, y=0.2)
+experiment.background.create(id='3', x=55000, y=0.2)
+experiment.background.create(id='4', x=65000, y=0.2)
+experiment.background.create(id='5', x=70000, y=0.2)
+experiment.background.create(id='6', x=75000, y=0.2)
+experiment.background.create(id='7', x=80000, y=0.2)
+experiment.background.create(id='8', x=85000, y=0.2)
+experiment.background.create(id='9', x=90000, y=0.2)
+experiment.background.create(id='10', x=95000, y=0.2)
+experiment.background.create(id='11', x=100000, y=0.2)
+experiment.background.create(id='12', x=105000, y=0.2)
+experiment.background.create(id='13', x=110000, y=0.2)
 
 # %% [markdown]
 # #### Set Linked Phases
 
 # %%
-experiment.linked_phases.add(id='lbco', scale=4.0)
-experiment.linked_phases.add(id='si', scale=0.2)
+experiment.linked_phases.create(id='lbco', scale=4.0)
+experiment.linked_phases.create(id='si', scale=0.2)
 
 # %% [markdown]
 # ## Define Project
 #
-# The project object is used to manage sample models, experiments, and
+# The project object is used to manage structures, experiments, and
 # analysis.
 #
 # #### Create Project
@@ -206,23 +206,23 @@
 project = Project()
 
 # %% [markdown]
-# #### Add Sample Models
+# #### Add Structures
 
 # %%
-project.sample_models.add(sample_model=model_1)
-project.sample_models.add(sample_model=model_2)
+project.structures.add(structure_1)
+project.structures.add(structure_2)
 
 # %% [markdown]
-# #### Show Sample Models
+# #### Show Structures
 
 # %%
-project.sample_models.show_names()
+project.structures.show_names()
 
 # %% [markdown]
 # #### Add Experiments
 
 # %%
-project.experiments.add(experiment=experiment)
+project.experiments.add(experiment)
 
 # %% [markdown]
 # #### Set Excluded Regions
@@ -236,8 +236,8 @@
 # Add excluded regions.
 
 # %%
-experiment.excluded_regions.add(id='1', start=0, end=40000)
-experiment.excluded_regions.add(id='2', start=108000, end=200000)
+experiment.excluded_regions.create(id='1', start=0, end=40000)
+experiment.excluded_regions.create(id='2', start=108000, end=200000)
 
 # %% [markdown]
 # Show excluded regions.
@@ -263,28 +263,22 @@
 # This section outlines the analysis process, including how to configure
 # calculation and fitting engines.
 #
-# #### Set Calculator
-
-# %%
-project.analysis.current_calculator = 'cryspy'
-
-# %% [markdown]
 # #### Set Minimizer
 
 # %%
-project.analysis.current_minimizer = 'lmfit (leastsq)'
+project.analysis.current_minimizer = 'lmfit'
 
 # %% [markdown]
 # #### Set Fitting Parameters
 #
-# Set sample model parameters to be optimized.
+# Set structure parameters to be optimized.
 
 # %%
-model_1.cell.length_a.free = True
-model_1.atom_sites['Co'].b_iso.free = True
-model_1.atom_sites['O'].b_iso.free = True
+structure_1.cell.length_a.free = True
+structure_1.atom_sites['Co'].b_iso.free = True
+structure_1.atom_sites['O'].b_iso.free = True
 
-model_2.cell.length_a.free = True
+structure_2.cell.length_a.free = True
 
 # %% [markdown]
 # Set experiment parameters to be optimized.
diff --git a/tutorials/index.json b/tutorials/index.json
index 14808266..138b0e51 100644
--- a/tutorials/index.json
+++ b/tutorials/index.json
@@ -3,14 +3,14 @@
     "url": "https://easyscience.github.io/diffraction-lib/{version}/tutorials/ed-1/ed-1.ipynb",
     "original_name": "quick_from-cif_pd-neut-cwl_LBCO-HRPT",
     "title": "Quick Start: LBCO from CIF",
-    "description": "Minimalistic Rietveld refinement of La0.5Ba0.5CoO3 using sample model and experiment defined via CIF files",
+    "description": "Minimalistic Rietveld refinement of La0.5Ba0.5CoO3 using structure and experiment defined via CIF files",
     "level": "quick"
   },
   "2": {
     "url": "https://easyscience.github.io/diffraction-lib/{version}/tutorials/ed-2/ed-2.ipynb",
     "original_name": "quick_from-code_pd-neut-cwl_LBCO-HRPT",
     "title": "Quick Start: LBCO from Code",
-    "description": "Minimalistic Rietveld refinement of La0.5Ba0.5CoO3 with sample model and experiment defined directly in code",
+    "description": "Minimalistic Rietveld refinement of La0.5Ba0.5CoO3 with structure and experiment defined directly in code",
     "level": "quick"
   },
   "3": {
@@ -96,5 +96,19 @@
     "title": "Crystal Structure: Tb2TiO7, HEiDi",
     "description": "Crystal structure refinement of Tb2TiO7 using single crystal neutron diffraction data from HEiDi at FRM II",
     "level": "intermediate"
+  },
+  "15": {
+    "url": "https://easyscience.github.io/diffraction-lib/{version}/tutorials/ed-15/ed-15.ipynb",
+    "original_name": "",
+    "title": "Crystal Structure: Taurine, SENJU (TOF)",
+    "description": "Crystal structure refinement of Taurine using time-of-flight neutron single crystal diffraction data from SENJU at J-PARC",
+    "level": "intermediate"
+  },
+  "16": {
+    "url": "https://easyscience.github.io/diffraction-lib/{version}/tutorials/ed-16/ed-16.ipynb",
+    "original_name": "advanced_joint-fit_bragg-pdf_pd-neut-tof_Si",
+    "title": "Advanced: Si Joint Bragg+PDF Fit",
+    "description": "Joint refinement of Si crystal structure combining Bragg diffraction (SEPD) and pair distribution function (NOMAD) analysis",
+    "level": "advanced"
   }
 }