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 '')