From 883f373ac6bdf0afa02007e229aa95940cd7552f Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 27 Apr 2026 14:56:32 +0200 Subject: [PATCH 1/3] Fix Cryspy recalculation for wrapped Wyckoff coordinates --- .../analysis/calculators/cryspy.py | 61 +++++++++++++++++++ .../analysis/calculators/test_cryspy.py | 33 ++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py index 8416a283..5f794d77 100644 --- a/src/easydiffraction/analysis/calculators/cryspy.py +++ b/src/easydiffraction/analysis/calculators/cryspy.py @@ -110,9 +110,19 @@ def calculate_structure_factors( else: cryspy_obj = self._recreate_cryspy_obj(structure, experiment) cryspy_dict = cryspy_obj.get_dictionary() + self._update_structure_in_cryspy_dict( + cryspy_dict[f'crystal_{structure.name}'], + structure, + ) + self._update_experiment_in_cryspy_dict(cryspy_dict, experiment) else: cryspy_obj = self._recreate_cryspy_obj(structure, experiment) cryspy_dict = cryspy_obj.get_dictionary() + self._update_structure_in_cryspy_dict( + cryspy_dict[f'crystal_{structure.name}'], + structure, + ) + self._update_experiment_in_cryspy_dict(cryspy_dict, experiment) self._cryspy_dicts[combined_name] = copy.deepcopy(cryspy_dict) @@ -182,9 +192,19 @@ def calculate_pattern( else: cryspy_obj = self._recreate_cryspy_obj(structure, experiment) cryspy_dict = cryspy_obj.get_dictionary() + self._update_structure_in_cryspy_dict( + cryspy_dict[f'crystal_{structure.name}'], + structure, + ) + self._update_experiment_in_cryspy_dict(cryspy_dict, experiment) else: cryspy_obj = self._recreate_cryspy_obj(structure, experiment) cryspy_dict = cryspy_obj.get_dictionary() + self._update_structure_in_cryspy_dict( + cryspy_dict[f'crystal_{structure.name}'], + structure, + ) + self._update_experiment_in_cryspy_dict(cryspy_dict, experiment) self._cryspy_dicts[combined_name] = copy.deepcopy(cryspy_dict) @@ -290,6 +310,13 @@ def _update_structure_in_cryspy_dict( for idx, atom_site in enumerate(structure.atom_sites): cryspy_occ[idx] = atom_site.occupancy.value + # Atomic multiplicities + if 'atom_multiplicity' in cryspy_model_dict: + CryspyCalculator._update_atom_multiplicity( + cryspy_model_dict, + structure, + ) + # Atomic ADPs - isotropic # For anisotropic atoms the full ADP lives in the β tensor; # setting b_iso to zero avoids double-counting in cryspy's DWF @@ -313,6 +340,40 @@ def _update_structure_in_cryspy_dict( structure, ) + @staticmethod + def _update_atom_multiplicity( + cryspy_model_dict: dict[str, Any], + structure: Structure, + ) -> None: + """ + Update cryspy atom multiplicities. + + CrysPy normalizes fractional coordinates into the ``[0, 1)`` + interval while parsing CIF. For sites such as ``(x, -x, z)``, + that can turn ``-x`` into ``1 - x`` before the Wyckoff + multiplicity is inferred, making special positions look like + general positions. EasyDiffraction already stores the intended + Wyckoff letter, so keep the calculator dictionary aligned with + that model state. + """ + from cryspy.A_functions_base.function_2_space_group import ( # noqa: PLC0415 + get_it_number_by_name_hm_short, + ) + + from easydiffraction.crystallography.space_groups import SPACE_GROUPS # noqa: PLC0415 + + it_number = get_it_number_by_name_hm_short(structure.space_group.name_h_m.value) + coord_code = structure.space_group.it_coordinate_system_code.value + if it_number is None or (it_number, coord_code) not in SPACE_GROUPS: + return + + positions = SPACE_GROUPS[it_number, coord_code]['Wyckoff_positions'] + multiplicity = cryspy_model_dict['atom_multiplicity'] + for idx, atom_site in enumerate(structure.atom_sites): + wyckoff_letter = atom_site.wyckoff_letter.value + if wyckoff_letter in positions: + multiplicity[idx] = positions[wyckoff_letter]['multiplicity'] + @staticmethod def _update_aniso_beta( cryspy_model_dict: dict[str, Any], diff --git a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py index dbe5939b..5f4cf104 100644 --- a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py +++ b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py @@ -78,3 +78,36 @@ def test_update_structure_zeroes_biso_for_anisotropic_atoms(): CryspyCalculator._update_structure_in_cryspy_dict(cryspy_model_dict, structure) assert cryspy_model_dict['atom_b_iso'][0] == 0.0 + + +def test_update_structure_restores_wyckoff_multiplicity_after_coordinate_wrapping(): + import numpy as np + + from easydiffraction.analysis.calculators.cryspy import CryspyCalculator + from easydiffraction.datablocks.structure.item.base import Structure + + structure = Structure(name='hs') + structure.space_group.name_h_m = 'R -3 m' + structure.space_group.it_coordinate_system_code = 'h' + structure.atom_sites.create( + label='O', + type_symbol='O', + fract_x=0.20587714, + fract_y=-0.20587714, + fract_z=0.06271739, + wyckoff_letter='h', + adp_iso=0.5, + ) + + cryspy_model_dict = { + 'unit_cell_parameters': [6.86, 6.86, 14.14, np.pi / 2, np.pi / 2, 2 * np.pi / 3], + 'atom_fract_xyz': np.array([[0.20587714], [0.79412286], [0.06271739]]), + 'atom_occupancy': np.array([1.0]), + 'atom_multiplicity': np.array([36]), + 'atom_b_iso': np.array([0.5]), + } + + CryspyCalculator._update_structure_in_cryspy_dict(cryspy_model_dict, structure) + + assert cryspy_model_dict['atom_fract_xyz'][1][0] == -0.20587714 + assert cryspy_model_dict['atom_multiplicity'][0] == 18 From 87bb078984077f48980172134f8c40a3283c0646 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 27 Apr 2026 14:56:50 +0200 Subject: [PATCH 2/3] Update SG tutorial to use Bumps minimizer --- docs/docs/tutorials/ed-15.ipynb | 41 ++++++++++++++++++++------------- docs/docs/tutorials/ed-15.py | 4 +++- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/docs/tutorials/ed-15.ipynb b/docs/docs/tutorials/ed-15.ipynb index 879097a8..d1ece4eb 100644 --- a/docs/docs/tutorials/ed-15.ipynb +++ b/docs/docs/tutorials/ed-15.ipynb @@ -229,8 +229,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fit.show_minimizer_types()\n", - "project.analysis.fit.minimizer_type = 'lmfit'" + "project.analysis.fit.show_minimizer_types()" ] }, { @@ -239,6 +238,16 @@ "id": "22", "metadata": {}, "outputs": [], + "source": [ + "project.analysis.fit.minimizer_type = 'bumps'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], "source": [ "# Start refinement. All parameters, which have standard uncertainties\n", "# in the input CIF files, are refined by default.\n", @@ -248,7 +257,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -259,7 +268,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -269,7 +278,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -279,7 +288,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -288,7 +297,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "28", "metadata": {}, "source": [ "## Step 5: Perform Analysis (ADP aniso)" @@ -297,7 +306,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -308,7 +317,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -321,7 +330,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -331,7 +340,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -341,7 +350,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -351,7 +360,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -361,7 +370,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -371,7 +380,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -381,7 +390,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "37", "metadata": {}, "outputs": [], "source": [ diff --git a/docs/docs/tutorials/ed-15.py b/docs/docs/tutorials/ed-15.py index 9a1a879b..c198fc23 100644 --- a/docs/docs/tutorials/ed-15.py +++ b/docs/docs/tutorials/ed-15.py @@ -75,7 +75,9 @@ # %% project.analysis.fit.show_minimizer_types() -project.analysis.fit.minimizer_type = 'lmfit' + +# %% +project.analysis.fit.minimizer_type = 'bumps' # %% # Start refinement. All parameters, which have standard uncertainties From 559a51caf994394ab1ea6e9570987884d6ce9aef Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 27 Apr 2026 15:14:31 +0200 Subject: [PATCH 3/3] Address review comments [ci skip] --- src/easydiffraction/analysis/calculators/cryspy.py | 3 +++ tests/unit/easydiffraction/analysis/calculators/test_cryspy.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py index 5f794d77..8e6a842a 100644 --- a/src/easydiffraction/analysis/calculators/cryspy.py +++ b/src/easydiffraction/analysis/calculators/cryspy.py @@ -356,6 +356,9 @@ def _update_atom_multiplicity( Wyckoff letter, so keep the calculator dictionary aligned with that model state. """ + if cryspy is None: + return + from cryspy.A_functions_base.function_2_space_group import ( # noqa: PLC0415 get_it_number_by_name_hm_short, ) diff --git a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py index 5f4cf104..178faab5 100644 --- a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py +++ b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py @@ -82,6 +82,9 @@ def test_update_structure_zeroes_biso_for_anisotropic_atoms(): def test_update_structure_restores_wyckoff_multiplicity_after_coordinate_wrapping(): import numpy as np + import pytest + + pytest.importorskip('cryspy') from easydiffraction.analysis.calculators.cryspy import CryspyCalculator from easydiffraction.datablocks.structure.item.base import Structure