Skip to content

Commit cc1be3d

Browse files
committed
Exclude symmetry-fixed parameters from refinement
1 parent ac6b627 commit cc1be3d

11 files changed

Lines changed: 301 additions & 37 deletions

File tree

‎docs/docs/tutorials/ed-13.ipynb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2665,7 +2665,7 @@
26652665
],
26662666
"metadata": {
26672667
"jupytext": {
2668-
"cell_metadata_filter": "title,tags,-all",
2668+
"cell_metadata_filter": "tags,title,-all",
26692669
"main_language": "python",
26702670
"notebook_metadata_filter": "-all"
26712671
}

‎src/easydiffraction/analysis/analysis.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,19 @@ class AnalysisDisplay:
105105
def __init__(self, analysis: 'Analysis') -> None:
106106
self._analysis = analysis
107107

108+
def _flush_structure_categories(self) -> None:
109+
"""
110+
Flush pending category updates so symmetry flags are fresh.
111+
"""
112+
project = self._analysis.project
113+
for structure in project.structures:
114+
structure._need_categories_update = True
115+
structure._update_categories()
116+
108117
def all_params(self) -> None:
109118
"""Print all parameters for structures and experiments."""
110119
project = self._analysis.project
120+
self._flush_structure_categories()
111121
structures_params = project.structures.parameters
112122
experiments_params = project.experiments.parameters
113123

@@ -139,6 +149,7 @@ def all_params(self) -> None:
139149
def fittable_params(self) -> None:
140150
"""Print all fittable parameters."""
141151
project = self._analysis.project
152+
self._flush_structure_categories()
142153
structures_params = project.structures.fittable_parameters
143154
experiments_params = project.experiments.fittable_parameters
144155

@@ -172,6 +183,7 @@ def fittable_params(self) -> None:
172183
def free_params(self) -> None:
173184
"""Print only currently free (varying) parameters."""
174185
project = self._analysis.project
186+
self._flush_structure_categories()
175187
structures_params = project.structures.free_parameters
176188
experiments_params = project.experiments.free_parameters
177189
free_params = structures_params + experiments_params

‎src/easydiffraction/core/variable.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,10 +364,12 @@ def free(self, v: bool) -> None:
364364

365365
@property
366366
def symmetry_fixed(self) -> bool:
367-
"""Whether this parameter is fixed by crystallographic symmetry."""
367+
"""
368+
Whether this parameter is fixed by crystallographic symmetry.
369+
"""
368370
return self._symmetry_fixed
369371

370-
def _set_symmetry_fixed(self, v: bool) -> None:
372+
def _set_symmetry_fixed(self, *, value: bool) -> None:
371373
"""
372374
Mark or unmark this parameter as fixed by symmetry.
373375
@@ -378,11 +380,13 @@ def _set_symmetry_fixed(self, v: bool) -> None:
378380
379381
Parameters
380382
----------
381-
v : bool
383+
value : bool
382384
New symmetry-fixed state.
383385
"""
384386
validated = self._symmetry_fixed_spec.validated(
385-
v, name=f'{self.unique_name}.symmetry_fixed', current=self._symmetry_fixed
387+
value,
388+
name=f'{self.unique_name}.symmetry_fixed',
389+
current=self._symmetry_fixed,
386390
)
387391
self._symmetry_fixed = validated
388392
if validated:

‎src/easydiffraction/crystallography/crystallography.py‎

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ def _crystal_system_from_name_hm(name_hm: str) -> str | None:
118118
return crystal_system
119119

120120

121+
_CELL_FIXED_AXES_BY_SYSTEM: dict[str, set[str]] = {
122+
'cubic': {'lattice_b', 'lattice_c', 'angle_alpha', 'angle_beta', 'angle_gamma'},
123+
'tetragonal': {'lattice_b', 'angle_alpha', 'angle_beta', 'angle_gamma'},
124+
'orthorhombic': {'angle_alpha', 'angle_beta', 'angle_gamma'},
125+
'hexagonal': {'lattice_b', 'angle_alpha', 'angle_beta', 'angle_gamma'},
126+
'trigonal': {'lattice_b', 'angle_alpha', 'angle_beta', 'angle_gamma'},
127+
'monoclinic': {'angle_alpha', 'angle_gamma'},
128+
'triclinic': set(),
129+
}
130+
131+
121132
def _cell_fixed_axes(crystal_system: str) -> set[str]:
122133
"""
123134
Return cell keys that are dependent on others for a crystal system.
@@ -136,19 +147,7 @@ def _cell_fixed_axes(crystal_system: str) -> set[str]:
136147
set[str]
137148
Subset of cell keys that are fixed by symmetry.
138149
"""
139-
if crystal_system == 'cubic':
140-
return {'lattice_b', 'lattice_c', 'angle_alpha', 'angle_beta', 'angle_gamma'}
141-
if crystal_system == 'tetragonal':
142-
return {'lattice_b', 'angle_alpha', 'angle_beta', 'angle_gamma'}
143-
if crystal_system == 'orthorhombic':
144-
return {'angle_alpha', 'angle_beta', 'angle_gamma'}
145-
if crystal_system in {'hexagonal', 'trigonal'}:
146-
return {'lattice_b', 'angle_alpha', 'angle_beta', 'angle_gamma'}
147-
if crystal_system == 'monoclinic':
148-
return {'angle_alpha', 'angle_gamma'}
149-
if crystal_system == 'triclinic':
150-
return set()
151-
return set()
150+
return _CELL_FIXED_AXES_BY_SYSTEM.get(crystal_system, set())
152151

153152

154153
def cell_symmetry_fixed_flags(name_hm: str) -> dict[str, bool]:
@@ -170,7 +169,7 @@ def cell_symmetry_fixed_flags(name_hm: str) -> dict[str, bool]:
170169
"""
171170
crystal_system = _crystal_system_from_name_hm(name_hm)
172171
if crystal_system is None:
173-
return {key: False for key in _CELL_KEYS}
172+
return dict.fromkeys(_CELL_KEYS, False)
174173
fixed = _cell_fixed_axes(crystal_system)
175174
return {key: key in fixed for key in _CELL_KEYS}
176175

@@ -207,6 +206,12 @@ def _get_wyckoff_exprs(
207206
log.error('IT_coordinate_system_code is not set')
208207
return None
209208

209+
if (it_number, coord_code) not in SPACE_GROUPS:
210+
# Space group is not in the local SPACE_GROUPS table (e.g. P 1,
211+
# where cryspy reports no coordinate-system codes). Treat as
212+
# "no symmetry constraints to apply".
213+
return None
214+
210215
entry = SPACE_GROUPS[it_number, coord_code]
211216
first_position = entry['Wyckoff_positions'][wyckoff_letter]['coords_xyz'][0]
212217
components = first_position.strip('()').split(',')
@@ -217,9 +222,9 @@ def _fract_fixed_flags(parsed_exprs: list[Expr]) -> dict[str, bool]:
217222
"""
218223
Return per-axis flags marking coordinates fixed by site symmetry.
219224
220-
For each axis (x, y, z), the coordinate is considered fixed when
221-
the corresponding symbol does not appear as a free symbol in any of
222-
the Wyckoff position expressions.
225+
For each axis (x, y, z), the coordinate is considered fixed when the
226+
corresponding symbol does not appear as a free symbol in any of the
227+
Wyckoff position expressions.
223228
224229
Parameters
225230
----------
@@ -327,9 +332,9 @@ def atom_site_symmetry_fixed_flags(
327332
Returns
328333
-------
329334
dict[str, bool]
330-
Mapping ``'fract_x' / 'fract_y' / 'fract_z'`` to ``True`` if
331-
the axis is fully determined by site symmetry. Returns all
332-
``False`` when the Wyckoff position cannot be resolved.
335+
Mapping ``'fract_x' / 'fract_y' / 'fract_z'`` to ``True`` if the
336+
axis is fully determined by site symmetry. Returns all ``False``
337+
when the Wyckoff position cannot be resolved.
333338
"""
334339
parsed_exprs = _get_wyckoff_exprs(name_hm, coord_code, wyckoff_letter)
335340
if parsed_exprs is None:

‎src/easydiffraction/datablocks/structure/categories/atom_sites/default.py‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -558,8 +558,8 @@ def _apply_atomic_coordinates_symmetry_constraints(self) -> None:
558558
Uses the parent structure's space-group symbol, IT coordinate
559559
system code and each atom's Wyckoff letter. Atoms without a
560560
Wyckoff letter are silently skipped. Coordinates fully
561-
determined by site symmetry are flagged as ``symmetry_fixed``
562-
so they cannot be marked refinable.
561+
determined by site symmetry are flagged as ``symmetry_fixed`` so
562+
they cannot be marked refinable.
563563
"""
564564
structure = self._parent
565565
space_group_name = structure.space_group.name_h_m.value
@@ -589,16 +589,17 @@ def _apply_atomic_coordinates_symmetry_constraints(self) -> None:
589589
atom.fract_x.value = dummy_atom['fract_x']
590590
atom.fract_y.value = dummy_atom['fract_y']
591591
atom.fract_z.value = dummy_atom['fract_z']
592-
atom._fract_x._set_symmetry_fixed(fixed_flags['fract_x'])
593-
atom._fract_y._set_symmetry_fixed(fixed_flags['fract_y'])
594-
atom._fract_z._set_symmetry_fixed(fixed_flags['fract_z'])
592+
atom._fract_x._set_symmetry_fixed(value=fixed_flags['fract_x'])
593+
atom._fract_y._set_symmetry_fixed(value=fixed_flags['fract_y'])
594+
atom._fract_z._set_symmetry_fixed(value=fixed_flags['fract_z'])
595595

596596
@staticmethod
597597
def _clear_fract_symmetry_fixed(atom: AtomSite) -> None:
598-
"""Reset the ``symmetry_fixed`` flag on all fract coordinates."""
599-
atom._fract_x._set_symmetry_fixed(False)
600-
atom._fract_y._set_symmetry_fixed(False)
601-
atom._fract_z._set_symmetry_fixed(False)
598+
"""
599+
Reset the ``symmetry_fixed`` flag on all fract coordinates.
600+
"""
601+
for axis_param in (atom._fract_x, atom._fract_y, atom._fract_z):
602+
axis_param._set_symmetry_fixed(value=False)
602603

603604
def _apply_adp_symmetry_constraints(self) -> None:
604605
"""
@@ -619,7 +620,7 @@ def _apply_adp_symmetry_constraints(self) -> None:
619620
for atom in self._items:
620621
is_aniso = atom.adp_type.value in aniso_types
621622
# Isotropic ADP is not refinable for aniso atoms
622-
atom._adp_iso._set_symmetry_fixed(is_aniso)
623+
atom._adp_iso._set_symmetry_fixed(value=is_aniso)
623624
if not is_aniso:
624625
continue
625626
wl = atom.wyckoff_letter.value
@@ -653,7 +654,7 @@ def _apply_adp_symmetry_constraints(self) -> None:
653654
for key, is_free in zip(adp_keys, ref_i, strict=False):
654655
param = getattr(aniso_entry, key)
655656
param.value = dummy[key]
656-
param._set_symmetry_fixed(not is_free)
657+
param._set_symmetry_fixed(value=not is_free)
657658

658659
def _sync_iso_from_aniso(self) -> None:
659660
"""

‎src/easydiffraction/datablocks/structure/categories/cell/default.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def _apply_cell_symmetry_constraints(self) -> None:
134134
}
135135
for key, param in param_by_key.items():
136136
param.value = dummy_cell[key]
137-
param._set_symmetry_fixed(fixed_flags[key])
137+
param._set_symmetry_fixed(value=fixed_flags[key])
138138

139139
def _update(
140140
self,

‎tests/functional/test_structure_workflow.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,103 @@ def test_multiple_atom_sites(self):
137137
adp_iso=0.3,
138138
)
139139
assert len(s.atom_sites) == 2
140+
141+
142+
class TestSymmetryFixedParameters:
143+
def test_special_position_fract_cannot_be_freed(self, monkeypatch):
144+
from easydiffraction.utils.logging import Logger
145+
146+
project = _make_project()
147+
project.structures.create(name='test')
148+
s = project.structures['test']
149+
s.space_group.name_h_m = 'P m -3 m'
150+
s.atom_sites.create(
151+
label='La',
152+
type_symbol='La',
153+
fract_x=0,
154+
fract_y=0,
155+
fract_z=0,
156+
wyckoff_letter='a',
157+
adp_iso=0.5,
158+
)
159+
# Trigger symmetry-flag computation (normally done by display
160+
# / fitting, here we call it explicitly).
161+
s._need_categories_update = True
162+
s._update_categories()
163+
164+
atom = s.atom_sites['La']
165+
assert atom.fract_x.symmetry_fixed is True
166+
assert atom.fract_y.symmetry_fixed is True
167+
assert atom.fract_z.symmetry_fixed is True
168+
169+
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
170+
for parameter in ('fract_x', 'fract_y', 'fract_z'):
171+
getattr(atom, parameter).free = True
172+
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
173+
174+
assert atom.fract_x.free is False
175+
assert atom.fract_y.free is False
176+
assert atom.fract_z.free is False
177+
178+
def test_cubic_cell_has_only_a_free(self):
179+
project = _make_project()
180+
project.structures.create(name='test')
181+
s = project.structures['test']
182+
s.space_group.name_h_m = 'P m -3 m'
183+
s._need_categories_update = True
184+
s._update_categories()
185+
186+
assert s.cell.length_a.symmetry_fixed is False
187+
assert s.cell.length_b.symmetry_fixed is True
188+
assert s.cell.length_c.symmetry_fixed is True
189+
assert s.cell.angle_alpha.symmetry_fixed is True
190+
assert s.cell.angle_beta.symmetry_fixed is True
191+
assert s.cell.angle_gamma.symmetry_fixed is True
192+
193+
def test_general_position_remains_refinable(self):
194+
project = _make_project()
195+
project.structures.create(name='test')
196+
s = project.structures['test']
197+
# Default space group is P 1 -- general position 'a'
198+
s.atom_sites.create(
199+
label='La',
200+
type_symbol='La',
201+
fract_x=0.1,
202+
fract_y=0.2,
203+
fract_z=0.3,
204+
wyckoff_letter='a',
205+
adp_iso=0.5,
206+
)
207+
s._need_categories_update = True
208+
s._update_categories()
209+
210+
atom = s.atom_sites['La']
211+
assert atom.fract_x.symmetry_fixed is False
212+
atom.fract_x.free = True
213+
assert atom.fract_x.free is True
214+
215+
def test_changing_space_group_updates_flags(self, monkeypatch):
216+
from easydiffraction.utils.logging import Logger
217+
218+
project = _make_project()
219+
project.structures.create(name='test')
220+
s = project.structures['test']
221+
222+
# Start in P 1: cell free
223+
s._need_categories_update = True
224+
s._update_categories()
225+
assert s.cell.length_b.symmetry_fixed is False
226+
s.cell.length_b.free = True
227+
assert s.cell.length_b.free is True
228+
229+
# Switch to cubic: length_b becomes fixed
230+
s.space_group.name_h_m = 'P m -3 m'
231+
s._update_categories()
232+
assert s.cell.length_b.symmetry_fixed is True
233+
assert s.cell.length_b.free is False
234+
235+
# Setting free=True is now ignored with a warning
236+
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
237+
s.cell.length_b.free = True
238+
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
239+
assert s.cell.length_b.free is False

‎tests/unit/easydiffraction/analysis/test_analysis_show_empty.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ def fittable_parameters(self):
1818
def free_parameters(self):
1919
return []
2020

21+
def __iter__(self):
22+
return iter(())
23+
2124
class P:
2225
structures = Empty()
2326
experiments = Empty()

‎tests/unit/easydiffraction/core/test_parameters.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,57 @@ def test_parameter_fit_bounds_assign_and_read():
101101
p.fit_max = 10.0
102102
assert np.isclose(p.fit_min, -1.0)
103103
assert np.isclose(p.fit_max, 10.0)
104+
105+
106+
def _make_param() -> object:
107+
from easydiffraction.core.validation import AttributeSpec
108+
from easydiffraction.core.variable import Parameter
109+
from easydiffraction.io.cif.handler import CifHandler
110+
111+
return Parameter(
112+
name='p',
113+
value_spec=AttributeSpec(default=0.0),
114+
cif_handler=CifHandler(names=['_param.p']),
115+
)
116+
117+
118+
def test_parameter_symmetry_fixed_default_is_false():
119+
p = _make_param()
120+
assert p.symmetry_fixed is False
121+
122+
123+
def test_parameter_set_symmetry_fixed_forces_free_false():
124+
p = _make_param()
125+
p.free = True
126+
assert p.free is True
127+
p._set_symmetry_fixed(value=True)
128+
assert p.symmetry_fixed is True
129+
assert p.free is False
130+
131+
132+
def test_parameter_free_true_ignored_when_symmetry_fixed(monkeypatch):
133+
from easydiffraction.utils.logging import Logger
134+
135+
p = _make_param()
136+
p._set_symmetry_fixed(value=True)
137+
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
138+
p.free = True
139+
assert p.free is False
140+
assert p.symmetry_fixed is True
141+
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
142+
143+
144+
def test_parameter_free_false_allowed_when_symmetry_fixed():
145+
p = _make_param()
146+
p._set_symmetry_fixed(value=True)
147+
p.free = False # should not warn or raise
148+
assert p.free is False
149+
150+
151+
def test_parameter_clearing_symmetry_fixed_allows_free_true():
152+
p = _make_param()
153+
p._set_symmetry_fixed(value=True)
154+
p._set_symmetry_fixed(value=False)
155+
p.free = True
156+
assert p.free is True
157+
assert p.symmetry_fixed is False

0 commit comments

Comments
 (0)