From aa616ce1b519af8b26249e01c3f0f04071630528 Mon Sep 17 00:00:00 2001 From: Pete Jemian Date: Tue, 19 May 2026 16:41:31 -0500 Subject: [PATCH] fix(#278) warn when fixed_psi_* target is unreachable; add reference.natural_psi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixed_psi_* modes silently returned [] when the user-specified ψ target did not match the natural ψ for the requested reflection, leaving callers without a clue why their reflection was unreachable. Under the ψ-validation-filter design (issue #176), ψ is uniquely determined by Q_phi = UB @ (h, k, l) and the azimuthal reference — every Bragg solution of a reflection has the same ψ. No motor configuration can change it. Returning [] on a mismatched target is mathematically correct, but the silence was unhelpful. Changes: - _solve_psi_mode now emits a UserWarning that names the natural ψ and points the user at ad_hoc_diffractometer.reference.natural_psi when the target mismatches; a separate warning fires when ψ is undefined (Q parallel to reference or beam). - New public helper reference.natural_psi(geometry, h, k, l) returns the natural ψ from UB and hkl alone (None when undefined). - YAML comments above every fixed_psi* mode in psic, kappa6c, fourcv, fourch, kappa4cv, and kappa4ch clarify the validation- filter semantics. Contributed by: OpenCode (argo/claudeopus47) --- CHANGES.md | 8 + src/ad_hoc_diffractometer/forward.py | 34 ++- .../geometries/fourch.yml | 5 + .../geometries/fourcv.yml | 5 + .../geometries/kappa4ch.yml | 5 + .../geometries/kappa4cv.yml | 5 + .../geometries/kappa6c.yml | 9 + src/ad_hoc_diffractometer/geometries/psic.yml | 27 +- src/ad_hoc_diffractometer/reference.py | 71 +++++ tests/test_reference.py | 94 ++++++- tests/test_regression_issue_278.py | 264 ++++++++++++++++++ 11 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 tests/test_regression_issue_278.py diff --git a/CHANGES.md b/CHANGES.md index a8a78953..0c5927a1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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) diff --git a/src/ad_hoc_diffractometer/forward.py b/src/ad_hoc_diffractometer/forward.py index 1c8fefdc..8d4ec29f 100644 --- a/src/ad_hoc_diffractometer/forward.py +++ b/src/ad_hoc_diffractometer/forward.py @@ -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 @@ -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 @@ -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 @@ -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. diff --git a/src/ad_hoc_diffractometer/geometries/fourch.yml b/src/ad_hoc_diffractometer/geometries/fourch.yml index df924f5a..e708568c 100644 --- a/src/ad_hoc_diffractometer/geometries/fourch.yml +++ b/src/ad_hoc_diffractometer/geometries/fourch.yml @@ -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} diff --git a/src/ad_hoc_diffractometer/geometries/fourcv.yml b/src/ad_hoc_diffractometer/geometries/fourcv.yml index 500a55f0..0a0f7bac 100644 --- a/src/ad_hoc_diffractometer/geometries/fourcv.yml +++ b/src/ad_hoc_diffractometer/geometries/fourcv.yml @@ -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} diff --git a/src/ad_hoc_diffractometer/geometries/kappa4ch.yml b/src/ad_hoc_diffractometer/geometries/kappa4ch.yml index 62353991..1a164030 100644 --- a/src/ad_hoc_diffractometer/geometries/kappa4ch.yml +++ b/src/ad_hoc_diffractometer/geometries/kappa4ch.yml @@ -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} diff --git a/src/ad_hoc_diffractometer/geometries/kappa4cv.yml b/src/ad_hoc_diffractometer/geometries/kappa4cv.yml index 5ea83217..3b8033ff 100644 --- a/src/ad_hoc_diffractometer/geometries/kappa4cv.yml +++ b/src/ad_hoc_diffractometer/geometries/kappa4cv.yml @@ -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} diff --git a/src/ad_hoc_diffractometer/geometries/kappa6c.yml b/src/ad_hoc_diffractometer/geometries/kappa6c.yml index 04005a6e..463fd39b 100644 --- a/src/ad_hoc_diffractometer/geometries/kappa6c.yml +++ b/src/ad_hoc_diffractometer/geometries/kappa6c.yml @@ -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} diff --git a/src/ad_hoc_diffractometer/geometries/psic.yml b/src/ad_hoc_diffractometer/geometries/psic.yml index 17fad1df..60576396 100644 --- a/src/ad_hoc_diffractometer/geometries/psic.yml +++ b/src/ad_hoc_diffractometer/geometries/psic.yml @@ -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} @@ -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} diff --git a/src/ad_hoc_diffractometer/reference.py b/src/ad_hoc_diffractometer/reference.py index e90e95a2..b0c3a706 100644 --- a/src/ad_hoc_diffractometer/reference.py +++ b/src/ad_hoc_diffractometer/reference.py @@ -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. @@ -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, diff --git a/tests/test_reference.py b/tests/test_reference.py index 735607cf..551c5815 100644 --- a/tests/test_reference.py +++ b/tests/test_reference.py @@ -14,6 +14,7 @@ """ import re +from contextlib import nullcontext as does_not_raise import pytest from helpers import psic @@ -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 @@ -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 @@ -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) diff --git a/tests/test_regression_issue_278.py b/tests/test_regression_issue_278.py new file mode 100644 index 00000000..0af92705 --- /dev/null +++ b/tests/test_regression_issue_278.py @@ -0,0 +1,264 @@ +# Copyright (c) 2026 UChicago Argonne, LLC +# SPDX-License-Identifier: LicenseRef-ANL-Open-Source-License +""" +Regression tests for issue #278. + +Issue #278 surfaced confusion around the ψ-validation-filter model +(issue #176): when ``forward()`` is called on a ``fixed_psi_*`` mode +with a target ψ that does not match the natural ψ for the requested +(h, k, l), the solver returned a silent empty list — which looked +like a bug to callers expecting a "set ψ and rock the sample to +achieve it" semantics. + +In fact ψ is uniquely determined by ``Q_phi = UB @ (h, k, l)`` and +the azimuthal reference vector: every Bragg solution of a fixed +reflection has the same ψ. No motor configuration can change it. +The empty return is therefore mathematically correct, but the lack +of feedback made the cause non-obvious. + +The fix in this PR: + +1. Emits a :class:`UserWarning` from + :func:`ad_hoc_diffractometer.forward._solve_psi_mode` whenever the + target ψ does not match the natural ψ (or ψ is undefined). The + message names the natural value and points the user at + :func:`~ad_hoc_diffractometer.reference.natural_psi`. + +2. Adds the public helper + :func:`~ad_hoc_diffractometer.reference.natural_psi(g, h, k, l)` + so callers can discover the natural value programmatically before + calling :meth:`~diffractometer.AdHocDiffractometer.forward`. + +3. Clarifies the validation-filter semantics in the YAML comments + above every ``fixed_psi*`` mode in the demo geometries. + +These tests verify the cross-module contracts: + +- ``forward()`` on a ψ-mode with the *natural* ψ returns solutions + (this is the path the user wants to take after the warning tells + them the correct value). +- ``forward()`` on a ψ-mode with a *mismatched* ψ emits the warning + and returns ``[]``. +- ``forward()`` on a reflection with undefined ψ (Q ∥ reference) + emits the "undefined" warning and returns ``[]``. +- The :func:`reference.natural_psi` helper agrees with the warning + message and with the psi observed in a real Bragg solution. +""" + +from __future__ import annotations + +import re +import warnings +from contextlib import nullcontext as does_not_raise + +import pytest +from helpers import psic + +import ad_hoc_diffractometer as ahd +from ad_hoc_diffractometer import ub_identity +from ad_hoc_diffractometer.reference import natural_psi +from ad_hoc_diffractometer.reference import psi_angle + +WAVELENGTH = 1.5406 # Cu Kα + + +def _setup_psic_cubic(): + g = psic() + g.wavelength = WAVELENGTH + g.sample.lattice = ahd.Lattice(a=4.0) + ub_identity(g.sample) + g.azimuthal_reference = (0, 0, 1) + g.surface_normal = (0, 0, 1) + return g + + +def _set_psi_constraint(mode, value: float) -> None: + """Mutate the mode's psi ReferenceConstraint value in place.""" + for c in mode.constraints: + if c.__class__.__name__ == "ReferenceConstraint" and c.name == "psi": + c._value = float(value) # noqa: SLF001 + return + raise AssertionError("mode has no psi ReferenceConstraint") + + +# --------------------------------------------------------------------------- +# Mismatched-target path emits warning and returns []. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "mode_name, h, k, l, context", + [ + pytest.param( + "fixed_psi_horizontal", + 1, + 1, + 0, + does_not_raise(), + id="horizontal-110", + ), + pytest.param( + "fixed_psi_vertical", + 1, + 1, + 0, + does_not_raise(), + id="vertical-110", + ), + ], +) +def test_fixed_psi_mismatch_warns_and_returns_empty( + mode_name, + h, + k, + l, # noqa: E741 + context, +): + """When the constraint ψ does not match the natural ψ for the + reflection, ``forward()`` warns the user (naming the natural value + and the recommended helper) and returns ``[]``. + """ + with context: + g = _setup_psic_cubic() + g.mode_name = mode_name + # Default constraint value is 0.0; the natural ψ for (1,1,0) on + # cubic / ub_identity / azimuthal=(0,0,1) is 90°, so the target + # 0° is guaranteed to mismatch. + nat = natural_psi(g, h, k, l) + assert nat is not None + with pytest.warns(UserWarning, match=re.escape(f"{nat:.4f}")): + solutions = g.forward(h, k, l) + assert solutions == [] + + +# --------------------------------------------------------------------------- +# Undefined-ψ path emits warning and returns []. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "mode_name, h, k, l, context", + [ + # (0,0,1) makes Q_phi parallel to the azimuthal reference (0,0,1) + # so ψ is undefined regardless of the mode. + pytest.param( + "fixed_psi_horizontal", + 0, + 0, + 1, + does_not_raise(), + id="horizontal-undef-001", + ), + pytest.param( + "fixed_psi_vertical", + 0, + 0, + 1, + does_not_raise(), + id="vertical-undef-001", + ), + ], +) +def test_fixed_psi_undefined_warns_and_returns_empty( + mode_name, + h, + k, + l, # noqa: E741 + context, +): + """When ψ is undefined for the reflection (Q ∥ reference), ``forward()`` + warns and returns ``[]``. + + The warning message must mention the azimuthal_reference so the + user can choose a different reference direction if desired. + """ + with context: + g = _setup_psic_cubic() + g.mode_name = mode_name + assert natural_psi(g, h, k, l) is None + with pytest.warns(UserWarning, match=re.escape("undefined")): + solutions = g.forward(h, k, l) + assert solutions == [] + + +# --------------------------------------------------------------------------- +# Matching-target path returns solutions and does NOT warn. +# --------------------------------------------------------------------------- + + +def test_fixed_psi_horizontal_with_natural_target_returns_solutions(): + """Setting the constraint ψ to the value returned by ``natural_psi`` + makes the reflection reachable and produces real Bragg solutions. + + This is the recommended user workflow: call ``natural_psi`` to + discover the achievable ψ, set the constraint, then call + ``forward()``. The forward solver must not warn in this case. + """ + g = _setup_psic_cubic() + g.mode_name = "fixed_psi_horizontal" + nat = natural_psi(g, 1, 1, 0) + assert nat is not None + _set_psi_constraint(g.modes["fixed_psi_horizontal"], nat) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) # any UserWarning fails + solutions = g.forward(1, 1, 0) + assert len(solutions) >= 1 + # Every solution must report the same ψ as natural_psi (the central + # claim of issue #176). + for sol in solutions: + assert psi_angle(g, angles=sol) == pytest.approx(nat, abs=1e-6) + + +def test_fixed_psi_vertical_with_natural_target_returns_solutions(): + """Mirror of the horizontal test for the vertical mode.""" + g = _setup_psic_cubic() + g.mode_name = "fixed_psi_vertical" + nat = natural_psi(g, 1, 1, 0) + assert nat is not None + _set_psi_constraint(g.modes["fixed_psi_vertical"], nat) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + solutions = g.forward(1, 1, 0) + assert len(solutions) >= 1 + for sol in solutions: + assert psi_angle(g, angles=sol) == pytest.approx(nat, abs=1e-6) + + +# --------------------------------------------------------------------------- +# Issue #278's exact reproducer: sapphire + horizontal mode. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "h, k, l, context", + [ + # Issue #278 lists these as "forward() returns 0 solutions" — + # the user's own Newton check confirmed they are not reachable + # at ψ = 0. These tests pin that the empty return is now + # accompanied by an informative warning. + pytest.param(0, 0, 3, does_not_raise(), id="003"), + pytest.param(0, 0, 6, does_not_raise(), id="006"), + pytest.param(1, 1, 0, does_not_raise(), id="110"), + pytest.param(0, 1, 2, does_not_raise(), id="012"), + pytest.param(1, 0, 4, does_not_raise(), id="104"), + ], +) +def test_issue_278_sapphire_reproducer_warns(h, k, l, context): # noqa: E741 + """The exact sapphire reproducer from issue #278: every hkl yields a + warning + empty list (correct behavior under the validation-filter + model). + """ + with context: + g = psic() + g.wavelength = 1.0 + g.sample.lattice = ahd.Lattice( + a=4.758, b=4.758, c=12.991, alpha=90, beta=90, gamma=120 + ) + ub_identity(g.sample) + g.azimuthal_reference = (0, 0, 1) + g.surface_normal = (0, 0, 1) + g.mode_name = "fixed_psi_horizontal" + + with pytest.warns(UserWarning): + solutions = g.forward(h, k, l) + assert solutions == []