Skip to content
Open
4 changes: 2 additions & 2 deletions .github/workflows/code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ jobs:
- name: Cached Python ${{ matrix.python-version }} environment
uses: mamba-org/setup-micromamba@v3
with:
cache-environment: true
cache-environment-key: env-key-${{ matrix.python-version }}
cache-environment: ${{ matrix.experimental != true }}
cache-environment-key: ${{ matrix.experimental != true && format('env-key-{0}', matrix.python-version) || '' }}
condarc: |
channels:
- conda-forge
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ docs/source/_build/
# local developer files
dev_*
.dev/
.nogit_*

# RE.md saved by local bluesky session
.re_md_dict.yml
Expand Down
5 changes: 5 additions & 0 deletions RELEASE_NOTES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ describe future plans.

Release expected 2026-Q4.

Fixes
-----

* Preserve full precision during forward-solution constraint checks. (:issue:`427`)

1.0.0
#####

Expand Down
16 changes: 8 additions & 8 deletions docs/source/guides/how_forward_solution.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@ Two pickers are provided:

* - Function
- Behavior
* - :func:`~hklpy2.misc.pick_first_solution` *(default)*
* - :func:`~hklpy2.utils.pick_first_solution` *(default)*
- Returns the first solution in the list as supplied by the solver.
* - :func:`~hklpy2.misc.pick_closest_solution`
* - :func:`~hklpy2.utils.pick_closest_solution`
- Returns the solution whose real-axis values are closest (RMS distance)
to the current motor positions.

The default is :func:`~hklpy2.misc.pick_first_solution`.
The default is :func:`~hklpy2.utils.pick_first_solution`.

Check the current picker
-------------------------
Expand All @@ -97,7 +97,7 @@ Switch to pick_closest_solution at runtime

Assign directly to :attr:`~hklpy2.diffract.DiffractometerBase._forward_solution`::

>>> from hklpy2.misc import pick_closest_solution
>>> from hklpy2.utils import pick_closest_solution
>>> e4cv._forward_solution = pick_closest_solution

Switch back::
Expand All @@ -114,13 +114,13 @@ Pass ``forward_solution_function`` as a dotted name string to
>>> import hklpy2
>>> e4cv = hklpy2.creator(
... name="e4cv",
... forward_solution_function="hklpy2.misc.pick_closest_solution",
... forward_solution_function="hklpy2.utils.pick_closest_solution",
... )

Or pass a callable directly to
:class:`~hklpy2.diffract.DiffractometerBase.__init__` when subclassing::

