Skip to content

Commit abf7b63

Browse files
Fix asymmetry and save/restore peak profile type in experiment CIF (#144)
* Update save_as paths to include 'data/' directory and adjust test function to use temporary directory * Add crysfml * Update notebooks * Clean up * Add simple integration test for crysfml * Bump pycrysfml to 0.2.1 * Add pytest marker to neutron_pd_cwl_lbco_crysfml test * Extend package test script by adding pycrysfml * Fix scipp integration test * Move jupyterlab and pixi-kernel out from pyproject.toml * Remove unnecessary libcxx addition for osx-64 in test.yml * Add custom PyPI index support for pycrysfml in test.yml * Update .gitignore to include data directory for tutorial runtime * Apply latest templates * Add temporary rule ignores for docstring and datetime checks in pyproject.toml * Apply new templates * Update index hash for data integrity in utils.py * Add reverse flag to fit_sequential for reverse-order processing * Update tutorials index * Update tutorial * Add initial structure and analysis configuration files * Fix asymmetry and save/restore peak_profile_type in experiment CIF * Refactor switchable-type restore into _restore_switchable_types * Update test example * Update data-index.json known hash in utils.py
1 parent e6dc9a1 commit abf7b63

36 files changed

Lines changed: 33917 additions & 9 deletions

File tree

docs/docs/tutorials/ed-13.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@
575575
# #### Show Free Parameters
576576
#
577577
# We can check which parameters are free to be refined by calling the
578-
# `show_free_params` method of the `analysis` object of the project.
578+
# `free_params` method of the `analysis.display` object of the project.
579579

580580
# %% [markdown] tags=["doc-link"]
581581
# 📖 See

docs/docs/tutorials/ed-17.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,12 @@
270270
# %%
271271
project.analysis.fit()
272272

273+
# %% [markdown]
274+
# #### Compare measured and calculated patterns for the first fit.
275+
276+
# %%
277+
project.plotter.plot_meas_vs_calc(expt_name='d20', show_residual=True)
278+
273279
# %% [markdown]
274280
# #### Run Sequential Fitting
275281
#
@@ -301,11 +307,20 @@ def extract_diffrn(file_path):
301307
data_dir=data_dir,
302308
extract_diffrn=extract_diffrn,
303309
max_workers='auto',
310+
reverse=True,
304311
)
305312

306313
# %% [markdown]
307314
# #### Replay a Dataset
308315
#
316+
# Apply fitted parameters from the first CSV row and plot the result.
317+
318+
# %%
319+
project.apply_params_from_csv(row_index=0)
320+
project.plotter.plot_meas_vs_calc(expt_name='d20', show_residual=True)
321+
322+
# %% [markdown]
323+
#
309324
# Apply fitted parameters from the last CSV row and plot the result.
310325

311326
# %%

