diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index d29a15d1..6c342493 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -100,6 +100,13 @@
case.
- Don't add dependencies without asking.
+## Tutorials
+
+- Jupyter notebooks (`docs/docs/tutorials/*.ipynb`) are **generated
+ artifacts** — never edit them by hand. Edit only the corresponding
+ `*.py` script, then run `pixi run notebook-convert` followed by
+ `pixi run notebook-prepare` to regenerate the notebook.
+
## Changes
- Before implementing any structural or design change (new categories,
diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md
index 884ed4f1..65187b07 100644
--- a/docs/architecture/architecture.md
+++ b/docs/architecture/architecture.md
@@ -727,6 +727,12 @@ project_dir/
All examples below are drawn from the actual tutorials (`tutorials/`).
+> **Notebook workflow:** Jupyter notebooks (`*.ipynb`) in
+> `docs/docs/tutorials/` are generated artifacts. Edit only the
+> corresponding `*.py` script, then run `pixi run notebook-convert`
+> followed by `pixi run notebook-prepare` to regenerate the notebook.
+> Never edit `*.ipynb` files by hand.
+
### 8.1 Project Setup
```python
diff --git a/docs/docs/tutorials/ed-13.ipynb b/docs/docs/tutorials/ed-13.ipynb
index d35f3628..a31467a1 100644
--- a/docs/docs/tutorials/ed-13.ipynb
+++ b/docs/docs/tutorials/ed-13.ipynb
@@ -396,7 +396,7 @@
"TOF, respectively.\n",
"\n",
"You can set them manually, but it is more convenient to use the\n",
- "`get_value_from_xye_header` function from the EasyDiffraction library."
+ "`extract_metadata` function from the EasyDiffraction library."
]
},
{
@@ -420,11 +420,11 @@
"metadata": {},
"outputs": [],
"source": [
- "project_1.experiments['sim_si'].instrument.setup_twotheta_bank = ed.get_value_from_xye_header(\n",
- " si_xye_path, 'two_theta'\n",
+ "project_1.experiments['sim_si'].instrument.setup_twotheta_bank = ed.extract_metadata(\n",
+ " si_xye_path, r'two_theta\\s*=\\s*([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)'\n",
")\n",
- "project_1.experiments['sim_si'].instrument.calib_d_to_tof_linear = ed.get_value_from_xye_header(\n",
- " si_xye_path, 'DIFC'\n",
+ "project_1.experiments['sim_si'].instrument.calib_d_to_tof_linear = ed.extract_metadata(\n",
+ " si_xye_path, r'DIFC\\s*=\\s*([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)'\n",
")"
]
},
@@ -1485,11 +1485,11 @@
},
"outputs": [],
"source": [
- "project_2.experiments['sim_lbco'].instrument.setup_twotheta_bank = ed.get_value_from_xye_header(\n",
- " lbco_xye_path, 'two_theta'\n",
+ "project_2.experiments['sim_lbco'].instrument.setup_twotheta_bank = ed.extract_metadata(\n",
+ " lbco_xye_path, r'two_theta\\s*=\\s*([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)'\n",
")\n",
- "project_2.experiments['sim_lbco'].instrument.calib_d_to_tof_linear = ed.get_value_from_xye_header(\n",
- " lbco_xye_path, 'DIFC'\n",
+ "project_2.experiments['sim_lbco'].instrument.calib_d_to_tof_linear = ed.extract_metadata(\n",
+ " lbco_xye_path, r'DIFC\\s*=\\s*([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)'\n",
")"
]
},
diff --git a/docs/docs/tutorials/ed-13.py b/docs/docs/tutorials/ed-13.py
index e29aad49..42996532 100644
--- a/docs/docs/tutorials/ed-13.py
+++ b/docs/docs/tutorials/ed-13.py
@@ -212,7 +212,7 @@
# TOF, respectively.
#
# You can set them manually, but it is more convenient to use the
-# `get_value_from_xye_header` function from the EasyDiffraction library.
+# `extract_metadata` function from the EasyDiffraction library.
# %% [markdown] tags=["doc-link"]
# 📖 See
@@ -220,11 +220,11 @@
# for more details about the instrument parameters.
# %%
-project_1.experiments['sim_si'].instrument.setup_twotheta_bank = ed.get_value_from_xye_header(
- si_xye_path, 'two_theta'
+project_1.experiments['sim_si'].instrument.setup_twotheta_bank = ed.extract_metadata(
+ si_xye_path, r'two_theta\s*=\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'
)
-project_1.experiments['sim_si'].instrument.calib_d_to_tof_linear = ed.get_value_from_xye_header(
- si_xye_path, 'DIFC'
+project_1.experiments['sim_si'].instrument.calib_d_to_tof_linear = ed.extract_metadata(
+ si_xye_path, r'DIFC\s*=\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'
)
# %% [markdown]
@@ -804,11 +804,11 @@
# **Solution:**
# %% tags=["solution", "hide-input"]
-project_2.experiments['sim_lbco'].instrument.setup_twotheta_bank = ed.get_value_from_xye_header(
- lbco_xye_path, 'two_theta'
+project_2.experiments['sim_lbco'].instrument.setup_twotheta_bank = ed.extract_metadata(
+ lbco_xye_path, r'two_theta\s*=\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'
)
-project_2.experiments['sim_lbco'].instrument.calib_d_to_tof_linear = ed.get_value_from_xye_header(
- lbco_xye_path, 'DIFC'
+project_2.experiments['sim_lbco'].instrument.calib_d_to_tof_linear = ed.extract_metadata(
+ lbco_xye_path, r'DIFC\s*=\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'
)
# %% [markdown]
diff --git a/docs/docs/tutorials/ed-17.ipynb b/docs/docs/tutorials/ed-17.ipynb
new file mode 100644
index 00000000..566c1783
--- /dev/null
+++ b/docs/docs/tutorials/ed-17.ipynb
@@ -0,0 +1,601 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# Structure Refinement: Co2SiO4, D20 (T-scan)\n",
+ "\n",
+ "This example demonstrates a Rietveld refinement of Co2SiO4 crystal\n",
+ "structure using constant wavelength neutron powder diffraction data\n",
+ "from D20 at ILL. A sequential refinement of the same structure against\n",
+ "a temperature scan is performed to show how to manage multiple\n",
+ "experiments in a project."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1",
+ "metadata": {},
+ "source": [
+ "## Import Library"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import easydiffraction as ed"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3",
+ "metadata": {},
+ "source": [
+ "## Step 1: Define Project\n",
+ "\n",
+ "The project object is used to manage the structure, experiment, and\n",
+ "analysis."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Create minimal project without name and description\n",
+ "project = ed.Project()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5",
+ "metadata": {},
+ "source": [
+ "## Step 2: Define Crystal Structure\n",
+ "\n",
+ "This section shows how to add structures and modify their\n",
+ "parameters.\n",
+ "\n",
+ "#### Create Structure"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "project.structures.create(name='cosio')\n",
+ "structure = project.structures['cosio']"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7",
+ "metadata": {},
+ "source": [
+ "#### Set Space Group"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "structure.space_group.name_h_m = 'P n m a'\n",
+ "structure.space_group.it_coordinate_system_code = 'abc'"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9",
+ "metadata": {},
+ "source": [
+ "#### Set Unit Cell"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "10",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "structure.cell.length_a = 10.31\n",
+ "structure.cell.length_b = 6.0\n",
+ "structure.cell.length_c = 4.79"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "11",
+ "metadata": {},
+ "source": [
+ "#### Set Atom Sites"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "12",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "structure.atom_sites.create(\n",
+ " label='Co1',\n",
+ " type_symbol='Co',\n",
+ " fract_x=0,\n",
+ " fract_y=0,\n",
+ " fract_z=0,\n",
+ " wyckoff_letter='a',\n",
+ " b_iso=0.3,\n",
+ ")\n",
+ "structure.atom_sites.create(\n",
+ " label='Co2',\n",
+ " type_symbol='Co',\n",
+ " fract_x=0.279,\n",
+ " fract_y=0.25,\n",
+ " fract_z=0.985,\n",
+ " wyckoff_letter='c',\n",
+ " b_iso=0.3,\n",
+ ")\n",
+ "structure.atom_sites.create(\n",
+ " label='Si',\n",
+ " type_symbol='Si',\n",
+ " fract_x=0.094,\n",
+ " fract_y=0.25,\n",
+ " fract_z=0.429,\n",
+ " wyckoff_letter='c',\n",
+ " b_iso=0.34,\n",
+ ")\n",
+ "structure.atom_sites.create(\n",
+ " label='O1',\n",
+ " type_symbol='O',\n",
+ " fract_x=0.091,\n",
+ " fract_y=0.25,\n",
+ " fract_z=0.771,\n",
+ " wyckoff_letter='c',\n",
+ " b_iso=0.63,\n",
+ ")\n",
+ "structure.atom_sites.create(\n",
+ " label='O2',\n",
+ " type_symbol='O',\n",
+ " fract_x=0.448,\n",
+ " fract_y=0.25,\n",
+ " fract_z=0.217,\n",
+ " wyckoff_letter='c',\n",
+ " b_iso=0.59,\n",
+ ")\n",
+ "structure.atom_sites.create(\n",
+ " label='O3',\n",
+ " type_symbol='O',\n",
+ " fract_x=0.164,\n",
+ " fract_y=0.032,\n",
+ " fract_z=0.28,\n",
+ " wyckoff_letter='d',\n",
+ " b_iso=0.83,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "13",
+ "metadata": {},
+ "source": [
+ "## Define Experiment\n",
+ "\n",
+ "This section shows how to add experiments, configure their parameters,\n",
+ "and link the structures defined in the previous step.\n",
+ "\n",
+ "#### Download Measured Data"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "14",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "file_path = ed.download_data(id=27, destination='data')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "15",
+ "metadata": {},
+ "source": [
+ "#### Create Experiments and Set Temperature"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "16",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "data_paths = ed.extract_data_paths_from_zip(file_path)\n",
+ "for i, data_path in enumerate(data_paths, start=1):\n",
+ " name = f'd20_{i}'\n",
+ " project.experiments.add_from_data_path(\n",
+ " name=name,\n",
+ " data_path=data_path,\n",
+ " )\n",
+ " expt = project.experiments[name]\n",
+ " expt.diffrn.ambient_temperature = ed.extract_metadata(\n",
+ " file_path=data_path,\n",
+ " pattern=r'^TEMP\\s+([0-9.]+)',\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "17",
+ "metadata": {},
+ "source": [
+ "#### Set Instrument"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "18",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for expt in project.experiments:\n",
+ " expt.instrument.setup_wavelength = 1.87\n",
+ " expt.instrument.calib_twotheta_offset = 0.29"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "19",
+ "metadata": {},
+ "source": [
+ "#### Set Peak Profile"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "20",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for expt in project.experiments:\n",
+ " expt.peak.broad_gauss_u = 0.24\n",
+ " expt.peak.broad_gauss_v = -0.53\n",
+ " expt.peak.broad_gauss_w = 0.38\n",
+ " expt.peak.broad_lorentz_y = 0.02"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "21",
+ "metadata": {},
+ "source": [
+ "#### Set Excluded Regions"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "22",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for expt in project.experiments:\n",
+ " expt.excluded_regions.create(id='1', start=0, end=8)\n",
+ " expt.excluded_regions.create(id='2', start=150, end=180)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "23",
+ "metadata": {},
+ "source": [
+ "#### Set Background"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "24",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for expt in project.experiments:\n",
+ " expt.background.create(id='1', x=8, y=609)\n",
+ " expt.background.create(id='2', x=9, y=581)\n",
+ " expt.background.create(id='3', x=10, y=563)\n",
+ " expt.background.create(id='4', x=11, y=540)\n",
+ " expt.background.create(id='5', x=12, y=520)\n",
+ " expt.background.create(id='6', x=15, y=507)\n",
+ " expt.background.create(id='7', x=25, y=463)\n",
+ " expt.background.create(id='8', x=30, y=434)\n",
+ " expt.background.create(id='9', x=50, y=451)\n",
+ " expt.background.create(id='10', x=70, y=431)\n",
+ " expt.background.create(id='11', x=90, y=414)\n",
+ " expt.background.create(id='12', x=110, y=361)\n",
+ " expt.background.create(id='13', x=130, y=292)\n",
+ " expt.background.create(id='14', x=150, y=241)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "25",
+ "metadata": {},
+ "source": [
+ "#### Set Linked Phases"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "26",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for expt in project.experiments:\n",
+ " expt.linked_phases.create(id='cosio', scale=1.2)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "27",
+ "metadata": {},
+ "source": [
+ "## Perform Analysis\n",
+ "\n",
+ "This section shows the analysis process, including how to set up\n",
+ "calculation and fitting engines."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "28",
+ "metadata": {},
+ "source": [
+ "#### Set Free Parameters"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "29",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "structure.cell.length_a.free = True\n",
+ "structure.cell.length_b.free = True\n",
+ "structure.cell.length_c.free = True\n",
+ "\n",
+ "structure.atom_sites['Co2'].fract_x.free = True\n",
+ "structure.atom_sites['Co2'].fract_z.free = True\n",
+ "structure.atom_sites['Si'].fract_x.free = True\n",
+ "structure.atom_sites['Si'].fract_z.free = True\n",
+ "structure.atom_sites['O1'].fract_x.free = True\n",
+ "structure.atom_sites['O1'].fract_z.free = True\n",
+ "structure.atom_sites['O2'].fract_x.free = True\n",
+ "structure.atom_sites['O2'].fract_z.free = True\n",
+ "structure.atom_sites['O3'].fract_x.free = True\n",
+ "structure.atom_sites['O3'].fract_y.free = True\n",
+ "structure.atom_sites['O3'].fract_z.free = True\n",
+ "\n",
+ "structure.atom_sites['Co1'].b_iso.free = True\n",
+ "structure.atom_sites['Co2'].b_iso.free = True\n",
+ "structure.atom_sites['Si'].b_iso.free = True\n",
+ "structure.atom_sites['O1'].b_iso.free = True\n",
+ "structure.atom_sites['O2'].b_iso.free = True\n",
+ "structure.atom_sites['O3'].b_iso.free = True"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "30",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for expt in project.experiments:\n",
+ " expt.linked_phases['cosio'].scale.free = True\n",
+ "\n",
+ " expt.instrument.calib_twotheta_offset.free = True\n",
+ "\n",
+ " expt.peak.broad_gauss_u.free = True\n",
+ " expt.peak.broad_gauss_v.free = True\n",
+ " expt.peak.broad_gauss_w.free = True\n",
+ " expt.peak.broad_lorentz_y.free = True\n",
+ "\n",
+ " for point in expt.background:\n",
+ " point.y.free = True"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "31",
+ "metadata": {},
+ "source": [
+ "#### Set Constraints\n",
+ "\n",
+ "Set aliases for parameters."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "32",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "project.analysis.aliases.create(\n",
+ " label='biso_Co1',\n",
+ " param_uid=structure.atom_sites['Co1'].b_iso.uid,\n",
+ ")\n",
+ "project.analysis.aliases.create(\n",
+ " label='biso_Co2',\n",
+ " param_uid=structure.atom_sites['Co2'].b_iso.uid,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "33",
+ "metadata": {},
+ "source": [
+ "Set constraints."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "34",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "project.analysis.constraints.create(\n",
+ " expression='biso_Co2 = biso_Co1',\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "35",
+ "metadata": {},
+ "source": [
+ "Apply constraints."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "36",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "project.analysis.apply_constraints()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "37",
+ "metadata": {},
+ "source": [
+ "#### Set Fit Mode and Weights"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "38",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "project.analysis.fit_mode.mode = 'single'"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "39",
+ "metadata": {},
+ "source": [
+ "#### Run Fitting"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "40",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "project.analysis.fit()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "41",
+ "metadata": {},
+ "source": [
+ "#### Plot Measured vs Calculated"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "42",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "last_expt_name = project.experiments.names[-1]\n",
+ "project.plot_meas_vs_calc(expt_name=last_expt_name, show_residual=True)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "43",
+ "metadata": {},
+ "source": [
+ "#### Plot parameters evolution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "44",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "temperature = project.experiments[0].diffrn.ambient_temperature"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "45",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "project.plot_param_series(structure.cell.length_a, versus=temperature)\n",
+ "project.plot_param_series(structure.cell.length_b, versus=temperature)\n",
+ "project.plot_param_series(structure.cell.length_c, versus=temperature)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "46",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "project.plot_param_series(structure.atom_sites['Co1'].b_iso, versus=temperature)\n",
+ "project.plot_param_series(structure.atom_sites['Si'].b_iso, versus=temperature)\n",
+ "project.plot_param_series(structure.atom_sites['O1'].b_iso, versus=temperature)\n",
+ "project.plot_param_series(structure.atom_sites['O2'].b_iso, versus=temperature)\n",
+ "project.plot_param_series(structure.atom_sites['O3'].b_iso, versus=temperature)"
+ ]
+ }
+ ],
+ "metadata": {
+ "jupytext": {
+ "cell_metadata_filter": "-all",
+ "main_language": "python",
+ "notebook_metadata_filter": "-all"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/docs/tutorials/ed-17.py b/docs/docs/tutorials/ed-17.py
new file mode 100644
index 00000000..0f059849
--- /dev/null
+++ b/docs/docs/tutorials/ed-17.py
@@ -0,0 +1,304 @@
+# %% [markdown]
+# # Structure Refinement: Co2SiO4, D20 (T-scan)
+#
+# This example demonstrates a Rietveld refinement of Co2SiO4 crystal
+# structure using constant wavelength neutron powder diffraction data
+# from D20 at ILL. A sequential refinement of the same structure against
+# a temperature scan is performed to show how to manage multiple
+# experiments in a project.
+
+# %% [markdown]
+# ## Import Library
+
+# %%
+import easydiffraction as ed
+
+# %% [markdown]
+# ## Step 1: Define Project
+#
+# The project object is used to manage the structure, experiment, and
+# analysis.
+
+# %%
+# Create minimal project without name and description
+project = ed.Project()
+
+# %% [markdown]
+# ## Step 2: Define Crystal Structure
+#
+# This section shows how to add structures and modify their
+# parameters.
+#
+# #### Create Structure
+
+# %%
+project.structures.create(name='cosio')
+structure = project.structures['cosio']
+
+# %% [markdown]
+# #### Set Space Group
+
+# %%
+structure.space_group.name_h_m = 'P n m a'
+structure.space_group.it_coordinate_system_code = 'abc'
+
+# %% [markdown]
+# #### Set Unit Cell
+
+# %%
+structure.cell.length_a = 10.31
+structure.cell.length_b = 6.0
+structure.cell.length_c = 4.79
+
+# %% [markdown]
+# #### Set Atom Sites
+
+# %%
+structure.atom_sites.create(
+ label='Co1',
+ type_symbol='Co',
+ fract_x=0,
+ fract_y=0,
+ fract_z=0,
+ wyckoff_letter='a',
+ b_iso=0.3,
+)
+structure.atom_sites.create(
+ label='Co2',
+ type_symbol='Co',
+ fract_x=0.279,
+ fract_y=0.25,
+ fract_z=0.985,
+ wyckoff_letter='c',
+ b_iso=0.3,
+)
+structure.atom_sites.create(
+ label='Si',
+ type_symbol='Si',
+ fract_x=0.094,
+ fract_y=0.25,
+ fract_z=0.429,
+ wyckoff_letter='c',
+ b_iso=0.34,
+)
+structure.atom_sites.create(
+ label='O1',
+ type_symbol='O',
+ fract_x=0.091,
+ fract_y=0.25,
+ fract_z=0.771,
+ wyckoff_letter='c',
+ b_iso=0.63,
+)
+structure.atom_sites.create(
+ label='O2',
+ type_symbol='O',
+ fract_x=0.448,
+ fract_y=0.25,
+ fract_z=0.217,
+ wyckoff_letter='c',
+ b_iso=0.59,
+)
+structure.atom_sites.create(
+ label='O3',
+ type_symbol='O',
+ fract_x=0.164,
+ fract_y=0.032,
+ fract_z=0.28,
+ wyckoff_letter='d',
+ b_iso=0.83,
+)
+
+# %% [markdown]
+# ## Define Experiment
+#
+# This section shows how to add experiments, configure their parameters,
+# and link the structures defined in the previous step.
+#
+# #### Download Measured Data
+
+# %%
+file_path = ed.download_data(id=27, destination='data')
+
+# %% [markdown]
+# #### Create Experiments and Set Temperature
+
+# %%
+data_paths = ed.extract_data_paths_from_zip(file_path)
+for i, data_path in enumerate(data_paths, start=1):
+ name = f'd20_{i}'
+ project.experiments.add_from_data_path(
+ name=name,
+ data_path=data_path,
+ )
+ expt = project.experiments[name]
+ expt.diffrn.ambient_temperature = ed.extract_metadata(
+ file_path=data_path,
+ pattern=r'^TEMP\s+([0-9.]+)',
+ )
+
+# %% [markdown]
+# #### Set Instrument
+
+# %%
+for expt in project.experiments:
+ expt.instrument.setup_wavelength = 1.87
+ expt.instrument.calib_twotheta_offset = 0.29
+
+# %% [markdown]
+# #### Set Peak Profile
+
+# %%
+for expt in project.experiments:
+ expt.peak.broad_gauss_u = 0.24
+ expt.peak.broad_gauss_v = -0.53
+ expt.peak.broad_gauss_w = 0.38
+ expt.peak.broad_lorentz_y = 0.02
+
+# %% [markdown]
+# #### Set Excluded Regions
+
+# %%
+for expt in project.experiments:
+ expt.excluded_regions.create(id='1', start=0, end=8)
+ expt.excluded_regions.create(id='2', start=150, end=180)
+
+# %% [markdown]
+# #### Set Background
+
+# %%
+for expt in project.experiments:
+ expt.background.create(id='1', x=8, y=609)
+ expt.background.create(id='2', x=9, y=581)
+ expt.background.create(id='3', x=10, y=563)
+ expt.background.create(id='4', x=11, y=540)
+ expt.background.create(id='5', x=12, y=520)
+ expt.background.create(id='6', x=15, y=507)
+ expt.background.create(id='7', x=25, y=463)
+ expt.background.create(id='8', x=30, y=434)
+ expt.background.create(id='9', x=50, y=451)
+ expt.background.create(id='10', x=70, y=431)
+ expt.background.create(id='11', x=90, y=414)
+ expt.background.create(id='12', x=110, y=361)
+ expt.background.create(id='13', x=130, y=292)
+ expt.background.create(id='14', x=150, y=241)
+
+# %% [markdown]
+# #### Set Linked Phases
+
+# %%
+for expt in project.experiments:
+ expt.linked_phases.create(id='cosio', scale=1.2)
+
+# %% [markdown]
+# ## Perform Analysis
+#
+# This section shows the analysis process, including how to set up
+# calculation and fitting engines.
+
+# %% [markdown]
+# #### Set Free Parameters
+
+# %%
+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
+
+# %%
+for expt in project.experiments:
+ expt.linked_phases['cosio'].scale.free = True
+
+ expt.instrument.calib_twotheta_offset.free = True
+
+ expt.peak.broad_gauss_u.free = True
+ expt.peak.broad_gauss_v.free = True
+ expt.peak.broad_gauss_w.free = True
+ expt.peak.broad_lorentz_y.free = True
+
+ for point in expt.background:
+ point.y.free = True
+
+# %% [markdown]
+# #### Set Constraints
+#
+# Set aliases for parameters.
+
+# %%
+project.analysis.aliases.create(
+ label='biso_Co1',
+ param_uid=structure.atom_sites['Co1'].b_iso.uid,
+)
+project.analysis.aliases.create(
+ label='biso_Co2',
+ param_uid=structure.atom_sites['Co2'].b_iso.uid,
+)
+
+# %% [markdown]
+# Set constraints.
+
+# %%
+project.analysis.constraints.create(
+ expression='biso_Co2 = biso_Co1',
+)
+
+# %% [markdown]
+# Apply constraints.
+
+# %%
+project.analysis.apply_constraints()
+
+# %% [markdown]
+# #### Set Fit Mode and Weights
+
+# %%
+project.analysis.fit_mode.mode = 'single'
+
+# %% [markdown]
+# #### Run Fitting
+
+# %%
+project.analysis.fit()
+
+# %% [markdown]
+# #### Plot Measured vs Calculated
+
+# %%
+last_expt_name = project.experiments.names[-1]
+project.plot_meas_vs_calc(expt_name=last_expt_name, show_residual=True)
+
+# %% [markdown]
+# #### Plot parameters evolution
+
+# %%
+temperature = project.experiments[0].diffrn.ambient_temperature
+
+# %%
+project.plot_param_series(structure.cell.length_a, versus=temperature)
+project.plot_param_series(structure.cell.length_b, versus=temperature)
+project.plot_param_series(structure.cell.length_c, versus=temperature)
+
+# %%
+project.plot_param_series(structure.atom_sites['Co1'].b_iso, versus=temperature)
+project.plot_param_series(structure.atom_sites['Si'].b_iso, versus=temperature)
+project.plot_param_series(structure.atom_sites['O1'].b_iso, versus=temperature)
+project.plot_param_series(structure.atom_sites['O2'].b_iso, versus=temperature)
+project.plot_param_series(structure.atom_sites['O3'].b_iso, versus=temperature)
diff --git a/docs/docs/tutorials/ed-3.ipynb b/docs/docs/tutorials/ed-3.ipynb
index 0fadb404..5104fcab 100644
--- a/docs/docs/tutorials/ed-3.ipynb
+++ b/docs/docs/tutorials/ed-3.ipynb
@@ -1429,7 +1429,7 @@
"metadata": {},
"outputs": [],
"source": [
- "project.analysis.constraints.create(lhs_alias='biso_Ba', rhs_expr='biso_La')"
+ "project.analysis.constraints.create(expression='biso_Ba = biso_La')"
]
},
{
@@ -1614,8 +1614,7 @@
"outputs": [],
"source": [
"project.analysis.constraints.create(\n",
- " lhs_alias='occ_Ba',\n",
- " rhs_expr='1 - occ_La',\n",
+ " expression='occ_Ba = 1 - occ_La',\n",
")"
]
},
diff --git a/docs/docs/tutorials/ed-3.py b/docs/docs/tutorials/ed-3.py
index 051faae0..23b60d88 100644
--- a/docs/docs/tutorials/ed-3.py
+++ b/docs/docs/tutorials/ed-3.py
@@ -578,7 +578,7 @@
# Set constraints.
# %%
-project.analysis.constraints.create(lhs_alias='biso_Ba', rhs_expr='biso_La')
+project.analysis.constraints.create(expression='biso_Ba = biso_La')
# %% [markdown]
# Show defined constraints.
@@ -648,8 +648,7 @@
# %%
project.analysis.constraints.create(
- lhs_alias='occ_Ba',
- rhs_expr='1 - occ_La',
+ expression='occ_Ba = 1 - occ_La',
)
# %% [markdown]
diff --git a/docs/docs/tutorials/ed-5.ipynb b/docs/docs/tutorials/ed-5.ipynb
index 2ee5ec1a..903fe2ae 100644
--- a/docs/docs/tutorials/ed-5.ipynb
+++ b/docs/docs/tutorials/ed-5.ipynb
@@ -527,8 +527,7 @@
"outputs": [],
"source": [
"project.analysis.constraints.create(\n",
- " lhs_alias='biso_Co2',\n",
- " rhs_expr='biso_Co1',\n",
+ " expression='biso_Co2 = biso_Co1',\n",
")"
]
},
diff --git a/docs/docs/tutorials/ed-5.py b/docs/docs/tutorials/ed-5.py
index f7a30da2..74e1d887 100644
--- a/docs/docs/tutorials/ed-5.py
+++ b/docs/docs/tutorials/ed-5.py
@@ -267,8 +267,7 @@
# %%
project.analysis.constraints.create(
- lhs_alias='biso_Co2',
- rhs_expr='biso_Co1',
+ expression='biso_Co2 = biso_Co1',
)
# %% [markdown]
diff --git a/docs/docs/tutorials/index.json b/docs/docs/tutorials/index.json
index d40d1896..3f2f223c 100644
--- a/docs/docs/tutorials/index.json
+++ b/docs/docs/tutorials/index.json
@@ -110,5 +110,12 @@
"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"
+ },
+ "17": {
+ "url": "https://easyscience.github.io/diffraction-lib/{version}/tutorials/ed-17/ed-17.ipynb",
+ "original_name": "",
+ "title": "Structure Refinement: Co2SiO4, D20 (Temperature scan)",
+ "description": "Sequential Rietveld refinement of Co2SiO4 using constant wavelength neutron powder diffraction data from D20 at ILL across a temperature scan",
+ "level": "advanced"
}
}
diff --git a/docs/docs/tutorials/index.md b/docs/docs/tutorials/index.md
index 5e406e59..ef22e949 100644
--- a/docs/docs/tutorials/index.md
+++ b/docs/docs/tutorials/index.md
@@ -85,6 +85,9 @@ The tutorials are organized into the following categories.
diffraction (SEPD) and pair distribution function (NOMAD) analysis. A
single shared structure is refined simultaneously against both
datasets.
+- [Co2SiO4 Temperature scan](ed-17.ipynb) – Sequential Rietveld
+ refinement of Co2SiO4 using constant wavelength neutron powder
+ diffraction data from D20 at ILL across a temperature scan.
## Workshops & Schools
diff --git a/docs/docs/user-guide/analysis-workflow/analysis.md b/docs/docs/user-guide/analysis-workflow/analysis.md
index f0341519..76aaa699 100644
--- a/docs/docs/user-guide/analysis-workflow/analysis.md
+++ b/docs/docs/user-guide/analysis-workflow/analysis.md
@@ -294,23 +294,17 @@ project.analysis.aliases.create(
### Setting Constraints
Now that you have set the aliases, you can define constraints using the
-`add` method of the `constraints` object. Constraints are defined by
-specifying the **left-hand side (lhs) alias** and the **right-hand side
-(rhs) expression**. The rhs expression can be a simple alias or a more
-complex expression involving other aliases.
+`create` method of the `constraints` object. Each constraint is a single
+expression string of the form `lhs = rhs`, where the left-hand side is
+an alias and the right-hand side is an expression involving other
+aliases.
An example of setting constraints for the aliases defined above:
```python
-project.analysis.constraints.create(
- lhs_alias='biso_Ba',
- rhs_expr='biso_La',
-)
+project.analysis.constraints.create(expression='biso_Ba = biso_La')
-project.analysis.constraints.create(
- lhs_alias='occ_Ba',
- rhs_expr='1 - occ_La',
-)
+project.analysis.constraints.create(expression='occ_Ba = 1 - occ_La')
```
These constraints ensure that the `biso_Ba` parameter is equal to
@@ -332,10 +326,10 @@ The example of the output is:
User defined constraints
-| lhs_alias | rhs_expr | full expression |
-| --------- | ---------- | ------------------- |
-| biso_Ba | biso_La | biso_Ba = biso_La |
-| occ_Ba | 1 - occ_La | occ_Ba = 1 - occ_La |
+| expression |
+| ------------------- |
+| biso_Ba = biso_La |
+| occ_Ba = 1 - occ_La |
## Analysis as CIF
@@ -363,10 +357,9 @@ Example output:
│ occ_Ba lbco.atom_site.Ba.occupancy │
│ │
│ loop_ │
-│ _constraint.lhs_alias │
-│ _constraint.rhs_expr │
-│ biso_Ba biso_La │
-│ occ_Ba "1 - occ_La" │
+│ _constraint.expression │
+│ "biso_Ba = biso_La" │
+│ "occ_Ba = 1 - occ_La" │
╘════════════════════════════════════════════════╛
```
diff --git a/docs/docs/user-guide/analysis-workflow/project.md b/docs/docs/user-guide/analysis-workflow/project.md
index ad41f852..60c944b9 100644
--- a/docs/docs/user-guide/analysis-workflow/project.md
+++ b/docs/docs/user-guide/analysis-workflow/project.md
@@ -260,10 +260,9 @@ occ_La lbco.atom_site.La.occupancy
occ_Ba lbco.atom_site.Ba.occupancy
loop_
-_constraint.lhs_alias
-_constraint.rhs_expr
-biso_Ba biso_La
-occ_Ba "1 - occ_La"
+_constraint.expression
+"biso_Ba = biso_La"
+"occ_Ba = 1 - occ_La"
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 55ca5f22..c272bdec 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -211,6 +211,7 @@ nav:
- PbSO4 NPD+XRD: tutorials/ed-4.ipynb
- LBCO+Si McStas: tutorials/ed-9.ipynb
- Si Bragg+PDF: tutorials/ed-16.ipynb
+ - Co2SiO4 T-scan: tutorials/ed-17.ipynb
- Workshops & Schools:
- DMSC Summer School: tutorials/ed-13.ipynb
- API Reference:
diff --git a/pixi.lock b/pixi.lock
index 1b3db850..5195fec7 100644
--- a/pixi.lock
+++ b/pixi.lock
@@ -4869,8 +4869,8 @@ packages:
requires_python: '>=3.5'
- pypi: ./
name: easydiffraction
- version: 0.10.2+dev46
- sha256: 9ada6af993ed7303fd58593648683db9afb40a7c191b5f5f7384f37934362c42
+ version: 0.10.2+dev7
+ sha256: 322f1ccfe97af5f9fa88e7c14eb8b8e12221deb7058cef6320de02e52560d1ad
requires_dist:
- asciichartpy
- asteval
diff --git a/pyproject.toml b/pyproject.toml
index 16d18621..88036948 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -322,6 +322,10 @@ ignore = [
'docs/**' = [
'INP001', # https://docs.astral.sh/ruff/rules/implicit-namespace-package/
'T201', # https://docs.astral.sh/ruff/rules/print/
+ # Temporary:
+ 'ANN',
+ 'D',
+ 'W',
]
# Specific options for certain rules
diff --git a/src/easydiffraction/__init__.py b/src/easydiffraction/__init__.py
index e2fb3c4e..10308402 100644
--- a/src/easydiffraction/__init__.py
+++ b/src/easydiffraction/__init__.py
@@ -3,6 +3,9 @@
from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory
from easydiffraction.datablocks.structure.item.factory import StructureFactory
+from easydiffraction.io.ascii import extract_data_paths_from_dir
+from easydiffraction.io.ascii import extract_data_paths_from_zip
+from easydiffraction.io.ascii import extract_metadata
from easydiffraction.project.project import Project
from easydiffraction.utils.logging import Logger
from easydiffraction.utils.logging import console
@@ -10,6 +13,5 @@
from easydiffraction.utils.utils import download_all_tutorials
from easydiffraction.utils.utils import download_data
from easydiffraction.utils.utils import download_tutorial
-from easydiffraction.utils.utils import get_value_from_xye_header
from easydiffraction.utils.utils import list_tutorials
from easydiffraction.utils.utils import show_version
diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py
index 874017cb..f97c2e13 100644
--- a/src/easydiffraction/analysis/analysis.py
+++ b/src/easydiffraction/analysis/analysis.py
@@ -33,11 +33,6 @@ class Analysis:
This class wires calculators and minimizers, exposes a compact
interface for parameters, constraints and results, and coordinates
computations across the project's structures and experiments.
-
- Typical usage:
- - Display or filter parameters to fit.
- - Select a calculator/minimizer implementation.
- - Calculate patterns and run single or joint fits.
"""
def __init__(self, project: object) -> None:
@@ -59,6 +54,8 @@ def __init__(self, project: object) -> None:
self._fit_mode = FitModeFactory.create(self._fit_mode_type)
self._joint_fit_experiments = JointFitExperiments()
self.fitter = Fitter('lmfit')
+ self.fit_results = None
+ self._parameter_snapshots: dict[str, dict[str, dict]] = {}
def help(self) -> None:
"""Print a summary of analysis properties and methods."""
@@ -554,29 +551,18 @@ def joint_fit_experiments(self) -> object:
def show_constraints(self) -> None:
"""Print a table of all user-defined symbolic constraints."""
- constraints_dict = dict(self.constraints)
-
if not self.constraints._items:
log.warning('No constraints defined.')
return
rows = []
- for constraint in constraints_dict.values():
- row = {
- 'lhs_alias': constraint.lhs_alias.value,
- 'rhs_expr': constraint.rhs_expr.value,
- 'full expression': f'{constraint.lhs_alias.value} = {constraint.rhs_expr.value}',
- }
- rows.append(row)
-
- headers = ['lhs_alias', 'rhs_expr', 'full expression']
- alignments = ['left', 'left', 'left']
- rows = [[row[header] for header in headers] for row in rows]
+ for constraint in self.constraints:
+ rows.append([constraint.expression.value])
console.paragraph('User defined constraints')
render_table(
- columns_headers=headers,
- columns_alignment=alignments,
+ columns_headers=['expression'],
+ columns_alignment=['left'],
columns_data=rows,
)
@@ -638,6 +624,10 @@ def fit(self) -> None:
weights=self._joint_fit_experiments,
analysis=self,
)
+
+ # After fitting, get the results
+ self.fit_results = self.fitter.results
+
elif mode is FitModeEnum.SINGLE:
# TODO: Find a better way without creating dummy
# experiments?
@@ -657,11 +647,29 @@ def fit(self) -> None:
dummy_experiments,
analysis=self,
)
+
+ # After fitting, snapshot parameter values before
+ # they get overwritten by the next experiment's fit
+ results = self.fitter.results
+ snapshot: dict[str, dict] = {}
+ for param in results.parameters:
+ snapshot[param.unique_name] = {
+ 'value': param.value,
+ 'uncertainty': param.uncertainty,
+ 'units': param.units,
+ }
+ self._parameter_snapshots[expt_name] = snapshot
+ self.fit_results = results
+
else:
raise NotImplementedError(f'Fit mode {mode.value} not implemented yet.')
- # After fitting, get the results
- self.fit_results = self.fitter.results
+ # After fitting, save the project
+ # TODO: Consider saving individual data during sequential
+ # (single) fitting, instead of waiting until the end and save
+ # only the last one
+ if self.project.info.path is not None:
+ self.project.save()
def show_fit_results(self) -> None:
"""
@@ -678,7 +686,7 @@ def show_fit_results(self) -> None:
project.analysis.fit() project.analysis.show_fit_results()
"""
- if not hasattr(self, 'fit_results') or self.fit_results is None:
+ if self.fit_results is None:
log.warning('No fit results available. Run fit() first.')
return
diff --git a/src/easydiffraction/analysis/categories/aliases/default.py b/src/easydiffraction/analysis/categories/aliases/default.py
index 63cc5f28..7b1e0df0 100644
--- a/src/easydiffraction/analysis/categories/aliases/default.py
+++ b/src/easydiffraction/analysis/categories/aliases/default.py
@@ -34,7 +34,7 @@ def __init__(self) -> None:
name='label',
description='...', # TODO
value_spec=AttributeSpec(
- default='_',
+ default='_', # TODO, Maybe None?
validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_]*$'),
),
cif_handler=CifHandler(names=['_alias.label']),
diff --git a/src/easydiffraction/analysis/categories/constraints/default.py b/src/easydiffraction/analysis/categories/constraints/default.py
index 71d61d95..3bb1b77e 100644
--- a/src/easydiffraction/analysis/categories/constraints/default.py
+++ b/src/easydiffraction/analysis/categories/constraints/default.py
@@ -3,8 +3,9 @@
"""
Simple symbolic constraint between parameters.
-Represents an equation of the form ``lhs_alias = rhs_expr`` where
-``rhs_expr`` is evaluated elsewhere by the analysis engine.
+Represents an equation of the form ``lhs_alias = rhs_expr`` stored as a
+single expression string. The left- and right-hand sides are derived by
+splitting the expression at the ``=`` sign.
"""
from __future__ import annotations
@@ -21,66 +22,70 @@
class Constraint(CategoryItem):
- """Single constraint item."""
+ """Single constraint item stored as ``lhs = rhs`` expression."""
def __init__(self) -> None:
super().__init__()
- self._lhs_alias = StringDescriptor(
- name='lhs_alias',
- description='Left-hand side of the equation.', # TODO
+ self._expression = StringDescriptor(
+ name='expression',
+ description='Constraint equation, e.g. "occ_Ba = 1 - occ_La".',
value_spec=AttributeSpec(
- default='...', # TODO
+ default='_', # TODO, Maybe None?
validator=RegexValidator(pattern=r'.*'),
),
- cif_handler=CifHandler(names=['_constraint.lhs_alias']),
- )
- self._rhs_expr = StringDescriptor(
- name='rhs_expr',
- description='Right-hand side expression.', # TODO
- value_spec=AttributeSpec(
- default='...', # TODO
- validator=RegexValidator(pattern=r'.*'),
- ),
- cif_handler=CifHandler(names=['_constraint.rhs_expr']),
+ cif_handler=CifHandler(names=['_constraint.expression']),
)
self._identity.category_code = 'constraint'
- self._identity.category_entry_name = lambda: str(self.lhs_alias.value)
+ self._identity.category_entry_name = lambda: self.lhs_alias
# ------------------------------------------------------------------
# Public properties
# ------------------------------------------------------------------
@property
- def lhs_alias(self) -> StringDescriptor:
+ def expression(self) -> StringDescriptor:
"""
- Left-hand side of the equation.
+ Full constraint equation (e.g. ``'occ_Ba = 1 - occ_La'``).
Reading this property returns the underlying
- ``StringDescriptor`` object. Assigning to it updates the
- parameter value.
+ ``StringDescriptor`` object. Assigning to it updates the value.
"""
- return self._lhs_alias
+ return self._expression
- @lhs_alias.setter
- def lhs_alias(self, value: str) -> None:
- self._lhs_alias.value = value
+ @expression.setter
+ def expression(self, value: str) -> None:
+ self._expression.value = value
@property
- def rhs_expr(self) -> StringDescriptor:
- """
- Right-hand side expression.
+ def lhs_alias(self) -> str:
+ """Left-hand side alias derived from the expression."""
+ return self._split_expression()[0]
- Reading this property returns the underlying
- ``StringDescriptor`` object. Assigning to it updates the
- parameter value.
+ @property
+ def rhs_expr(self) -> str:
+ """Right-hand side expression derived from the expression."""
+ return self._split_expression()[1]
+
+ # ------------------------------------------------------------------
+ # Internal helpers
+ # ------------------------------------------------------------------
+
+ def _split_expression(self) -> tuple[str, str]:
"""
- return self._rhs_expr
+ Split the expression at the first ``=`` sign.
- @rhs_expr.setter
- def rhs_expr(self, value: str) -> None:
- self._rhs_expr.value = value
+ Returns
+ -------
+ tuple[str, str]
+ ``(lhs_alias, rhs_expr)`` with whitespace stripped.
+ """
+ raw = self._expression.value or ''
+ if '=' not in raw:
+ return (raw.strip(), '')
+ lhs, rhs = raw.split('=', 1)
+ return (lhs.strip(), rhs.strip())
@ConstraintsFactory.register
@@ -98,6 +103,20 @@ def __init__(self) -> None:
"""Create an empty constraints collection."""
super().__init__(item_type=Constraint)
+ def create(self, *, expression: str) -> None:
+ """
+ Create a constraint from an expression string.
+
+ Parameters
+ ----------
+ expression : str
+ Constraint equation, e.g. ``'biso_Co2 = biso_Co1'`` or
+ ``'occ_Ba = 1 - occ_La'``.
+ """
+ item = Constraint()
+ item.expression = expression
+ self.add(item)
+
def _update(self, called_by_minimizer: bool = False) -> None:
del called_by_minimizer
diff --git a/src/easydiffraction/core/collection.py b/src/easydiffraction/core/collection.py
index 0560a37e..8f590b07 100644
--- a/src/easydiffraction/core/collection.py
+++ b/src/easydiffraction/core/collection.py
@@ -33,18 +33,34 @@ def __init__(self, item_type: type) -> None:
self._index: dict = {}
self._item_type = item_type
- def __getitem__(self, name: str) -> GuardedBase:
+ def __getitem__(self, key: str | int) -> GuardedBase:
"""
- Return an item by its identity key.
+ Return an item by name or positional index.
- Rebuilds the internal index on a cache miss to stay consistent
- with recent mutations.
+ Parameters
+ ----------
+ key : str | int
+ Identity key (str) or zero-based positional index (int).
+
+ Returns
+ -------
+ GuardedBase
+ The item matching the given key or index.
+
+ Raises
+ ------
+ TypeError
+ If *key* is neither ``str`` nor ``int``.
"""
- try:
- return self._index[name]
- except KeyError:
- self._rebuild_index()
- return self._index[name]
+ if isinstance(key, int):
+ return self._items[key]
+ if isinstance(key, str):
+ try:
+ return self._index[key]
+ except KeyError:
+ self._rebuild_index()
+ return self._index[key]
+ raise TypeError(f'Collection indices must be str or int, not {type(key).__name__}')
def __setitem__(self, name: str, item: GuardedBase) -> None:
"""Insert or replace an item under the given identity key."""
diff --git a/src/easydiffraction/core/singleton.py b/src/easydiffraction/core/singleton.py
index 9033822a..d40e62bc 100644
--- a/src/easydiffraction/core/singleton.py
+++ b/src/easydiffraction/core/singleton.py
@@ -129,8 +129,8 @@ def _parse_constraints(self) -> None:
self._parsed_constraints = []
for expr_obj in self._constraints:
- lhs_alias = expr_obj.lhs_alias.value
- rhs_expr = expr_obj.rhs_expr.value
+ lhs_alias = expr_obj.lhs_alias
+ rhs_expr = expr_obj.rhs_expr
if lhs_alias and rhs_expr:
constraint = (lhs_alias.strip(), rhs_expr.strip())
diff --git a/src/easydiffraction/datablocks/experiment/categories/diffrn/__init__.py b/src/easydiffraction/datablocks/experiment/categories/diffrn/__init__.py
new file mode 100644
index 00000000..6c11ee7e
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/diffrn/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.datablocks.experiment.categories.diffrn.default import DefaultDiffrn
diff --git a/src/easydiffraction/datablocks/experiment/categories/diffrn/default.py b/src/easydiffraction/datablocks/experiment/categories/diffrn/default.py
new file mode 100644
index 00000000..9635fc2e
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/diffrn/default.py
@@ -0,0 +1,136 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Default diffraction ambient-conditions category."""
+
+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 NumericDescriptor
+from easydiffraction.datablocks.experiment.categories.diffrn.factory import DiffrnFactory
+from easydiffraction.io.cif.handler import CifHandler
+
+
+@DiffrnFactory.register
+class DefaultDiffrn(CategoryItem):
+ """Ambient conditions recorded during diffraction measurement."""
+
+ type_info = TypeInfo(
+ tag='default',
+ description='Diffraction ambient conditions',
+ )
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ self._ambient_temperature = NumericDescriptor(
+ name='ambient_temperature',
+ description='Mean temperature during measurement',
+ units='K',
+ value_spec=AttributeSpec(
+ default=None,
+ allow_none=True,
+ validator=RangeValidator(),
+ ),
+ cif_handler=CifHandler(names=['_diffrn.ambient_temperature']),
+ )
+
+ self._ambient_pressure = NumericDescriptor(
+ name='ambient_pressure',
+ description='Mean hydrostatic pressure during measurement',
+ units='kPa',
+ value_spec=AttributeSpec(
+ default=None,
+ allow_none=True,
+ validator=RangeValidator(),
+ ),
+ cif_handler=CifHandler(names=['_diffrn.ambient_pressure']),
+ )
+
+ self._ambient_magnetic_field = NumericDescriptor(
+ name='ambient_magnetic_field',
+ description='Mean magnetic field during measurement',
+ units='T',
+ value_spec=AttributeSpec(
+ default=None,
+ allow_none=True,
+ validator=RangeValidator(),
+ ),
+ cif_handler=CifHandler(names=['_diffrn.ambient_magnetic_field']),
+ )
+
+ self._ambient_electric_field = NumericDescriptor(
+ name='ambient_electric_field',
+ description='Mean electric field during measurement',
+ units='V/m',
+ value_spec=AttributeSpec(
+ default=None,
+ allow_none=True,
+ validator=RangeValidator(),
+ ),
+ cif_handler=CifHandler(names=['_diffrn.ambient_electric_field']),
+ )
+
+ self._identity.category_code = 'diffrn'
+
+ # ------------------------------------------------------------------
+ # Public properties
+ # ------------------------------------------------------------------
+
+ @property
+ def ambient_temperature(self) -> NumericDescriptor:
+ """
+ Mean temperature during measurement (K).
+
+ Reading this property returns the underlying
+ ``NumericDescriptor`` object. Assigning to it updates the value.
+ """
+ return self._ambient_temperature
+
+ @ambient_temperature.setter
+ def ambient_temperature(self, value: float) -> None:
+ self._ambient_temperature.value = value
+
+ @property
+ def ambient_pressure(self) -> NumericDescriptor:
+ """
+ Mean hydrostatic pressure during measurement (kPa).
+
+ Reading this property returns the underlying
+ ``NumericDescriptor`` object. Assigning to it updates the value.
+ """
+ return self._ambient_pressure
+
+ @ambient_pressure.setter
+ def ambient_pressure(self, value: float) -> None:
+ self._ambient_pressure.value = value
+
+ @property
+ def ambient_magnetic_field(self) -> NumericDescriptor:
+ """
+ Mean magnetic field during measurement (T).
+
+ Reading this property returns the underlying
+ ``NumericDescriptor`` object. Assigning to it updates the value.
+ """
+ return self._ambient_magnetic_field
+
+ @ambient_magnetic_field.setter
+ def ambient_magnetic_field(self, value: float) -> None:
+ self._ambient_magnetic_field.value = value
+
+ @property
+ def ambient_electric_field(self) -> NumericDescriptor:
+ """
+ Mean electric field during measurement (V/m).
+
+ Reading this property returns the underlying
+ ``NumericDescriptor`` object. Assigning to it updates the value.
+ """
+ return self._ambient_electric_field
+
+ @ambient_electric_field.setter
+ def ambient_electric_field(self, value: float) -> None:
+ self._ambient_electric_field.value = value
diff --git a/src/easydiffraction/datablocks/experiment/categories/diffrn/factory.py b/src/easydiffraction/datablocks/experiment/categories/diffrn/factory.py
new file mode 100644
index 00000000..ef5fb719
--- /dev/null
+++ b/src/easydiffraction/datablocks/experiment/categories/diffrn/factory.py
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Factory for diffraction ambient-conditions categories."""
+
+from __future__ import annotations
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class DiffrnFactory(FactoryBase):
+ """Create diffraction ambient-conditions category instances."""
+
+ _default_rules = {
+ frozenset(): 'default',
+ }
diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py
index 2dddee44..bc42728f 100644
--- a/src/easydiffraction/datablocks/experiment/item/base.py
+++ b/src/easydiffraction/datablocks/experiment/item/base.py
@@ -11,6 +11,7 @@
from easydiffraction.core.datablock import DatablockItem
from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory
+from easydiffraction.datablocks.experiment.categories.diffrn.factory import DiffrnFactory
from easydiffraction.datablocks.experiment.categories.excluded_regions.factory import (
ExcludedRegionsFactory,
)
@@ -49,6 +50,9 @@ def __init__(
self._calculator_type: str | None = None
self._identity.datablock_entry_name = lambda: self.name
+ self._diffrn_type: str = DiffrnFactory.default_tag()
+ self._diffrn = DiffrnFactory.create(self._diffrn_type)
+
@property
def name(self) -> str:
"""Human-readable name of the experiment."""
@@ -71,6 +75,53 @@ def type(self) -> object: # TODO: Consider another name
"""Experiment type: sample form, probe, beam mode."""
return self._type
+ # ------------------------------------------------------------------
+ # Diffrn conditions (switchable-category pattern)
+ # ------------------------------------------------------------------
+
+ @property
+ def diffrn(self) -> object:
+ """Ambient conditions recorded during measurement."""
+ return self._diffrn
+
+ @property
+ def diffrn_type(self) -> str:
+ """Tag of the active diffraction conditions type."""
+ return self._diffrn_type
+
+ @diffrn_type.setter
+ def diffrn_type(self, new_type: str) -> None:
+ """
+ Switch to a different diffraction conditions type.
+
+ Parameters
+ ----------
+ new_type : str
+ Diffrn conditions tag (e.g. ``'default'``).
+ """
+ supported_tags = DiffrnFactory.supported_tags()
+ if new_type not in supported_tags:
+ log.warning(
+ f"Unsupported diffrn type '{new_type}'. "
+ f'Supported: {supported_tags}. '
+ f"For more information, use 'show_supported_diffrn_types()'",
+ )
+ return
+
+ self._diffrn = DiffrnFactory.create(new_type)
+ self._diffrn_type = new_type
+ console.paragraph(f"Diffrn type for experiment '{self.name}' changed to")
+ console.print(new_type)
+
+ def show_supported_diffrn_types(self) -> None:
+ """Print a table of supported diffraction conditions types."""
+ DiffrnFactory.show_supported()
+
+ def show_current_diffrn_type(self) -> None:
+ """Print the currently used diffraction conditions type."""
+ console.paragraph('Current diffrn type')
+ console.print(self.diffrn_type)
+
@property
def as_cif(self) -> str:
"""Serialize this experiment to a CIF fragment."""
diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py
index d5b469cf..01c7aebe 100644
--- a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py
+++ b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py
@@ -16,6 +16,7 @@
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.io.ascii import load_numeric_block
from easydiffraction.utils.logging import console
from easydiffraction.utils.logging import log
@@ -54,7 +55,10 @@ def __init__(
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:
+ def _load_ascii_data_to_experiment(
+ self,
+ data_path: str,
+ ) -> None:
"""
Load (x, y, sy) data from an ASCII file into the data category.
@@ -65,16 +69,17 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None:
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
+ data = load_numeric_block(data_path)
if data.shape[1] < 2:
- raise ValueError('Data file must have at least two columns: x and y.')
+ log.error(
+ 'Data file must have at least two columns: x and y.',
+ exc_type=ValueError,
+ )
+ return
if data.shape[1] < 3:
- print('Warning: No uncertainty (sy) column provided. Defaulting to sqrt(y).')
+ log.warning('No uncertainty (sy) column provided. Defaulting to sqrt(y).')
# Extract x, y data
x: np.ndarray = data[:, 0]
@@ -95,8 +100,14 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None:
self.data._set_intensity_meas(y)
self.data._set_intensity_meas_su(sy)
+ temperature = ''
+ if self.diffrn.ambient_temperature.value is not None:
+ temperature = f' Temperature: {self.diffrn.ambient_temperature.value:.3f} K.'
+
console.paragraph('Data loaded successfully')
- console.print(f"Experiment 🔬 '{self.name}'. Number of data points: {len(x)}")
+ console.print(
+ f"Experiment 🔬 '{self.name}'. Number of data points: {len(x)}.{temperature}"
+ )
# ------------------------------------------------------------------
# Instrument (switchable-category pattern)
diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_sc.py b/src/easydiffraction/datablocks/experiment/item/bragg_sc.py
index 3cb3f8a1..4cc372d4 100644
--- a/src/easydiffraction/datablocks/experiment/item/bragg_sc.py
+++ b/src/easydiffraction/datablocks/experiment/item/bragg_sc.py
@@ -14,6 +14,7 @@
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.io.ascii import load_numeric_block
from easydiffraction.utils.logging import console
from easydiffraction.utils.logging import log
@@ -50,14 +51,7 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None:
The file format is space/column separated with 5 columns: ``h k
l Iobs sIobs``.
"""
- try:
- data = np.loadtxt(data_path)
- except Exception as e:
- log.error(
- f'Failed to read data from {data_path}: {e}',
- exc_type=IOError,
- )
- return
+ data = load_numeric_block(data_path)
if data.shape[1] < 5:
log.error(
@@ -114,8 +108,8 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None:
l Iobs sIobs wavelength``.
"""
try:
- data = np.loadtxt(data_path)
- except Exception as e:
+ data = load_numeric_block(data_path)
+ except IOError as e:
log.error(
f'Failed to read data from {data_path}: {e}',
exc_type=IOError,
diff --git a/src/easydiffraction/datablocks/experiment/item/factory.py b/src/easydiffraction/datablocks/experiment/item/factory.py
index 6406ed30..c23f60f5 100644
--- a/src/easydiffraction/datablocks/experiment/item/factory.py
+++ b/src/easydiffraction/datablocks/experiment/item/factory.py
@@ -260,5 +260,6 @@ def from_data_path(
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/display/plotters/ascii.py b/src/easydiffraction/display/plotters/ascii.py
index 8735a8a0..32ee45ed 100644
--- a/src/easydiffraction/display/plotters/ascii.py
+++ b/src/easydiffraction/display/plotters/ascii.py
@@ -181,3 +181,26 @@ def plot_single_crystal(
print(f' {line}')
print(f' {x_axis}')
console.print(f'{" " * (width - 3)}{axes_labels[0]}')
+
+ def plot_scatter(
+ self,
+ x: object,
+ y: object,
+ sy: object,
+ axes_labels: object,
+ title: str,
+ height: int | None = None,
+ ) -> None:
+ """Render a scatter plot with error bars in ASCII."""
+ _ = x, sy # ASCII backend does not use x ticks or error bars
+
+ if height is None:
+ height = DEFAULT_HEIGHT
+
+ config = {'height': height, 'colors': [asciichartpy.blue]}
+ chart = asciichartpy.plot([list(y)], config)
+
+ console.paragraph(f'{title}')
+ console.print(f'{axes_labels[1]} vs {axes_labels[0]}')
+ padded = '\n'.join(' ' + line for line in chart.splitlines())
+ print(padded)
diff --git a/src/easydiffraction/display/plotters/base.py b/src/easydiffraction/display/plotters/base.py
index d7ca594c..67fea11f 100644
--- a/src/easydiffraction/display/plotters/base.py
+++ b/src/easydiffraction/display/plotters/base.py
@@ -229,3 +229,33 @@ def plot_single_crystal(
Backend-specific height (text rows or pixels).
"""
pass
+
+ @abstractmethod
+ def plot_scatter(
+ self,
+ x: object,
+ y: object,
+ sy: object,
+ axes_labels: object,
+ title: str,
+ height: int | None,
+ ) -> None:
+ """
+ Render a scatter plot with error bars.
+
+ Parameters
+ ----------
+ x : object
+ 1-D array of x-axis values.
+ y : object
+ 1-D array of y-axis values.
+ sy : object
+ 1-D array of y uncertainties.
+ axes_labels : object
+ Pair of strings for x and y axis titles.
+ title : str
+ Figure title.
+ height : int | None
+ Backend-specific height (text rows or pixels).
+ """
+ pass
diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py
index 4df79cf0..9d559d02 100644
--- a/src/easydiffraction/display/plotters/plotly.py
+++ b/src/easydiffraction/display/plotters/plotly.py
@@ -369,3 +369,45 @@ def plot_single_crystal(
fig = self._get_figure(data, layout)
self._show_figure(fig)
+
+ def plot_scatter(
+ self,
+ x: object,
+ y: object,
+ sy: object,
+ axes_labels: object,
+ title: str,
+ height: int | None = None,
+ ) -> None:
+ """Render a scatter plot with error bars via Plotly."""
+ _ = height # not used by Plotly backend
+
+ trace = go.Scatter(
+ x=x,
+ y=y,
+ mode='markers+lines',
+ marker=dict(
+ symbol='circle',
+ size=10,
+ line=dict(width=0.5),
+ color=DEFAULT_COLORS['meas'],
+ ),
+ line=dict(
+ width=1,
+ color=DEFAULT_COLORS['meas'],
+ ),
+ error_y=dict(
+ type='data',
+ array=sy,
+ visible=True,
+ ),
+ hovertemplate='x: %{x}
y: %{y}
',
+ )
+
+ layout = self._get_layout(
+ title,
+ axes_labels,
+ )
+
+ fig = self._get_figure(trace, layout)
+ self._show_figure(fig)
diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py
index ec8e1d5c..e4b1ad34 100644
--- a/src/easydiffraction/display/plotting.py
+++ b/src/easydiffraction/display/plotting.py
@@ -569,6 +569,107 @@ def plot_meas_vs_calc(
height=self.height,
)
+ def plot_param_series(
+ self,
+ unique_name: str,
+ versus_name: str | None,
+ experiments: object,
+ parameter_snapshots: dict[str, dict[str, dict]],
+ ) -> None:
+ """
+ Plot a parameter's value across sequential fit results.
+
+ Parameters
+ ----------
+ unique_name : str
+ Unique name of the parameter to plot.
+ versus_name : str | None
+ Name of the diffrn descriptor to use as the x-axis (e.g.
+ ``'ambient_temperature'``). When ``None``, the experiment
+ sequence index is used instead.
+ experiments : object
+ Experiments collection for accessing diffrn conditions.
+ parameter_snapshots : dict[str, dict[str, dict]]
+ Per-experiment parameter value snapshots keyed by experiment
+ name, then by parameter unique name.
+ """
+ x = []
+ y = []
+ sy = []
+ axes_labels = []
+ title = ''
+
+ for idx, expt_name in enumerate(parameter_snapshots, start=1):
+ experiment = experiments[expt_name]
+ diffrn = experiment.diffrn
+
+ x_axis_param = self._resolve_diffrn_descriptor(diffrn, versus_name)
+
+ if x_axis_param is not None and x_axis_param.value is not None:
+ value = x_axis_param.value
+ else:
+ value = idx
+ x.append(value)
+
+ param_data = parameter_snapshots[expt_name][unique_name]
+ y.append(param_data['value'])
+ sy.append(param_data['uncertainty'])
+
+ if x_axis_param is not None:
+ axes_labels = [
+ x_axis_param.description or x_axis_param.name,
+ f'Parameter value ({param_data["units"]})',
+ ]
+ else:
+ axes_labels = [
+ 'Experiment No.',
+ f'Parameter value ({param_data["units"]})',
+ ]
+
+ title = f"Parameter '{unique_name}' across fit results"
+
+ self._backend.plot_scatter(
+ x=x,
+ y=y,
+ sy=sy,
+ axes_labels=axes_labels,
+ title=title,
+ height=self.height,
+ )
+
+ @staticmethod
+ def _resolve_diffrn_descriptor(
+ diffrn: object,
+ name: str | None,
+ ) -> object | None:
+ """
+ Return the diffrn descriptor matching *name*, or ``None``.
+
+ Parameters
+ ----------
+ diffrn : object
+ The diffrn category of an experiment.
+ name : str | None
+ Descriptor name (e.g. ``'ambient_temperature'``).
+
+ Returns
+ -------
+ object | None
+ The matching ``NumericDescriptor``, or ``None`` when *name*
+ is ``None`` or unrecognised.
+ """
+ if name is None:
+ return None
+ if name == 'ambient_temperature':
+ return diffrn.ambient_temperature
+ if name == 'ambient_pressure':
+ return diffrn.ambient_pressure
+ if name == 'ambient_magnetic_field':
+ return diffrn.ambient_magnetic_field
+ if name == 'ambient_electric_field':
+ return diffrn.ambient_electric_field
+ return None
+
class PlotterFactory(RendererFactoryBase):
"""Factory for plotter implementations."""
diff --git a/src/easydiffraction/io/__init__.py b/src/easydiffraction/io/__init__.py
index 4e798e20..6ce45a95 100644
--- a/src/easydiffraction/io/__init__.py
+++ b/src/easydiffraction/io/__init__.py
@@ -1,2 +1,7 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
+
+from easydiffraction.io.ascii import extract_data_paths_from_dir
+from easydiffraction.io.ascii import extract_data_paths_from_zip
+from easydiffraction.io.ascii import extract_metadata
+from easydiffraction.io.ascii import load_numeric_block
diff --git a/src/easydiffraction/io/ascii.py b/src/easydiffraction/io/ascii.py
new file mode 100644
index 00000000..6987d217
--- /dev/null
+++ b/src/easydiffraction/io/ascii.py
@@ -0,0 +1,182 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Helpers for loading numeric data from ASCII files."""
+
+from __future__ import annotations
+
+import tempfile
+import zipfile
+from io import StringIO
+from pathlib import Path
+
+import numpy as np
+
+
+def extract_data_paths_from_zip(zip_path: str | Path) -> list[str]:
+ """
+ Extract all files from a ZIP archive and return their paths.
+
+ Files are extracted into a temporary directory that persists for the
+ lifetime of the process. The returned paths are sorted
+ lexicographically by file name so that numbered data files (e.g.
+ ``scan_001.dat``, ``scan_002.dat``) appear in natural order. Hidden
+ files and directories (names starting with ``'.'`` or ``'__'``) are
+ excluded.
+
+ Parameters
+ ----------
+ zip_path : str | Path
+ Path to the ZIP archive.
+
+ Returns
+ -------
+ list[str]
+ Sorted absolute paths to the extracted data files.
+
+ Raises
+ ------
+ FileNotFoundError
+ If *zip_path* does not exist.
+ ValueError
+ If the archive contains no usable data files.
+ """
+ zip_path = Path(zip_path)
+ if not zip_path.exists():
+ raise FileNotFoundError(f'ZIP file not found: {zip_path}')
+
+ # TODO: Unify mkdir with other uses in the code
+ extract_dir = Path(tempfile.mkdtemp(prefix='ed_zip_'))
+
+ with zipfile.ZipFile(zip_path, 'r') as zf:
+ zf.extractall(extract_dir)
+
+ paths = sorted(
+ str(p)
+ for p in extract_dir.rglob('*')
+ if p.is_file() and not p.name.startswith('.') and not p.name.startswith('__')
+ )
+
+ if not paths:
+ raise ValueError(f'No data files found in ZIP archive: {zip_path}')
+
+ return paths
+
+
+def extract_data_paths_from_dir(
+ dir_path: str | Path,
+ file_pattern: str = '*',
+) -> list[str]:
+ """
+ List data files in a directory and return their sorted paths.
+
+ Hidden files (names starting with ``'.'`` or ``'__'``) are excluded.
+ The returned paths are sorted lexicographically by file name.
+
+ Parameters
+ ----------
+ dir_path : str | Path
+ Path to the directory containing data files.
+ file_pattern : str, default='*'
+ Glob pattern to filter files (e.g. ``'*.dat'``, ``'*.xye'``).
+
+ Returns
+ -------
+ list[str]
+ Sorted absolute paths to the matching data files.
+
+ Raises
+ ------
+ FileNotFoundError
+ If *dir_path* does not exist or is not a directory.
+ ValueError
+ If no matching data files are found.
+ """
+ dir_path = Path(dir_path)
+ if not dir_path.is_dir():
+ raise FileNotFoundError(f'Directory not found: {dir_path}')
+
+ paths = sorted(
+ str(p)
+ for p in dir_path.glob(file_pattern)
+ if p.is_file() and not p.name.startswith('.') and not p.name.startswith('__')
+ )
+
+ if not paths:
+ raise ValueError(f"No files matching '{file_pattern}' found in directory: {dir_path}")
+
+ return paths
+
+
+def extract_metadata(
+ file_path: str | Path,
+ pattern: str,
+) -> float | None:
+ """
+ Extract a single numeric value from a file using a regex pattern.
+
+ The entire file content is searched (not just the header). The
+ **first** match is used. The regex must contain exactly one capture
+ group whose match is convertible to ``float``.
+
+ Parameters
+ ----------
+ file_path : str | Path
+ Path to the input file.
+ pattern : str
+ Regex with one capture group that matches the numeric value.
+
+ Returns
+ -------
+ float | None
+ The extracted value, or ``None`` if the pattern did not match or
+ the captured text could not be converted to float.
+ """
+ import re
+
+ content = Path(file_path).read_text(encoding='utf-8', errors='ignore')
+ match = re.search(pattern, content, re.MULTILINE)
+ if match is None:
+ return None
+ try:
+ return float(match.group(1))
+ except (ValueError, IndexError):
+ return None
+
+
+def load_numeric_block(data_path: str | Path) -> np.ndarray:
+ """
+ Load a numeric block from an ASCII file, skipping header lines.
+
+ Read the file and try ``numpy.loadtxt`` starting from the first
+ line, then the second, etc., until the load succeeds. This allows
+ files with an arbitrary number of non-numeric header lines to be
+ parsed without prior knowledge of the format.
+
+ Parameters
+ ----------
+ data_path : str | Path
+ Path to the ASCII data file.
+
+ Returns
+ -------
+ np.ndarray
+ 2-D array of the parsed numeric data.
+
+ Raises
+ ------
+ IOError
+ If no contiguous numeric block can be found in the file.
+ """
+ data_path = Path(data_path)
+ lines = data_path.read_text().splitlines()
+
+ last_error: Exception | None = None
+ for start in range(len(lines)):
+ try:
+ return np.loadtxt(StringIO('\n'.join(lines[start:])))
+ except Exception as e: # noqa: BLE001
+ last_error = e
+
+ raise IOError(
+ f'Failed to read numeric data from {data_path}: {last_error}',
+ ) from last_error
diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py
index af5a5922..ace0fc95 100644
--- a/src/easydiffraction/project/project.py
+++ b/src/easydiffraction/project/project.py
@@ -156,7 +156,7 @@ def load(self, dir_path: str) -> None:
def save(self) -> None:
"""Save the project into the existing project directory."""
- if not self._info.path:
+ if self._info.path is None:
log.error('Project path not specified. Use save_as() to define the path first.')
return
@@ -174,12 +174,10 @@ def save(self) -> None:
# Save structures
sm_dir = self._info.path / 'structures'
sm_dir.mkdir(parents=True, exist_ok=True)
- # Iterate over structure objects (MutableMapping iter gives
- # keys)
+ console.print('├── 📁 structures/')
for structure in self.structures.values():
file_name: str = f'{structure.name}.cif'
file_path = sm_dir / file_name
- console.print('├── 📁 structures')
with file_path.open('w') as f:
f.write(structure.as_cif)
console.print(f'│ └── 📄 {file_name}')
@@ -187,10 +185,10 @@ def save(self) -> None:
# Save experiments
expt_dir = self._info.path / 'experiments'
expt_dir.mkdir(parents=True, exist_ok=True)
+ console.print('├── 📁 experiments/')
for experiment in self.experiments.values():
file_name: str = f'{experiment.name}.cif'
file_path = expt_dir / file_name
- console.print('├── 📁 experiments')
with file_path.open('w') as f:
f.write(experiment.as_cif)
console.print(f'│ └── 📄 {file_name}')
@@ -333,3 +331,27 @@ def plot_meas_vs_calc(
show_residual=show_residual,
x=x,
)
+
+ def plot_param_series(self, param: object, versus: object | None = None) -> None:
+ """
+ Plot a parameter's value across sequential fit results.
+
+ Parameters
+ ----------
+ param : object
+ Parameter descriptor whose ``unique_name`` identifies the
+ values to plot.
+ versus : object | None, default=None
+ A diffrn descriptor (e.g.
+ ``expt.diffrn.ambient_temperature``) whose value is used as
+ the x-axis for each experiment. When ``None``, the
+ experiment sequence number is used instead.
+ """
+ unique_name = param.unique_name
+ versus_name = versus.name if versus is not None else None
+ self.plotter.plot_param_series(
+ unique_name,
+ versus_name,
+ self.experiments,
+ self.analysis._parameter_snapshots,
+ )
diff --git a/src/easydiffraction/project/project_info.py b/src/easydiffraction/project/project_info.py
index d062d522..dcba2fba 100644
--- a/src/easydiffraction/project/project_info.py
+++ b/src/easydiffraction/project/project_info.py
@@ -25,7 +25,7 @@ def __init__(
self._name = name
self._title = title
self._description = description
- self._path: pathlib.Path = pathlib.Path.cwd()
+ self._path: pathlib.Path | None = None # pathlib.Path.cwd()
self._created: datetime.datetime = datetime.datetime.now()
self._last_modified: datetime.datetime = datetime.datetime.now()
@@ -86,7 +86,7 @@ def description(self, value: str) -> None:
self._description = ' '.join(value.split())
@property
- def path(self) -> pathlib.Path:
+ def path(self) -> pathlib.Path | None:
"""Return the project path as a Path object."""
return self._path
diff --git a/src/easydiffraction/utils/utils.py b/src/easydiffraction/utils/utils.py
index 3e8c5f1e..5527629f 100644
--- a/src/easydiffraction/utils/utils.py
+++ b/src/easydiffraction/utils/utils.py
@@ -6,7 +6,6 @@
import functools
import json
import pathlib
-import re
import urllib.request
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version
@@ -75,7 +74,7 @@ def _fetch_data_index() -> dict:
_validate_url(index_url)
# macOS: sha256sum index.json
- index_hash = 'sha256:9aceaf51d298992058c80903283c9a83543329a063692d49b7aaee1156e76884'
+ index_hash = 'sha256:f421aab32ec532782dc62f4440a97320e5cec23b9e64f5ae3f8a3e818d013430'
destination_dirname = 'easydiffraction'
destination_fname = 'data-index.json'
cache_dir = pooch.os_cache(destination_dirname)
@@ -696,39 +695,6 @@ def sin_theta_over_lambda_to_d_spacing(sin_theta_over_lambda: object) -> object:
return d
-def get_value_from_xye_header(file_path: str, key: str) -> float:
- """
- Extract a float from the first line of the file by key.
-
- Parameters
- ----------
- file_path : str
- Path to the input file.
- key : str
- The key to extract ('DIFC' or 'two_theta').
-
- Returns
- -------
- float
- The extracted value.
-
- Raises
- ------
- ValueError
- If the key is not found.
- """
- pattern = rf'{key}\s*=\s*([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)'
-
- with pathlib.Path(file_path).open('r') as f:
- first_line = f.readline()
-
- match = re.search(pattern, first_line)
- if match:
- return float(match.group(1))
- else:
- raise ValueError(f'{key} not found in the header.')
-
-
def str_to_ufloat(s: Optional[str], default: Optional[float] = None) -> UFloat:
"""
Parse a CIF-style numeric string into a ufloat.
diff --git a/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py b/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py
index 5045dd27..7a98c15d 100644
--- a/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py
+++ b/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py
@@ -293,8 +293,8 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None:
)
# Set constraints
- 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')
+ project.analysis.constraints.create(expression='biso_Ba = biso_La')
+ project.analysis.constraints.create(expression='occ_Ba = 1 - occ_La')
# Apply constraints
project.analysis.apply_constraints()
diff --git a/tests/unit/easydiffraction/analysis/categories/test_constraints.py b/tests/unit/easydiffraction/analysis/categories/test_constraints.py
index 443f9b1b..15dddc4f 100644
--- a/tests/unit/easydiffraction/analysis/categories/test_constraints.py
+++ b/tests/unit/easydiffraction/analysis/categories/test_constraints.py
@@ -7,10 +7,10 @@
def test_constraint_creation_and_collection():
c = Constraint()
- c.lhs_alias = 'a'
- c.rhs_expr = 'b + c'
- assert c.lhs_alias.value == 'a'
+ c.expression = 'a = b + c'
+ assert c.lhs_alias == 'a'
+ assert c.rhs_expr == 'b + c'
coll = Constraints()
- coll.create(lhs_alias='a', rhs_expr='b + c')
+ coll.create(expression='a = b + c')
assert 'a' in coll.names
- assert coll['a'].rhs_expr.value == 'b + c'
+ assert coll['a'].rhs_expr == 'b + c'
diff --git a/tests/unit/easydiffraction/core/test_collection.py b/tests/unit/easydiffraction/core/test_collection.py
index 817b76dd..bbbd9c65 100644
--- a/tests/unit/easydiffraction/core/test_collection.py
+++ b/tests/unit/easydiffraction/core/test_collection.py
@@ -84,6 +84,49 @@ def as_cif(self) -> str:
c.remove('nonexistent')
+def test_collection_getitem_by_int_index():
+ """Verify items can be retrieved by positional index."""
+ import pytest
+
+ 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)
+ a = Item('a')
+ b = Item('b')
+ c['a'] = a
+ c['b'] = b
+
+ # Forward indexing
+ assert c[0] is a
+ assert c[1] is b
+
+ # Negative indexing
+ assert c[-1] is b
+ assert c[-2] is a
+
+ # Out of range
+ with pytest.raises(IndexError):
+ c[2]
+
+ # Invalid key type
+ with pytest.raises(TypeError):
+ c[3.14]
+
+
def test_collection_datablock_keyed_items():
"""Verify __setitem__/__delitem__/__contains__ work for datablock-keyed items."""
from easydiffraction.core.collection import CollectionBase
diff --git a/tests/unit/easydiffraction/project/test_project_save.py b/tests/unit/easydiffraction/project/test_project_save.py
index 421e8928..ac8b9895 100644
--- a/tests/unit/easydiffraction/project/test_project_save.py
+++ b/tests/unit/easydiffraction/project/test_project_save.py
@@ -3,14 +3,14 @@
def test_project_save_uses_cwd_when_no_explicit_path(monkeypatch, tmp_path, capsys):
- # Default ProjectInfo.path is cwd; ensure save writes into a temp cwd, not repo root
+ # ProjectInfo.path defaults to None; save() requires save_as() first
from easydiffraction.project.project import Project
monkeypatch.chdir(tmp_path)
p = Project()
- p.save()
+ p.save_as(str(tmp_path))
out = capsys.readouterr().out
- # It should announce saving and create the three core files in cwd
+ # It should announce saving and create the three core files
assert 'Saving project' in out
assert (tmp_path / 'project.cif').exists()
assert (tmp_path / 'analysis.cif').exists()
diff --git a/tests/unit/easydiffraction/test___init__.py b/tests/unit/easydiffraction/test___init__.py
index 75f273e1..e42e801c 100644
--- a/tests/unit/easydiffraction/test___init__.py
+++ b/tests/unit/easydiffraction/test___init__.py
@@ -17,7 +17,7 @@ def test_lazy_attributes_resolve_and_are_accessible():
# Access utility functions from utils via lazy getattr
assert callable(ed.show_version)
- assert callable(ed.get_value_from_xye_header)
+ assert callable(ed.extract_metadata)
# Import once to exercise __getattr__; subsequent access should be cached by Python
_ = ed.Project
diff --git a/tests/unit/easydiffraction/utils/test_utils.py b/tests/unit/easydiffraction/utils/test_utils.py
index 48d5a182..ab2c9bab 100644
--- a/tests/unit/easydiffraction/utils/test_utils.py
+++ b/tests/unit/easydiffraction/utils/test_utils.py
@@ -68,20 +68,18 @@ def test_str_to_ufloat_no_esd_defaults_nan():
assert np.isclose(expected_value, actual_value) and np.isnan(u.std_dev)
-def test_get_value_from_xye_header(tmp_path):
- import easydiffraction.utils.utils as MUT
+def test_extract_metadata(tmp_path):
+ import easydiffraction.io.ascii as ascii_io
- text = 'DIFC = 123.45 two_theta = 67.89\nrest of file\n'
+ text = '# DIFC = 123.45 two_theta = 67.89\nrest of file\n'
p = tmp_path / 'file.xye'
p.write_text(text)
- expected_difc = 123.45
- expected_two_theta = 67.89
- actual = np.array([
- MUT.get_value_from_xye_header(p, 'DIFC'),
- MUT.get_value_from_xye_header(p, 'two_theta'),
- ])
- expected = np.array([expected_difc, expected_two_theta])
- assert np.allclose(expected, actual)
+ difc = ascii_io.extract_metadata(str(p), r'DIFC\s*=\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)')
+ two_theta = ascii_io.extract_metadata(
+ str(p), r'two_theta\s*=\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'
+ )
+ assert np.isclose(difc, 123.45)
+ assert np.isclose(two_theta, 67.89)
def test_validate_url_rejects_non_http_https():
diff --git a/tools/test_scripts.py b/tools/test_scripts.py
index e24b28e2..2dcca083 100644
--- a/tools/test_scripts.py
+++ b/tools/test_scripts.py
@@ -51,6 +51,7 @@ def test_script_runs(script_path: Path):
env=env,
capture_output=True,
text=True,
+ encoding='utf-8',
)
if result.returncode != 0:
details = (result.stdout or '') + (result.stderr or '')