Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/tests-on-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ jobs:
project: diffpy.cmirietveld
c_extension: false
headless: false
run: python -m pip install git+https://github.com/diffpy/pyobjcryst.git@d6a1279a26b9b1e6e303d103935b4469c5a62e41 --no-deps
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
2 changes: 0 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
default_language_version:
python: python3.13
ci:
autofix_commit_msg: |
[pre-commit.ci] auto fixes from pre-commit hooks
Expand Down
24 changes: 24 additions & 0 deletions news/component-targets-and-settings.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
**Added:**

* Add ``Component.settings_version`` and ``Component.mark_settings_changed``, so a generator can run ``install`` again after a setting of a component changes.
* Add a ``target`` argument to ``Component.create_parameters_from`` and ``Component.push_parameters``, so one component can configure several pyobjcryst objects, such as a pattern and its radiation.

**Changed:**

* <news item>

**Deprecated:**

* <news item>

**Removed:**

* <news item>

**Fixed:**

* <news item>

**Security:**

* <news item>
24 changes: 24 additions & 0 deletions news/constant-wavelength-instrument.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
**Added:**

* Add ``ConstantWavelengthInstrument``, the instrument of a constant-wavelength x-ray, neutron or electron diffractometer, with refinable ``zero``, ``displacement``, ``transparency``, ``mu_r`` and ``wavelength``, and settings for the radiation type, an x-ray source's Kalpha1/Kalpha2 doublet and the polarization rate.
* Warn when a ``ConstantWavelengthInstrument`` setter replaces a refined wavelength or a Kalpha1/Kalpha2 doublet, or when a polarization rate has no effect because the radiation isn't x-rays.

**Changed:**

* <news item>

**Deprecated:**

* <news item>

**Removed:**

* <news item>

**Fixed:**

* <news item>

**Security:**

* <news item>
4 changes: 3 additions & 1 deletion requirements/conda.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
numpy
diffpy.srfit
pyobjcryst
libobjcryst>=2026.3.0
libboost-devel
libboost-python
134 changes: 111 additions & 23 deletions src/diffpy/cmirietveld/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,86 @@ class Component(ParameterSet):
they configure. They do not keep a reference to the pyobjcryst objects
they are given, so one component can serve several generators.

A component has two kinds of configuration:

- **Parameters** are the values a fit can refine, such as a zero shift
or a peak width. They are created from a pyobjcryst model with
``create_parameters_from``, and ``update`` writes them into the
model with ``push_parameters`` before every calculation. A component
that configures more than one pyobjcryst object, such as a pattern
and its radiation, creates the Parameters of each object under its
own target and pushes them one target at a time.
- **Settings** are the configuration that is not refined, such as the
radiation type or the x-ray tube. ``install`` applies them. A method
that changes a setting calls ``mark_settings_changed``, so every
generator holding the component runs ``install`` again before its
next calculation.

Parameters
----------
name : str
The name of the component. It must be a valid attribute name.

Examples
--------
An instrument whose ``zero`` belongs to the pattern and whose
``wavelength`` belongs to the pattern's radiation:

>>> class MyInstrument(Component):
... def __init__(self):
... super().__init__("instrument")
... self.create_parameters_from(
... PowderPattern(), {"zero": "Zero"}, target="pattern"
... )
... self.create_parameters_from(
... Radiation(),
... {"wavelength": "Wavelength"},
... target="radiation",
... )
...
... def update(self, pattern, diffraction):
... self.push_parameters(pattern, target="pattern")
... self.push_parameters(
... pattern.GetRadiation(), target="radiation"
... )
"""

def __init__(self, name):
ParameterSet.__init__(self, name)
self._refinable_par_names = {}
self._settings_version = 0

@property
def settings_version(self):
"""The number of times the settings of the component changed.

A generator compares it with the value it last installed, and
runs ``install`` again when it differs. It starts at 0, and only
``mark_settings_changed`` changes it: a change to a Parameter
value is not a settings change.
"""
return self._settings_version

def mark_settings_changed(self):
"""Record that a setting of the component changed.

Settings are the configuration of a component that is not a
Parameter, such as the radiation type of an instrument. Call
this from every method that changes a setting. It bumps
``settings_version`` and notifies the component's observers, so
every generator holding the component drops its cached pattern
and runs ``install`` again before its next calculation.

Examples
--------
A setting method of an instrument:

