Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a52831f
Update save_as paths to include 'data/' directory and adjust test fun…
AndrewSazonov Apr 5, 2026
5fe46b2
Add crysfml
AndrewSazonov Apr 6, 2026
85c937d
Update notebooks
AndrewSazonov Apr 6, 2026
a91eee4
Clean up
AndrewSazonov Apr 6, 2026
5f01122
Add simple integration test for crysfml
AndrewSazonov Apr 7, 2026
ab5a3fe
Bump pycrysfml to 0.2.1
AndrewSazonov Apr 7, 2026
604b7e1
Add pytest marker to neutron_pd_cwl_lbco_crysfml test
AndrewSazonov Apr 7, 2026
325d8a9
Extend package test script by adding pycrysfml
AndrewSazonov Apr 7, 2026
887ff0b
Fix scipp integration test
AndrewSazonov Apr 7, 2026
03aec86
Move jupyterlab and pixi-kernel out from pyproject.toml
AndrewSazonov Apr 7, 2026
0e0aa7a
Remove unnecessary libcxx addition for osx-64 in test.yml
AndrewSazonov Apr 7, 2026
4a53870
Add custom PyPI index support for pycrysfml in test.yml
AndrewSazonov Apr 7, 2026
efdad6e
Update .gitignore to include data directory for tutorial runtime
AndrewSazonov Apr 7, 2026
6582406
Apply latest templates
AndrewSazonov Apr 7, 2026
ef0f286
Add temporary rule ignores for docstring and datetime checks in pypro…
AndrewSazonov Apr 7, 2026
b76fa95
Apply new templates
AndrewSazonov Apr 7, 2026
e1ac6c2
Update index hash for data integrity in utils.py
AndrewSazonov Apr 7, 2026
0cc18d7
Add reverse flag to fit_sequential for reverse-order processing
AndrewSazonov Apr 7, 2026
adc13e4
Update tutorials index
AndrewSazonov Apr 7, 2026
4fdb30e
Update tutorial
AndrewSazonov Apr 7, 2026
346dec7
Add initial structure and analysis configuration files
AndrewSazonov Apr 7, 2026
f476a65
Fix asymmetry and save/restore peak_profile_type in experiment CIF
AndrewSazonov Apr 8, 2026
4ebd9e1
Refactor switchable-type restore into _restore_switchable_types
AndrewSazonov Apr 8, 2026
a57be56
Update test example
AndrewSazonov Apr 8, 2026
91cc7cd
Update data-index.json known hash in utils.py
AndrewSazonov Apr 8, 2026
810c27b
Merge remote-tracking branch 'origin/develop' into asymmetry
AndrewSazonov Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/tutorials/ed-13.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@
# #### Show Free Parameters
#
# We can check which parameters are free to be refined by calling the
# `show_free_params` method of the `analysis` object of the project.
# `free_params` method of the `analysis.display` object of the project.

# %% [markdown] tags=["doc-link"]
# 📖 See
Expand Down
15 changes: 15 additions & 0 deletions docs/docs/tutorials/ed-17.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,12 @@
# %%
project.analysis.fit()

# %% [markdown]
# #### Compare measured and calculated patterns for the first fit.

# %%
project.plotter.plot_meas_vs_calc(expt_name='d20', show_residual=True)

# %% [markdown]
# #### Run Sequential Fitting
#
Expand Down Expand Up @@ -301,11 +307,20 @@ def extract_diffrn(file_path):
data_dir=data_dir,
extract_diffrn=extract_diffrn,
max_workers='auto',
reverse=True,
)

# %% [markdown]
# #### Replay a Dataset
#
# Apply fitted parameters from the first CSV row and plot the result.

# %%
project.apply_params_from_csv(row_index=0)
project.plotter.plot_meas_vs_calc(expt_name='d20', show_residual=True)

# %% [markdown]
#
# Apply fitted parameters from the last CSV row and plot the result.