src/easydiffraction/analysis/calculators/cryspy.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,13 @@ def _update_experiment_in_cryspy_dict(
297297
cryspy_resolution[3] = experiment.peak.broad_lorentz_x.value
298298
cryspy_resolution[4] = experiment.peak.broad_lorentz_y.value
299299

300+
if 'asymmetry_parameters' in cryspy_expt_dict:
301+
cryspy_asymmetry = cryspy_expt_dict['asymmetry_parameters']
302+
cryspy_asymmetry[0] = experiment.peak.asym_empir_1.value
303+
cryspy_asymmetry[1] = experiment.peak.asym_empir_2.value
304+
cryspy_asymmetry[2] = experiment.peak.asym_empir_3.value
305+
cryspy_asymmetry[3] = experiment.peak.asym_empir_4.value
306+
300307
elif experiment.type.beam_mode.value == BeamModeEnum.TIME_OF_FLIGHT:
301308
cryspy_expt_name = f'tof_{experiment.name}'
302309
cryspy_expt_dict = cryspy_dict[cryspy_expt_name]
@@ -507,6 +514,10 @@ def _cif_peak_section(
507514
'broad_gauss_w': '_pd_instr_resolution_W',
508515
'broad_lorentz_x': '_pd_instr_resolution_X',
509516
'broad_lorentz_y': '_pd_instr_resolution_Y',
517+
'asym_empir_1': '_pd_instr_reflex_asymmetry_p1',
518+
'asym_empir_2': '_pd_instr_reflex_asymmetry_p2',
519+
'asym_empir_3': '_pd_instr_reflex_asymmetry_p3',
520+
'asym_empir_4': '_pd_instr_reflex_asymmetry_p4',
510521
}
511522
elif expt_type.beam_mode.value == BeamModeEnum.TIME_OF_FLIGHT:
512523
peak_mapping = {
@@ -522,7 +533,7 @@ def _cif_peak_section(
522533

523534
cif_lines.append('')
524535
for local_attr_name, engine_key_name in peak_mapping.items():
525-
attr_obj = getattr(peak, local_attr_name)
536+
attr_obj = getattr(peak, local_attr_name, None)
526537
if attr_obj is not None:
527538
cif_lines.append(f'{engine_key_name} {attr_obj.value}')
528539

src/easydiffraction/core/singleton.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# SPDX-FileCopyrightText: 2026 EasyScience contributors <https://github.com/easyscience>
22
# SPDX-License-Identifier: BSD-3-Clause
33

4+
from __future__ import annotations
5+
46
from typing import Any
57
from typing import Self
68

79
from asteval import Interpreter
810

11+
from easydiffraction.utils.logging import log
12+
913
# ======================================================================
1014

1115

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

118+
# asteval silently returns None for undefined names
119+
# instead of raising an exception; errors are stored in
120+
# ae.error.
121+
if ae.error:
122+
error_msgs = '; '.join(str(e.get_error()) for e in ae.error)
123+
ae.error.clear()
124+
log.error(
125+
f"Constraint '{lhs_alias} = {rhs_expr}' could not be "
126+
f'evaluated: {error_msgs}. '
127+
f'Make sure every name in the expression is registered '
128+
f'as an alias via analysis.aliases.create().',
129+
exc_type=ValueError,
130+
)
131+
114132
# Get the actual parameter object we want to update
115133
param = self._alias_to_param[lhs_alias].param
116134

src/easydiffraction/datablocks/experiment/categories/peak/base.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
# SPDX-License-Identifier: BSD-3-Clause
33
"""Base class for peak profile categories."""
44

5+
from __future__ import annotations
6+
57
from easydiffraction.core.category import CategoryItem
8+
from easydiffraction.core.validation import AttributeSpec
9+
from easydiffraction.core.variable import StringDescriptor
10+
from easydiffraction.io.cif.handler import CifHandler
611

712

813
class PeakBase(CategoryItem):
@@ -11,3 +16,24 @@ class PeakBase(CategoryItem):
1116
def __init__(self) -> None:
1217
super().__init__()
1318
self._identity.category_code = 'peak'
19+
20+
type_info = getattr(type(self), 'type_info', None)
21+
default_tag = type_info.tag if type_info is not None else ''
22+
self._profile_type: StringDescriptor = StringDescriptor(
23+
name='profile_type',
24+
description='Active peak profile type tag',
25+
value_spec=AttributeSpec(default=default_tag),
26+
cif_handler=CifHandler(names=['_peak.profile_type']),
27+
)
28+
29+
@property
30+
def profile_type(self) -> StringDescriptor:
31+
"""
32+
CIF identifier for the active peak profile type.
33+
34+
Returns
35+
-------
36+
StringDescriptor
37+
The descriptor holding the profile type tag string.
38+
"""
39+
return self._profile_type

src/easydiffraction/datablocks/experiment/categories/peak/cwl.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class CwlSplitPseudoVoigt(
5757
beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
5858
)
5959
calculator_support = CalculatorSupport(
60-
calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
60+
calculators=frozenset({CalculatorEnum.CRYSPY}),
6161
)
6262

6363
def __init__(self) -> None:
@@ -81,7 +81,7 @@ class CwlThompsonCoxHastings(
8181
beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
8282
)
8383
calculator_support = CalculatorSupport(
84-
calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
84+
calculators=frozenset({CalculatorEnum.CRYSFML}),
8585
)
8686

8787
def __init__(self) -> None:

src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ def __init__(self) -> None:
157157
description='Empirical asymmetry coefficient p1',
158158
units='',
159159
value_spec=AttributeSpec(
160-
default=0.1,
160+
default=0.0,
161161
validator=RangeValidator(),
162162
),
163163
cif_handler=CifHandler(names=['_peak.asym_empir_1']),
@@ -167,7 +167,7 @@ def __init__(self) -> None:
167167
description='Empirical asymmetry coefficient p2',
168168
units='',
169169
value_spec=AttributeSpec(
170-
default=0.2,
170+
default=0.0,
171171
validator=RangeValidator(),
172172
),
173173
cif_handler=CifHandler(names=['_peak.asym_empir_2']),
@@ -177,7 +177,7 @@ def __init__(self) -> None:
177177
description='Empirical asymmetry coefficient p3',
178178
units='',
179179
value_spec=AttributeSpec(
180-
default=0.3,
180+
default=0.0,
181181
validator=RangeValidator(),
182182
),
183183
cif_handler=CifHandler(names=['_peak.asym_empir_3']),
@@ -187,7 +187,7 @@ def __init__(self) -> None:
187187
description='Empirical asymmetry coefficient p4',
188188
units='',
189189
value_spec=AttributeSpec(
190-
default=0.4,
190+
default=0.0,
191191
validator=RangeValidator(),
192192
),
193193
cif_handler=CifHandler(names=['_peak.asym_empir_4']),

src/easydiffraction/datablocks/experiment/item/base.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
LinkedPhasesFactory,
2424
)
2525
from easydiffraction.datablocks.experiment.categories.peak.factory import PeakFactory
26+
from easydiffraction.io.cif.parse import read_cif_str
2627
from easydiffraction.io.cif.serialize import experiment_to_cif
2728
from easydiffraction.utils.logging import console
2829
from easydiffraction.utils.logging import log
@@ -122,6 +123,22 @@ def show_current_diffrn_type(self) -> None:
122123
console.paragraph('Current diffrn type')
123124
console.print(self.diffrn_type)
124125

126+
def _restore_switchable_types(self, block: object) -> None:
127+
"""
128+
Restore switchable category types from a parsed CIF block.
129+
130+
Called by the factory immediately after the experiment object is
131+
created and before any category parameters are loaded from CIF.
132+
Subclasses with switchable categories must override this method
133+
and call their ``_set_<type>`` private setter for each category
134+
whose active implementation is identified by a CIF type tag.
135+
136+
Parameters
137+
----------
138+
block : object
139+
Parsed ``gemmi.cif.Block`` to read type tags from.
140+
"""
141+
125142
@property
126143
def as_cif(self) -> str:
127144
"""Serialize this experiment to a CIF fragment."""
@@ -762,3 +779,47 @@ def show_current_peak_profile_type(self) -> None:
762779
"""Print the currently selected peak profile type."""
763780
console.paragraph('Current peak profile type')
764781
console.print(self.peak_profile_type)
782+
783+
def _set_peak_profile_type(self, new_type: str) -> None:
784+
"""
785+
Switch the peak profile type without console output.
786+
787+
Used internally by the factory when restoring state from CIF so
788+
that no user-facing warnings or progress messages are emitted.
789+
Invalid type tags are logged as warnings and ignored.
790+
791+
Parameters
792+
----------
793+
new_type : str
794+
Peak profile type tag (e.g. ``'split pseudo-voigt'``).
795+
"""
796+
supported = PeakFactory.supported_for(
797+
scattering_type=self.type.scattering_type.value,
798+
beam_mode=self.type.beam_mode.value,
799+
)
800+
supported_tags = [k.type_info.tag for k in supported]
801+
if new_type not in supported_tags:
802+
log.warning(
803+
f"Unsupported peak profile '{new_type}' in CIF. "
804+
f'Supported: {supported_tags}. Keeping default.',
805+
)
806+
return
807+
self._peak = PeakFactory.create(new_type)
808+
self._peak_profile_type = new_type
809+
810+
def _restore_switchable_types(self, block: object) -> None:
811+
"""
812+
Restore switchable category types for powder experiments.
813+
814+
Reads ``_peak.profile_type`` from the CIF block and switches to
815+
the matching peak implementation before category parameters are
816+
loaded, ensuring profile-specific descriptors are present.
817+
818+
Parameters
819+
----------
820+
block : object
821+
Parsed ``gemmi.cif.Block`` to read type tags from.
822+
"""
823+
peak_type = read_cif_str(block, '_peak.profile_type')
824+
if peak_type is not None:
825+
self._set_peak_profile_type(peak_type)

src/easydiffraction/datablocks/experiment/item/factory.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ def _from_gemmi_block(
120120
expt_class = cls._resolve_class(expt_type)
121121
expt_obj = expt_class(name=name, type=expt_type)
122122

123+
# Restore switchable category types before loading parameters
124+
# so implementation-specific descriptors exist for from_cif.
125+
expt_obj._restore_switchable_types(block)
126+
123127
for category in expt_obj.categories:
124128
category.from_cif(block)
125129

src/easydiffraction/datablocks/experiment/item/total_pd.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
1515
from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
1616
from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory
17+
from easydiffraction.utils.logging import log
1718

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

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

9192
x = data[:, 0]
9293
y = data[:, 1]

0 commit comments

Comments
 (0)