>>> def set_xray_source(self, element):
... self._xray_source = element
... self.mark_settings_changed()
"""
self._settings_version += 1
self._flush(other=(self,))

def install(self, pattern, diffraction):
"""Set the model up in one generator.
Expand Down Expand Up @@ -73,7 +144,7 @@ def update(self, pattern, diffraction):
"""
raise NotImplementedError

def create_parameters_from(self, refinable_obj, names):
def create_parameters_from(self, refinable_obj, names, target="default"):
"""Create the Parameters of the component from a pyobjcryst
model.

Expand All @@ -86,18 +157,23 @@ def create_parameters_from(self, refinable_obj, names):
rather than copied, so the names users see are this package's,
independent of ObjCryst's. Some ObjCryst names are not valid
Parameter names (``2ThetaDispl``). The mapping keeps ObjCryst
parameters out of refinement scripts. The mapping is stored, so
``push_parameters`` writes each value back to the same
``RefinablePar``.
parameters out of refinement scripts. The mapping is stored under
``target``, so ``push_parameters`` writes each value back to the
same ``RefinablePar``.

Parameters
----------
refinable_obj : pyobjcryst.refinableobj.RefinableObj
A fresh instance of the pyobjcryst model.
names : dict of str to str
The ``RefinablePar`` name for each Parameter name, for example
``{"eta0": "Eta0"}``. Only the named ``RefinablePar``s get a
Parameter.
``{"eta0": "Eta0"}``. Only the named ``RefinablePar`` objects
get a Parameter.
target : str, optional
The name of the pyobjcryst object the Parameters belong to
(default "default"). A component that configures more than one
pyobjcryst object, such as a pattern and its radiation, creates
the Parameters of each object under its own target.

Raises
------
Expand Down Expand Up @@ -134,13 +210,14 @@ def create_parameters_from(self, refinable_obj, names):
refinable_par.GetHumanMin(), refinable_par.GetHumanMax()
)
self.add_parameter(parameter)
self._refinable_par_names.update(names)
self._refinable_par_names.setdefault(target, {}).update(names)

def push_parameters(self, refinable_obj):
def push_parameters(self, refinable_obj, target="default"):
"""Push the Parameter values into a pyobjcryst model.

Each value is written to the ``RefinablePar`` that
``create_parameters_from`` mapped it to.
Each value of the target's Parameters is written to the
``RefinablePar`` that ``create_parameters_from`` mapped it to. The
Parameters of other targets are neither checked nor pushed.

A value outside the limits of its ``RefinablePar`` raises an error
instead of being clamped, because ObjCryst would otherwise use a
Expand All @@ -150,30 +227,41 @@ def push_parameters(self, refinable_obj):
value past a limit. An optimizer run without the recipe's bounds
stops with this error the first time it steps past a limit.

All the values are checked before any is pushed, so an error
leaves the model unchanged rather than half updated.
All the values of the target are checked before any is pushed,
so an error leaves the model unchanged rather than half updated.
A component with several targets pushes them one at a time, so an
error in a later target leaves the earlier targets' objects
updated. The calculation stops with the error, and the next one
pushes every value again.

Parameters
----------
refinable_obj : pyobjcryst.refinableobj.RefinableObj
The pyobjcryst model to update.
target : str, optional
The target whose Parameters are pushed (default "default"), as
given to ``create_parameters_from``. Only that target's values
are checked and pushed.

Raises
------
ValueError
If a Parameter value is outside the limits of its
``RefinablePar``.
If the component has no Parameters for the target, or a
Parameter value is outside the limits of its ``RefinablePar``.
"""
values = {
parameter_name: self.get(parameter_name).value
for parameter_name in self._refinable_par_names
}
refinable_pars = {
parameter_name: refinable_obj.GetPar(refinable_par_name)
for parameter_name, refinable_par_name in (
self._refinable_par_names.items()
if target not in self._refinable_par_names:
raise ValueError(
f"The component '{self.name}' has no Parameters for the "
f"target '{target}'. Please use one of its targets: "
f"{', '.join(self._refinable_par_names)}."
)
}
refinable_par_names = self._refinable_par_names[target]
values = {}
refinable_pars = {}
for parameter_name, refinable_par_name in refinable_par_names.items():
values[parameter_name] = self.get(parameter_name).value
refinable_par = refinable_obj.GetPar(refinable_par_name)
refinable_pars[parameter_name] = refinable_par
for parameter_name, refinable_par in refinable_pars.items():
if not refinable_par.IsLimited():
continue
Expand Down
Loading
Loading