>>> from hklpy2.misc import pick_closest_solution
>>> from hklpy2.utils import pick_closest_solution
>>> e4cv = MyDiffractometerClass(
... "",
... name="e4cv",
Expand Down Expand Up @@ -171,8 +171,8 @@ All pickers must follow this interface:

.. seealso::

:func:`~hklpy2.misc.pick_first_solution`,
:func:`~hklpy2.misc.pick_closest_solution` — built-in pickers.
:func:`~hklpy2.utils.pick_first_solution`,
:func:`~hklpy2.utils.pick_closest_solution` — built-in pickers.

:attr:`~hklpy2.diffract.DiffractometerBase._forward_solution` — the
attribute that holds the active picker.
Expand Down
15 changes: 10 additions & 5 deletions src/hklpy2/backends/hkl_soleil.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,15 @@ def extras(self, values: NamedFloatDict) -> None:
self.engine.parameter_set(k, p)

def forward(self, pseudos: NamedFloatDict) -> list[NamedFloatDict]:
"""Compute list of solutions(reals) from pseudos (hkl -> [angles])."""
"""
Compute list of solutions(reals) from pseudos (hkl -> [angles]).

The underlying geometry engine returns a bounded set of equivalent
solutions, ordered using the current real-axis geometry. The returned
order can therefore change when ``set_reals()`` is given a different
starting position. This method preserves that order making no
assumption about the selection of a specific solution from the set.
"""
from gi.repository import GLib # W0611

logger.debug("(%r) forward(%r)", __name__, pseudos)
Expand All @@ -462,10 +470,7 @@ def forward(self, pseudos: NamedFloatDict) -> list[NamedFloatDict]:
for glist_item in raw:
geo = glist_item.geometry_get()
sol = dict(
zip(
geo.axis_names_get(),
roundoff_list(geo.axis_values_get(LIBHKL_USER_UNITS)),
)
zip(geo.axis_names_get(), geo.axis_values_get(LIBHKL_USER_UNITS))
)
solutions.append(sol)
return solutions
Expand Down
163 changes: 163 additions & 0 deletions src/hklpy2/backends/tests/test_hkl_soleil.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,169 @@ def test_hkl_soleil():
hkl_soleil.to_hkl([1, 2])


APS_POLAR_R110 = {
"name": "543d0a3",
"pseudos": {"h": 1.0, "k": 1.0, "l": 0.0},
"reals": {
"tau": 0.0,
"mu": 9.89806867221,
"chi": 113.105327243248,
"phi": 0.00173136532,
"gamma": 9.42401385126,
"delta": -0.00013200263,
},
"wavelength": 0.6199478461064903,
}
APS_POLAR_R004 = {
"name": "88e3c3d",
"pseudos": {"h": 0.0, "k": 0.0, "l": 4.0},
"reals": {
"tau": 0.0,
"mu": 10.848157453245,
"chi": 30.583556860225,
"phi": 0.00038517158,
"gamma": 10.38248391045,
"delta": -0.00011434739,
},
"wavelength": 0.6199478461064903,
}


@pytest.mark.parametrize(
"parms, context",
[
pytest.param(
{
"reals": {
"tau": 0.0,
"mu": 9.89806867221,
"chi": 113.105327243248,
"phi": 0.00173136532,
"gamma": 9.42401385126,
"delta": 0.0,
},
"hkl": {"h": 1.0, "k": 1.0, "l": 0.0},
"expected": [
{
"tau": 0.0,
"mu": 9.8670,
"chi": 113.1061,
"phi": 0.0017,
"gamma": 9.3620,
"delta": 0.0,
},
{
"tau": 0.0,
"mu": -9.8670,
"chi": -66.8939,
"phi": 0.0017,
"gamma": -9.3620,
"delta": 0.0,
},
],
},
does_not_raise(),
id="reported-110-position",
),
pytest.param(
{
"reals": {
"tau": 0.0,
"mu": 9.89806867221,
"chi": 31.0,
"phi": 0.00173136532,
"gamma": 9.42401385126,
"delta": 0.0,
},
"hkl": {"h": 1.0, "k": 1.0, "l": 0.0},
"expected": [
{
"tau": 0.0,
"mu": 179.4950,
"chi": -66.8939,
"phi": 0.0017,
"gamma": 9.3620,
"delta": 0.0,
},
{
"tau": 0.0,
"mu": -179.4950,
"chi": 113.1061,
"phi": 0.0017,
"gamma": -9.3620,
"delta": 0.0,
},
],
},
does_not_raise(),
id="reported-110-axes-with-chi-31",
),
pytest.param(
{
"reals": {
"tau": 0.0,
"mu": 9.89806867221,
"chi": 125.0,
"phi": 0.00173136532,
"gamma": 9.42401385126,
"delta": 0.0,
},
"hkl": {"h": 0.0, "k": 0.0, "l": 6.0},
"expected": [
{
"tau": 0.0,
"mu": 177.9117,
"chi": 30.5462,
"phi": 0.0017,
"gamma": -15.4822,
"delta": 0.0,
},
{
"tau": 0.0,
"mu": -177.9117,
"chi": -149.4538,
"phi": 0.0017,
"gamma": 15.4822,
"delta": 0.0,
},
],
},
does_not_raise(),
id="reported-110-axes-with-chi-125-for-006",
),
],
)
def test_aps_polar_forward_depends_on_start_position(parms, context):
"""The backend returns two solutions with position-dependent ordering."""
with context:
solver = hkl_soleil.HklSolver("APS POLAR", engine="hkl")
solver.mode = "4-circles constant phi horizontal"
solver.wavelength = 0.6199478461064903
solver.sample = {
"name": "pr4310-exafs-cube1",
"lattice": {
"a": 5.38,
"b": 5.467,
"c": 14.066,
"alpha": 90.0,
"beta": 101.0,
"gamma": 90.0,
},
"order": [],
"reflections": [],
}
solver.calculate_UB(APS_POLAR_R110, APS_POLAR_R004)
solver.set_reals(parms["reals"])
solutions = solver.forward(parms.get("hkl", {"h": 1.0, "k": 1.0, "l": 0.0}))

assert len(solutions) == len(parms["expected"])
for actual, expected in zip(solutions, parms["expected"]):
assert actual.keys() == expected.keys()
np.testing.assert_allclose(
list(actual.values()), list(expected.values()), atol=0.001
)


def test_HklSolver():
solver = hkl_soleil.HklSolver(geometry="E4CV", engine="hkl")
assert solver.wavelength == 1.54
Expand Down
7 changes: 6 additions & 1 deletion src/hklpy2/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from .utils import _SolverDirty
from .utils import axes_to_dict
from .utils import convert_units
from .utils import roundoff
from .utils import unique_name

__all__ = ["Core"]
Expand Down Expand Up @@ -727,7 +728,11 @@ def forward(self, pseudos: AnyAxesType, wavelength: float | None = None) -> list
reals[name] = constraint.apply_cut(reals[name])

if self.constraints.valid(**reals):
solutions.append(self.diffractometer.RealPosition(**reals))
user_reals = {
axis: roundoff(value, self.diffractometer.digits)
for axis, value in reals.items()
}
solutions.append(self.diffractometer.RealPosition(**user_reals))
else:
# Log which constraint(s) rejected this solution.
for name, constraint in self.constraints.items():
Expand Down
Loading
Loading