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
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ issue tracker. The initial project development roadmap is documented here:
- Rotation composition and `ub_identity` corrected. (#280)
- B matrix for non-cubic lattices now matches BL1967. (#280)

### Added

- `ad_hoc_diffractometer.reference.natural_psi(g, h, k, l)`. (#278)

### Changed

- `forward()` warns when a `fixed_psi*` mode target ψ is unreachable. (#278)

### Fixed

- Doc version-switcher dropdown now shows every published version. (#273)
Expand Down
34 changes: 32 additions & 2 deletions src/ad_hoc_diffractometer/forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -1739,11 +1739,18 @@ def _solve_psi_mode(
1. Computes the natural ψ from ``Q_phi`` (no motor angles).
2. Compares with ``psi_target`` from the mode's
:class:`~mode.ReferenceConstraint`.
3. If they disagree (beyond 0.1° tolerance): returns ``[]``.
4. If they agree: delegates to the appropriate existing solver
3. If ψ is undefined for the reflection (Q ∥ azimuthal reference, or
Q ∥ incident beam): emits a :class:`UserWarning` and returns ``[]``.
4. If natural ψ and target disagree beyond 0.1°: emits a
:class:`UserWarning` naming the natural ψ value and returns ``[]``.
5. If they agree: delegates to the appropriate existing solver
(bisecting, kappa-virtual, or synthetic bisecting) and returns
all solutions.

The warnings (added in issue #278) make the "ψ is fixed by UB and
hkl, not by motors" semantics visible to callers who previously saw
only a silent empty list.

Parameters
----------
geometry : AdHocDiffractometer
Expand All @@ -1755,6 +1762,8 @@ def _solve_psi_mode(
-------
list of dict[str, float]
"""
import warnings

from .mode import BisectConstraint
from .mode import ConstraintSet
from .mode import ReferenceConstraint
Expand All @@ -1770,6 +1779,15 @@ def _solve_psi_mode(
# Compute natural psi from the phi frame (motor-angle independent)
natural_psi = _compute_natural_psi(geometry, Q_phi)
if natural_psi is None:
warnings.warn(
f"forward(): ψ is undefined for this reflection in geometry "
f"{geometry.name!r} — Q is parallel to "
f"azimuthal_reference={geometry.azimuthal_reference} (or to the "
f"incident beam). Choose a different reflection or change "
f"geometry.azimuthal_reference. Returning [].",
UserWarning,
stacklevel=5,
)
return [] # ψ undefined for this reflection

# Compare natural psi with target (tolerance 0.1° — generous enough
Expand All @@ -1779,6 +1797,18 @@ def _solve_psi_mode(
if diff > 180.0:
diff = 360.0 - diff
if diff > 0.1:
warnings.warn(
f"forward(): mode {geometry.mode_name!r} targets ψ = "
f"{psi_target:.4f}° but the natural ψ for this reflection is "
f"{natural_psi:.4f}°. ψ is fixed by UB and (h, k, l); no motor "
f"configuration can change it for a given reflection. Either "
f"set the constraint value to {natural_psi:.4f}° (or use "
f"ad_hoc_diffractometer.reference.natural_psi(g, h, k, l) to "
f"discover the natural value), or pick a different reflection. "
f"Returning [].",
UserWarning,
stacklevel=5,
)
return [] # this (h,k,l) is not accessible at the stored ψ

# ψ is satisfied — delegate to the appropriate existing solver.
Expand Down
5 changes: 5 additions & 0 deletions src/ad_hoc_diffractometer/geometries/fourch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ modes:
- {type: sample, stage: omega, value: 0.0}
computed: [chi, phi, ttheta]

# psi is a **validation filter** (issue #176, #278): ψ is uniquely
# determined by (h, k, l) and UB. The mode returns solutions only
# when the constraint value equals the natural ψ for the requested
# reflection. Use ahd.reference.natural_psi(g, h, k, l) to discover
# the natural value; forward() emits a UserWarning when it mismatches.
fixed_psi:
constraints:
- {type: reference, name: psi, value: 0.0}
Expand Down
5 changes: 5 additions & 0 deletions src/ad_hoc_diffractometer/geometries/fourcv.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ modes:
- {type: sample, stage: omega, value: 0.0}
computed: [chi, phi, ttheta]

# psi is a **validation filter** (issue #176, #278): ψ is uniquely
# determined by (h, k, l) and UB. The mode returns solutions only
# when the constraint value equals the natural ψ for the requested
# reflection. Use ahd.reference.natural_psi(g, h, k, l) to discover
# the natural value; forward() emits a UserWarning when it mismatches.
fixed_psi:
constraints:
- {type: reference, name: psi, value: 0.0}
Expand Down
5 changes: 5 additions & 0 deletions src/ad_hoc_diffractometer/geometries/kappa4ch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ modes:
- {type: sample, stage: phi, value: 0.0}
computed: [komega, kappa, kphi, ttheta]

# psi is a **validation filter** (issue #176, #278): ψ is uniquely
# determined by (h, k, l) and UB. The mode returns solutions only
# when the constraint value equals the natural ψ for the requested
# reflection. Use ahd.reference.natural_psi(g, h, k, l) to discover
# the natural value; forward() emits a UserWarning when it mismatches.
fixed_psi:
constraints:
- {type: reference, name: psi, value: 0.0}
Expand Down
5 changes: 5 additions & 0 deletions src/ad_hoc_diffractometer/geometries/kappa4cv.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ modes:
- {type: sample, stage: phi, value: 0.0}
computed: [komega, kappa, kphi, ttheta]

# psi is a **validation filter** (issue #176, #278): ψ is uniquely
# determined by (h, k, l) and UB. The mode returns solutions only
# when the constraint value equals the natural ψ for the requested
# reflection. Use ahd.reference.natural_psi(g, h, k, l) to discover
# the natural value; forward() emits a UserWarning when it mismatches.
fixed_psi:
constraints:
- {type: reference, name: psi, value: 0.0}
Expand Down
9 changes: 9 additions & 0 deletions src/ad_hoc_diffractometer/geometries/kappa6c.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ modes:
- {type: detector, stage: qaz, value: 90.0}
computed: [kphi, nu, delta]

# The psi ReferenceConstraint in both fixed_psi_* modes is a
# **validation filter** (issue #176): ψ is uniquely determined by
# (h, k, l) and UB — every Bragg solution of a given reflection
# has the same ψ — so the mode returns solutions only when the
# constraint's psi value equals the natural ψ for the requested
# reflection. Use ad_hoc_diffractometer.reference.natural_psi(
# g, h, k, l) to discover the natural value; the forward solver
# also emits a UserWarning naming it when the target is wrong
# (issue #278).
fixed_psi_vertical:
constraints:
- {type: virtual_bisect, stage1: omega, stage2: delta}
Expand Down
27 changes: 20 additions & 7 deletions src/ad_hoc_diffractometer/geometries/psic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,17 @@ modes:
beta_out: null

# Per @jwkim-anl, issue #264: drop the bisect(eta, delta) constraint
# entirely. "vertical" means nu = 0 (detector pin); mu and psi are
# fixed at user-specified values (defaults 0). Free: eta, chi,
# phi, delta — at any given (mu, psi) target a combination of the
# free angles satisfies Bragg under the validated psi constraint.
# entirely. "vertical" means nu = 0 (detector pin); mu is pinned
# at a user-settable value (default 0). Free: eta, chi, phi, delta.
# The psi ReferenceConstraint is a **validation filter** (issue
# #176), NOT a user-settable target in the same sense: ψ is a pure
# phi-frame quantity, uniquely determined by (h, k, l) and UB —
# every Bragg solution of the reflection has the same ψ. The mode
# therefore returns solutions only when the constraint's psi value
# equals the natural ψ for the requested reflection. Use
# ad_hoc_diffractometer.reference.natural_psi(g, h, k, l) to
# discover the natural value; the forward solver also emits a
# UserWarning naming it when the target is wrong (issue #278).
fixed_psi_vertical:
constraints:
- {type: detector, stage: nu, value: 0.0}
Expand Down Expand Up @@ -266,9 +273,15 @@ modes:
beta_out: null

# Per @jwkim-anl, issue #264: drop the bisect(mu, nu) constraint
# entirely. "horizontal" means delta = 0 (detector pin); eta and
# psi are fixed at user-specified values (defaults 0). Free: mu,
# chi, phi, nu.
# entirely. "horizontal" means delta = 0 (detector pin); eta is
# pinned at a user-settable value (default 0). Free: mu, chi,
# phi, nu. As with fixed_psi_vertical above, the psi
# ReferenceConstraint is a **validation filter** (not a
# user-settable target in the same sense): ψ is uniquely determined
# by (h, k, l) and UB, so the mode returns solutions only when the
# constraint's psi value equals the natural ψ for the requested
# reflection. See the fixed_psi_vertical comment and issue #278
# for the rationale and the UserWarning behavior.
fixed_psi_horizontal:
constraints:
- {type: detector, stage: delta, value: 0.0}
Expand Down
71 changes: 71 additions & 0 deletions src/ad_hoc_diffractometer/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
:func:`psi_angle`
Azimuthal angle ψ of the reference vector n̂ about Q (You 1999, eq. 23).

:func:`natural_psi`
Natural azimuthal angle ψ for a reflection, computed from UB and
(h, k, l) without any motor angles. Equals :func:`psi_angle` at
every Bragg solution of the same reflection (issue #176).

:func:`naz_angle`
Azimuthal angle of n̂ projected onto the lab-frame horizontal plane.

Expand Down Expand Up @@ -206,6 +211,72 @@ def psi_angle(
return geometry.psi(angles=angles)


def natural_psi(
geometry: AdHocDiffractometer,
h: float,
k: float,
l: float, # noqa: E741
) -> float | None:
"""
Compute the natural azimuthal angle ψ for a reflection from UB alone.

The azimuthal angle ψ is a pure phi-frame quantity: for a fixed
crystal orientation (UB matrix) and a fixed reflection (h, k, l),
ψ depends only on ``Q_phi = UB @ (h, k, l)`` and the azimuthal
reference vector ``n_phi = UB @ azimuthal_reference``. **No motor
angles enter the calculation** — every motor configuration that
brings (h, k, l) into Bragg condition produces the *same* ψ.

This invariance is the core fact behind the ψ-validation-filter
forward-solver model: an :class:`~mode.ReferenceConstraint`
``"psi"`` target is reachable for a given hkl if and only if it
equals this natural value. Use ``natural_psi`` to ask "what ψ
must I request to make this reflection reachable?" before calling
:meth:`~diffractometer.AdHocDiffractometer.forward`.

Requires :attr:`~geometry.AdHocDiffractometer.azimuthal_reference`
and :attr:`~sample.Sample.UB` to be set on the geometry.

Parameters
----------
geometry : AdHocDiffractometer
The diffractometer instance. ``geometry.sample.UB`` and
``geometry.azimuthal_reference`` must be set.
h, k, l : float
Miller indices of the reflection.

Returns
-------
float or None
Natural ψ in degrees, in the range (−180°, +180°]. Returns
``None`` when ψ is undefined for the reflection — that is,
when ``Q_phi`` is parallel to the azimuthal reference (any ψ
rotation about Q leaves the reference unchanged) or when
``Q_phi`` is parallel to the incident beam direction.

Raises
------
ValueError
If ``geometry.azimuthal_reference`` is ``None``.

See Also
--------
psi_angle : ψ computed from current motor angles (always equals
``natural_psi`` for any Bragg solution of the same (h, k, l)).

References
----------
* You (1999), eq. 23.
"""
_require_azimuthal_reference(geometry)
# Local import to avoid module-load ordering issues with forward.py.
from .forward import _compute_natural_psi

hkl = np.array([float(h), float(k), float(l)], dtype=float)
Q_phi = geometry.sample.UB @ hkl
return _compute_natural_psi(geometry, Q_phi)


def naz_angle(
geometry: AdHocDiffractometer,
angles: dict[str, float] | None = None,
Expand Down
94 changes: 92 additions & 2 deletions tests/test_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"""

import re
from contextlib import nullcontext as does_not_raise

import pytest
from helpers import psic
Expand All @@ -27,6 +28,7 @@
from ad_hoc_diffractometer import ub_identity
from ad_hoc_diffractometer.reference import exit_angle
from ad_hoc_diffractometer.reference import incidence_angle
from ad_hoc_diffractometer.reference import natural_psi
from ad_hoc_diffractometer.reference import naz_angle
from ad_hoc_diffractometer.reference import psi_angle

Expand Down Expand Up @@ -558,8 +560,6 @@ def test_surface_mode_not_implemented_raises():
# ---------------------------------------------------------------------------


from contextlib import nullcontext as does_not_raise # noqa: E402

from ad_hoc_diffractometer.reference import omega_pseudo # noqa: E402


Expand Down Expand Up @@ -718,3 +718,93 @@ def test_reference_constraint_omega_serialization_round_trip():
assert d["value"] == 12.5
rc2 = ReferenceConstraint.from_dict(d)
assert rc == rc2


# ---------------------------------------------------------------------------
# natural_psi — added by issue #278 to expose the ψ-validation target
# ---------------------------------------------------------------------------


def test_natural_psi_raises_without_azimuthal_reference():
"""natural_psi raises ValueError when azimuthal_reference is None."""
g = _setup_psic()
# azimuthal_reference defaults to None
with pytest.raises(
ValueError,
match=re.escape("azimuthal_reference must be set"),
):
natural_psi(g, 1, 0, 0)


@pytest.mark.parametrize(
"h, k, l, expected, context",
[
# On psic / cubic / ub_identity / azimuthal=(0,0,1):
# natural ψ is determined entirely by Q_phi = UB @ hkl and the
# beam direction (longitudinal axis). The expected values were
# produced by an independent run of _compute_natural_psi and
# are stable across the ψ-validation-filter design (issue #176).
pytest.param(0, 1, 0, 90.0, does_not_raise(), id="010-psi-90"),
pytest.param(1, 1, 0, 90.0, does_not_raise(), id="110-psi-90"),
pytest.param(0, 1, 1, 90.0, does_not_raise(), id="011-psi-90"),
pytest.param(1, 0, 1, 180.0, does_not_raise(), id="101-psi-180"),
pytest.param(1, 1, 1, 120.0, does_not_raise(), id="111-psi-120"),
],
)
def test_natural_psi_matches_expected_values(h, k, l, expected, context): # noqa: E741
"""natural_psi returns the expected motor-angle-independent ψ value."""
with context:
g = _setup_psic()
g.azimuthal_reference = (0, 0, 1)
result = natural_psi(g, h, k, l)
assert result == pytest.approx(expected, abs=1e-6)


@pytest.mark.parametrize(
"h, k, l, context",
[
# (1,0,0) and (0,0,1) make Q_phi parallel to the azimuthal
# reference / beam direction respectively, so ψ is undefined.
pytest.param(1, 0, 0, does_not_raise(), id="100-undef"),
pytest.param(0, 0, 1, does_not_raise(), id="001-undef"),
],
)
def test_natural_psi_returns_none_when_undefined(h, k, l, context): # noqa: E741
"""natural_psi returns None when ψ is undefined for the reflection.

ψ is undefined when ``Q_phi`` is parallel to the azimuthal
reference or to the incident beam — there is no in-plane direction
relative to which to measure the azimuth.
"""
with context:
g = _setup_psic()
g.azimuthal_reference = (0, 0, 1)
assert natural_psi(g, h, k, l) is None


def test_natural_psi_equals_psi_angle_at_bisecting_solution():
"""natural_psi equals psi_angle(motors) at every Bragg solution.

The central physical claim behind the ψ-validation-filter model
(issue #176): for fixed (UB, hkl), every motor configuration that
satisfies Bragg gives the same ψ.
"""
g = _setup_psic()
g.azimuthal_reference = (0, 0, 1)
g.mode_name = "bisecting_vertical"
sols = g.forward(1, 1, 0)
assert sols, "bisecting_vertical should return at least one solution for (1,1,0)"
nat = natural_psi(g, 1, 1, 0)
for sol in sols:
assert psi_angle(g, angles=sol) == pytest.approx(nat, abs=1e-6)


def test_natural_psi_independent_of_motor_state():
"""natural_psi depends only on UB and (h, k, l), not on stage angles."""
g = _setup_psic()
g.azimuthal_reference = (0, 0, 1)
baseline = natural_psi(g, 1, 1, 0)
# Move every stage to an arbitrary non-zero angle.
for stage in g._stages.values(): # noqa: SLF001
stage.angle = 17.5
assert natural_psi(g, 1, 1, 0) == pytest.approx(baseline, abs=1e-12)
Loading