# %%
Expand Down
13 changes: 12 additions & 1 deletion src/easydiffraction/analysis/calculators/cryspy.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ def _update_experiment_in_cryspy_dict(
cryspy_resolution[3] = experiment.peak.broad_lorentz_x.value
cryspy_resolution[4] = experiment.peak.broad_lorentz_y.value

if 'asymmetry_parameters' in cryspy_expt_dict:
cryspy_asymmetry = cryspy_expt_dict['asymmetry_parameters']
cryspy_asymmetry[0] = experiment.peak.asym_empir_1.value
cryspy_asymmetry[1] = experiment.peak.asym_empir_2.value
cryspy_asymmetry[2] = experiment.peak.asym_empir_3.value
cryspy_asymmetry[3] = experiment.peak.asym_empir_4.value

elif experiment.type.beam_mode.value == BeamModeEnum.TIME_OF_FLIGHT:
cryspy_expt_name = f'tof_{experiment.name}'
cryspy_expt_dict = cryspy_dict[cryspy_expt_name]
Expand Down Expand Up @@ -507,6 +514,10 @@ def _cif_peak_section(
'broad_gauss_w': '_pd_instr_resolution_W',
'broad_lorentz_x': '_pd_instr_resolution_X',
'broad_lorentz_y': '_pd_instr_resolution_Y',
'asym_empir_1': '_pd_instr_reflex_asymmetry_p1',
'asym_empir_2': '_pd_instr_reflex_asymmetry_p2',
'asym_empir_3': '_pd_instr_reflex_asymmetry_p3',
'asym_empir_4': '_pd_instr_reflex_asymmetry_p4',
}
elif expt_type.beam_mode.value == BeamModeEnum.TIME_OF_FLIGHT:
peak_mapping = {
Expand All @@ -522,7 +533,7 @@ def _cif_peak_section(

cif_lines.append('')
for local_attr_name, engine_key_name in peak_mapping.items():
attr_obj = getattr(peak, local_attr_name)
attr_obj = getattr(peak, local_attr_name, None)
if attr_obj is not None:
cif_lines.append(f'{engine_key_name} {attr_obj.value}')

Expand Down
18 changes: 18 additions & 0 deletions src/easydiffraction/core/singleton.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors <https://github.com/easyscience>
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

from typing import Any
from typing import Self

from asteval import Interpreter

from easydiffraction.utils.logging import log

# ======================================================================


Expand Down Expand Up @@ -111,6 +115,20 @@ def apply(self) -> None:
# Evaluate the RHS expression using the current values
rhs_value = ae(rhs_expr)

# asteval silently returns None for undefined names
# instead of raising an exception; errors are stored in
# ae.error.
if ae.error:
error_msgs = '; '.join(str(e.get_error()) for e in ae.error)
ae.error.clear()
log.error(
f"Constraint '{lhs_alias} = {rhs_expr}' could not be "
f'evaluated: {error_msgs}. '
f'Make sure every name in the expression is registered '
f'as an alias via analysis.aliases.create().',
exc_type=ValueError,
)

# Get the actual parameter object we want to update
param = self._alias_to_param[lhs_alias].param

Expand Down
26 changes: 26 additions & 0 deletions src/easydiffraction/datablocks/experiment/categories/peak/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
# SPDX-License-Identifier: BSD-3-Clause
"""Base class for peak profile categories."""

from __future__ import annotations

from easydiffraction.core.category import CategoryItem
from easydiffraction.core.validation import AttributeSpec
from easydiffraction.core.variable import StringDescriptor
from easydiffraction.io.cif.handler import CifHandler


class PeakBase(CategoryItem):
Expand All @@ -11,3 +16,24 @@ class PeakBase(CategoryItem):
def __init__(self) -> None:
super().__init__()
self._identity.category_code = 'peak'

type_info = getattr(type(self), 'type_info', None)
default_tag = type_info.tag if type_info is not None else ''
self._profile_type: StringDescriptor = StringDescriptor(
name='profile_type',
description='Active peak profile type tag',
value_spec=AttributeSpec(default=default_tag),
cif_handler=CifHandler(names=['_peak.profile_type']),
)

@property
def profile_type(self) -> StringDescriptor:
"""
CIF identifier for the active peak profile type.

Returns
-------
StringDescriptor
The descriptor holding the profile type tag string.
"""
return self._profile_type
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class CwlSplitPseudoVoigt(
beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
)
calculator_support = CalculatorSupport(
calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
calculators=frozenset({CalculatorEnum.CRYSPY}),
)

def __init__(self) -> None:
Expand All @@ -81,7 +81,7 @@ class CwlThompsonCoxHastings(
beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
)
calculator_support = CalculatorSupport(
calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
calculators=frozenset({CalculatorEnum.CRYSFML}),
)

def __init__(self) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def __init__(self) -> None:
description='Empirical asymmetry coefficient p1',
units='',
value_spec=AttributeSpec(
default=0.1,
default=0.0,
validator=RangeValidator(),
),
cif_handler=CifHandler(names=['_peak.asym_empir_1']),
Expand All @@ -167,7 +167,7 @@ def __init__(self) -> None:
description='Empirical asymmetry coefficient p2',
units='',
value_spec=AttributeSpec(
default=0.2,
default=0.0,
validator=RangeValidator(),
),
cif_handler=CifHandler(names=['_peak.asym_empir_2']),
Expand All @@ -177,7 +177,7 @@ def __init__(self) -> None:
description='Empirical asymmetry coefficient p3',
units='',
value_spec=AttributeSpec(
default=0.3,
default=0.0,
validator=RangeValidator(),
),
cif_handler=CifHandler(names=['_peak.asym_empir_3']),
Expand All @@ -187,7 +187,7 @@ def __init__(self) -> None:
description='Empirical asymmetry coefficient p4',
units='',
value_spec=AttributeSpec(
default=0.4,
default=0.0,
validator=RangeValidator(),
),
cif_handler=CifHandler(names=['_peak.asym_empir_4']),
Expand Down
61 changes: 61 additions & 0 deletions src/easydiffraction/datablocks/experiment/item/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
LinkedPhasesFactory,
)
from easydiffraction.datablocks.experiment.categories.peak.factory import PeakFactory
from easydiffraction.io.cif.parse import read_cif_str
from easydiffraction.io.cif.serialize import experiment_to_cif
from easydiffraction.utils.logging import console
from easydiffraction.utils.logging import log
Expand Down Expand Up @@ -122,6 +123,22 @@ def show_current_diffrn_type(self) -> None:
console.paragraph('Current diffrn type')
console.print(self.diffrn_type)

def _restore_switchable_types(self, block: object) -> None:
"""
Restore switchable category types from a parsed CIF block.

Called by the factory immediately after the experiment object is
created and before any category parameters are loaded from CIF.
Subclasses with switchable categories must override this method
and call their ``_set_<type>`` private setter for each category
whose active implementation is identified by a CIF type tag.

Parameters
----------
block : object
Parsed ``gemmi.cif.Block`` to read type tags from.
"""

@property
def as_cif(self) -> str:
"""Serialize this experiment to a CIF fragment."""
Expand Down Expand Up @@ -762,3 +779,47 @@ def show_current_peak_profile_type(self) -> None:
"""Print the currently selected peak profile type."""
console.paragraph('Current peak profile type')
console.print(self.peak_profile_type)

def _set_peak_profile_type(self, new_type: str) -> None:
"""
Switch the peak profile type without console output.

Used internally by the factory when restoring state from CIF so
that no user-facing warnings or progress messages are emitted.
Invalid type tags are logged as warnings and ignored.

Parameters
----------
new_type : str
Peak profile type tag (e.g. ``'split pseudo-voigt'``).
"""
supported = PeakFactory.supported_for(
scattering_type=self.type.scattering_type.value,
beam_mode=self.type.beam_mode.value,
)
supported_tags = [k.type_info.tag for k in supported]
if new_type not in supported_tags:
log.warning(
f"Unsupported peak profile '{new_type}' in CIF. "
f'Supported: {supported_tags}. Keeping default.',
)
return
self._peak = PeakFactory.create(new_type)
self._peak_profile_type = new_type

def _restore_switchable_types(self, block: object) -> None:
"""
Restore switchable category types for powder experiments.

Reads ``_peak.profile_type`` from the CIF block and switches to
the matching peak implementation before category parameters are
loaded, ensuring profile-specific descriptors are present.

Parameters
----------
block : object
Parsed ``gemmi.cif.Block`` to read type tags from.
"""
peak_type = read_cif_str(block, '_peak.profile_type')
if peak_type is not None:
self._set_peak_profile_type(peak_type)
4 changes: 4 additions & 0 deletions src/easydiffraction/datablocks/experiment/item/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ def _from_gemmi_block(
expt_class = cls._resolve_class(expt_type)
expt_obj = expt_class(name=name, type=expt_type)

# Restore switchable category types before loading parameters
# so implementation-specific descriptors exist for from_cif.
expt_obj._restore_switchable_types(block)

for category in expt_obj.categories:
category.from_cif(block)

Expand Down
3 changes: 2 additions & 1 deletion src/easydiffraction/datablocks/experiment/item/total_pd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.utils.logging import log

if TYPE_CHECKING:
from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType
Expand Down Expand Up @@ -86,7 +87,7 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> int:

default_sy = 0.03
if data.shape[1] < _MIN_COLUMNS_XY_SY:
print(f'Warning: No uncertainty (sy) column provided. Defaulting to {default_sy}.')
log.warning(f'No uncertainty (sy) column provided. Defaulting to {default_sy}.')

x = data[:, 0]
y = data[:, 1]
Expand Down
36 changes: 36 additions & 0 deletions src/easydiffraction/io/cif/parse.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# SPDX-FileCopyrightText: 2025 EasyScience contributors <https://github.com/easyscience>
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import gemmi

# Minimum raw-string length for CIF surrounding-quote detection
_MIN_QUOTED_LEN = 2


def document_from_path(path: str) -> gemmi.cif.Document:
"""Read a CIF document from a file path."""
Expand All @@ -25,6 +30,37 @@ def name_from_block(block: gemmi.cif.Block) -> str:
return block.name


def read_cif_str(block: gemmi.cif.Block, tag: str) -> str | None:
"""
Read a single string value from a CIF block by tag.

Strips surrounding single or double quotes when present, and returns
``None`` for absent tags or CIF unknown/inapplicable markers (``?``
/ ``.``).

Parameters
----------
block : gemmi.cif.Block
Parsed CIF data block to read from.
tag : str
CIF tag to look up (e.g. ``'_peak.profile_type'``).

Returns
-------
str | None
Unquoted string value, or ``None`` if not found.
"""
vals = list(block.find_values(tag))
if not vals:
return None
raw: str = vals[0]
if raw in {'?', '.'}:
return None
if len(raw) >= _MIN_QUOTED_LEN and raw[0] == raw[-1] and raw[0] in {"'", '"'}:
return raw[1:-1]
return raw


# def experiment_type_from_block(
# exp_type: ExperimentType,
# block: gemmi.cif.Block,
Expand Down
Loading
Loading