diff --git a/.gitignore b/.gitignore index af35f093..2d37ba44 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ src/ad_hoc_diffractometer/_version.py docs/build/ docs/_build/ docs/source/autoapi/ -.coverage +.coverage* htmlcov/ .pytest_cache/ __pycache__/ diff --git a/CHANGES.md b/CHANGES.md index fd39439b..9159e247 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,8 +5,17 @@ Issues](https://github.com/BCDA-APS/ad_hoc_diffractometer/issues) for the full issue tracker. The initial project development roadmap is documented here: [roadmap](https://github.com/BCDA-APS/ad_hoc_diffractometer/blob/main/roadmap.md). + + ## Release v0.11.0 Released 2026-05-19 diff --git a/src/ad_hoc_diffractometer/forward.py b/src/ad_hoc_diffractometer/forward.py index 8d4ec29f..07d0f819 100644 --- a/src/ad_hoc_diffractometer/forward.py +++ b/src/ad_hoc_diffractometer/forward.py @@ -730,8 +730,13 @@ def _solve_bisecting_analytic( q_perp = q - q_par * n_chi w_perp_norm = float(np.linalg.norm(w_perp)) q_perp_norm = float(np.linalg.norm(q_perp)) - if w_perp_norm < 1e-12 or q_perp_norm < 1e-12: + if w_perp_norm < 1e-12 or q_perp_norm < 1e-12: # pragma: no cover # Degenerate: w or q is parallel to n_chi; chi indeterminate. + # No shipped geometry / mode / reflection lands here after the + # issue-#284 kappa equivalent-Eulerian chi axis correction; + # retained as a defensive fallback for ad-hoc geometries + # whose target Q_phi happens to project to zero on the + # plane perpendicular to n_chi. chi_d = 0.0 else: # cos chi = (w_perp · q_perp) / (|w_perp| |q_perp|) diff --git a/src/ad_hoc_diffractometer/geometries/__init__.py b/src/ad_hoc_diffractometer/geometries/__init__.py index 0af3b22c..68b05ec5 100644 --- a/src/ad_hoc_diffractometer/geometries/__init__.py +++ b/src/ad_hoc_diffractometer/geometries/__init__.py @@ -101,17 +101,17 @@ ------------------------------ The kappa axis is inclined by ``alpha`` degrees from the omega axis, -lying in the plane that contains both omega and the equivalent-Eulerian -chi axis, and tilted from omega toward that chi direction (Walko 2016 +lying in the plane that contains both omega and the kappa-arm tilt +direction, and tilted from omega toward that tilt direction (Walko 2016 Fig. 3; Wyckoff 1985 Fig. 2(b); Thorkildsen 2006 Table 1; Enraf-Nonius; ITC Vol. C Sec. 2.2.6). -Per geometry (with omega and chi-equivalent shown as physical -basis-direction lines, ignoring sign of handedness): +Per geometry (with omega and kappa-arm tilt direction shown as +physical basis-direction lines, ignoring sign of handedness): -- ``kappa4cv``: omega along transverse, chi-eq along vertical; +- ``kappa4cv``: omega along transverse, tilt-direction along vertical; kappa lies in the T-V plane between +T and +V. -- ``kappa4ch``: omega along vertical, chi-eq along longitudinal; +- ``kappa4ch``: omega along vertical, tilt-direction along longitudinal; kappa lies in the V-L plane between +V and +L. - ``kappa6c``: same as ``kappa4cv`` (mounted on top of ``mu`` and ``nu``). @@ -122,6 +122,23 @@ :class:`~ad_hoc_diffractometer.kappa.KappaPseudoAngleConvention` from the canonical stage names ``komega`` / ``kappa`` / ``kphi``. +Equivalent-Eulerian chi axis vs kappa-arm tilt direction (issue #284) +--------------------------------------------------------------------- + +The kappa-arm tilt direction (``kappa_chi_eq``) and the equivalent- +Eulerian chi pseudo-angle axis (``kappa_eulerian_chi``) are two +distinct concepts. The former defines the kappa stage's geometric +axis (the plane the arm tilts in); the latter defines the axis the +kappa→Eulerian decomposition rotates about for the virtual chi +pseudo-angle. They happen to coincide for ``kappa4ch`` (both +``+longitudinal``) but differ for ``kappa4cv`` and ``kappa6c`` +(tilt direction is ``+vertical``; equivalent-Eulerian chi is +``+longitudinal``, matching ``fourcv`` and ``psic``). When +``kappa_eulerian_chi`` is omitted the loader derives it as the +first basis direction perpendicular to ``n_komega`` in the +conventional order ``(+longitudinal, +vertical, +transverse)`` — +the right answer for every kappa preset shipped with the package. + Handedness convention --------------------- diff --git a/src/ad_hoc_diffractometer/geometries/kappa4ch.yml b/src/ad_hoc_diffractometer/geometries/kappa4ch.yml index 1a164030..d7fd1d94 100644 --- a/src/ad_hoc_diffractometer/geometries/kappa4ch.yml +++ b/src/ad_hoc_diffractometer/geometries/kappa4ch.yml @@ -75,8 +75,12 @@ basis: BL parameters: alpha_deg: 50.0 -# Equivalent-Eulerian chi axis: +longitudinal for kappa4ch (the kappa -# arm tilts in the vertical-longitudinal plane). +# Kappa-arm tilt direction: +longitudinal for kappa4ch (the kappa arm +# tilts in the vertical-longitudinal plane). For this preset the +# kappa-arm tilt direction happens to coincide with the equivalent- +# Eulerian chi pseudo-angle axis (both +longitudinal), so a single +# ``kappa_chi_eq`` value is sufficient. See kappa4cv.yml for the +# general two-field story (issue #284). kappa_chi_eq: +longitudinal stages: diff --git a/src/ad_hoc_diffractometer/geometries/kappa4cv.yml b/src/ad_hoc_diffractometer/geometries/kappa4cv.yml index 3b8033ff..49b094e0 100644 --- a/src/ad_hoc_diffractometer/geometries/kappa4cv.yml +++ b/src/ad_hoc_diffractometer/geometries/kappa4cv.yml @@ -87,10 +87,19 @@ basis: BL parameters: alpha_deg: 50.0 -# Equivalent-Eulerian chi axis: +vertical for kappa4cv. This (together -# with parameters.alpha_deg and the kappa-stage axis spec below) drives -# the synthesized KappaPseudoAngleConvention attached to the -# constructed geometry. +# Kappa-arm tilt direction: +vertical for kappa4cv. The kappa stage +# axis (computed via Walko's formula in ``_resolve_axis``) lies in the +# plane spanned by the unsigned outer axis (+transverse) and this +# tilt direction (+vertical), tilted ``parameters.alpha_deg`` from the +# former toward the latter. +# +# This is the kappa-arm *geometric* tilt direction. The equivalent- +# Eulerian chi *pseudo-angle* axis (used by the kappa→Eulerian +# decomposition) is a separate convention: see ``kappa_eulerian_chi`` +# in the schema, and issue #284 for the history of why the two fields +# were separated. For kappa4cv the auto-derived value (+longitudinal, +# matching fourcv's chi axis) is correct; no explicit declaration is +# needed here. kappa_chi_eq: +vertical stages: diff --git a/src/ad_hoc_diffractometer/geometries/kappa6c.yml b/src/ad_hoc_diffractometer/geometries/kappa6c.yml index 463fd39b..ce33290c 100644 --- a/src/ad_hoc_diffractometer/geometries/kappa6c.yml +++ b/src/ad_hoc_diffractometer/geometries/kappa6c.yml @@ -107,7 +107,11 @@ basis: YOU parameters: alpha_deg: 50.0 -# Equivalent-Eulerian chi axis: +vertical (same as kappa4cv). +# Kappa-arm tilt direction: +vertical (same as kappa4cv). See the +# kappa4cv.yml header note for the distinction between this kappa-arm +# tilt direction and the equivalent-Eulerian chi pseudo-angle axis +# (the latter is auto-derived as +longitudinal, matching psic's chi +# axis, per issue #284). kappa_chi_eq: +vertical stages: diff --git a/src/ad_hoc_diffractometer/geometries/schema.json b/src/ad_hoc_diffractometer/geometries/schema.json index 53254dcf..d605a188 100644 --- a/src/ad_hoc_diffractometer/geometries/schema.json +++ b/src/ad_hoc_diffractometer/geometries/schema.json @@ -67,7 +67,11 @@ } }, "kappa_chi_eq": { - "description": "Equivalent-Eulerian chi axis. Required when parameters.alpha_deg is declared. Used by axis fields of the form {kappa_eulerian: ...}.", + "description": "Kappa-arm tilt direction. Lies in the plane spanned by n_komega and n_kappa (the in-plane perpendicular of n_komega). Used by axis fields of the form {kappa_eulerian: ...} to construct the kappa stage axis via Walko's formula. Distinct from the equivalent-Eulerian chi pseudo-angle axis (see kappa_eulerian_chi). Required when parameters.alpha_deg is declared (unless kappa_eulerian_chi is the only kappa input).", + "$ref": "#/$defs/axis_value" + }, + "kappa_eulerian_chi": { + "description": "Equivalent-Eulerian chi pseudo-angle axis (issue #284). Specifies the axis the kappa-to-Eulerian decomposition rotates about for the virtual chi angle. Should match the corresponding non-kappa Eulerian preset's chi axis (fourcv/fourch/psic all use +longitudinal). When omitted, the loader derives it from the first basis direction perpendicular to n_komega in the order (+longitudinal, +vertical, +transverse), which yields +longitudinal for all standard kappa geometries.", "$ref": "#/$defs/axis_value" }, "stages": { @@ -100,7 +104,7 @@ ] }, "axis_value": { - "description": "A stage axis specification. Three accepted forms: (1) signed physical-direction or Cartesian string ('+transverse', '-vertical', '+x', '-z'); (2) length-3 numeric vector (sign included; the Stage constructor handles normalisation for rotation calculations); (3) kappa-tilt mapping {kappa_eulerian: }, valid only when parameters.alpha_deg and the top-level kappa_chi_eq are both declared.", + "description": "A stage axis specification. Three accepted forms: (1) signed physical-direction or Cartesian string ('+transverse', '-vertical', '+x', '-z'); (2) length-3 numeric vector (sign included; the Stage constructor handles normalization for rotation calculations); (3) kappa-tilt mapping {kappa_eulerian: }, valid only when parameters.alpha_deg and the top-level kappa_chi_eq are both declared.", "oneOf": [ { "type": "string", diff --git a/src/ad_hoc_diffractometer/geometry_loader.py b/src/ad_hoc_diffractometer/geometry_loader.py index 2e143b2d..99862620 100644 --- a/src/ad_hoc_diffractometer/geometry_loader.py +++ b/src/ad_hoc_diffractometer/geometry_loader.py @@ -152,6 +152,7 @@ def get_schema() -> dict: "basis", "parameters", "kappa_chi_eq", + "kappa_eulerian_chi", "stages", "modes", } @@ -450,6 +451,64 @@ def _resolve_axis( ) +def _derive_kappa_eulerian_chi( + n_komega: np.ndarray, + basis: dict[str, np.ndarray], + source_label: str, +) -> np.ndarray: + """Derive the equivalent-Eulerian chi pseudo-angle axis when the + YAML does not declare ``kappa_eulerian_chi`` explicitly (issue + #284). + + Picks the first basis direction perpendicular to ``n_komega`` in + the conventional order ``(+longitudinal, +vertical, +transverse)``. + Every standard Eulerian preset shipped with this package + (fourcv, fourch, psic, sixc, fivec) puts its ``chi`` rotation + about ``+longitudinal``; honoring that order makes the kappa + preset's equivalent-Eulerian decomposition match its sister + Eulerian preset's chi pseudo-angle exactly. + + Parameters + ---------- + n_komega : numpy.ndarray, shape (3,) + Outer kappa stage axis. Need not be normalized. + basis : dict[str, numpy.ndarray] + The geometry's basis dictionary with keys ``vertical``, + ``longitudinal``, ``transverse``. + source_label : str + Label naming the source YAML file or string; used in error + messages. + + Returns + ------- + n_chi_eq : numpy.ndarray, shape (3,) + A unit-magnitude basis direction perpendicular to + ``n_komega``. + + Raises + ------ + GeometrySchemaError + If no basis direction is perpendicular to ``n_komega`` within + tolerance ``1e-9`` — i.e. ``n_komega`` is not aligned to any + single basis axis. In that pathological case the YAML must + declare ``kappa_eulerian_chi`` explicitly. + """ + n_om = np.asarray(n_komega, dtype=float) + n_om = n_om / np.linalg.norm(n_om) + for label in ("longitudinal", "vertical", "transverse"): + candidate = np.asarray(basis[label], dtype=float) + candidate = candidate / np.linalg.norm(candidate) + if abs(float(np.dot(n_om, candidate))) < 1e-9: + return candidate + raise GeometrySchemaError( + f"{source_label}: cannot derive the equivalent-Eulerian chi " + f"axis automatically because the outer kappa axis (komega = " + f"{n_komega.tolist()!r}) is not perpendicular to any single " + f"basis direction. Declare 'kappa_eulerian_chi' explicitly " + f"in the top-level YAML." + ) + + def _resolve_extras(extras: dict[str, Any]) -> dict[str, Any]: """Map the literal string ``'REQUIRED'`` to the :data:`ad_hoc_diffractometer.mode.REQUIRED` sentinel. @@ -674,7 +733,7 @@ def _construct_from_doc( alpha_deg = float(parameters["alpha_deg"]) if "alpha_deg" in overrides and overrides["alpha_deg"] is not None: alpha_deg = float(overrides["alpha_deg"]) - elif alpha_deg is None and "kappa_chi_eq" in doc: + elif alpha_deg is None and ("kappa_chi_eq" in doc or "kappa_eulerian_chi" in doc): # File declares a kappa pseudo-angle equivalent but no alpha; default. alpha_deg = KAPPA_ALPHA_DEFAULT @@ -708,7 +767,13 @@ def _construct_from_doc( ) basis = {k: np.asarray(v, dtype=float).copy() for k, v in BASIS_DEFAULT.items()} - # Optional kappa_chi_eq for kappa-tilt axis resolution. + # Optional kappa_chi_eq for kappa-arm tilt-direction (Walko's + # geometric formula in ``_resolve_axis``). This direction lies in + # the plane spanned by ``n_komega`` and ``n_kappa`` (it is the + # in-plane perpendicular of ``n_komega``). It is NOT the + # equivalent-Eulerian chi-pseudo-angle axis (which is generally a + # different direction; see ``kappa_eulerian_chi`` below and issue + # #284). kappa_chi_eq: np.ndarray | None = None if "kappa_chi_eq" in doc: kappa_chi_eq_spec = doc["kappa_chi_eq"] @@ -727,6 +792,29 @@ def _construct_from_doc( f"{kappa_chi_eq_spec!r}." ) + # Optional kappa_eulerian_chi for the equivalent-Eulerian chi + # pseudo-angle axis (issue #284). Distinct from ``kappa_chi_eq``: + # this is the axis the kappa→Eulerian decomposition rotates about + # for the virtual ``chi`` angle, and should match the corresponding + # non-kappa Eulerian preset's ``chi`` axis (fourcv/fourch/psic chi + # is conventionally ``+longitudinal``). When omitted, the loader + # derives it from the first basis direction perpendicular to + # ``n_komega`` in the conventional order + # (``+longitudinal``, ``+vertical``, ``+transverse``). + kappa_eulerian_chi: np.ndarray | None = None + if "kappa_eulerian_chi" in doc: + spec = doc["kappa_eulerian_chi"] + if isinstance(spec, str): + kappa_eulerian_chi = parse_axis(spec, basis=basis) + elif isinstance(spec, list | tuple) and len(spec) == 3: + kappa_eulerian_chi = np.asarray([float(c) for c in spec], dtype=float) + else: + raise GeometrySchemaError( + f"{source_label}: 'kappa_eulerian_chi' must be a " + f"signed-axis string or a length-3 numeric vector; " + f"got {spec!r}." + ) + # Stages if "stages" not in doc: raise GeometrySchemaError( @@ -795,7 +883,9 @@ def _construct_from_doc( # Build the kappa pseudo-angle convention if applicable. kappa_convention: KappaPseudoAngleConvention | None = None - if alpha_deg is not None and kappa_chi_eq is not None: + if alpha_deg is not None and ( + kappa_chi_eq is not None or kappa_eulerian_chi is not None + ): try: n_komega = next(s.axis for s in stages if s.name == "komega") n_kappa = next(s.axis for s in stages if s.name == "kappa") @@ -807,13 +897,34 @@ def _construct_from_doc( f"'komega', 'kappa', 'kphi'. The declarative loader " f"synthesizes the KappaPseudoAngleConvention from these " f"names; rename your stages accordingly or omit the " - f"'kappa_chi_eq' / 'parameters.alpha_deg' fields." + f"'kappa_chi_eq' / 'kappa_eulerian_chi' / " + f"'parameters.alpha_deg' fields." ) from None + + # Resolve the equivalent-Eulerian chi axis (issue #284). + # Precedence: + # 1. explicit ``kappa_eulerian_chi`` field (caller override); + # 2. derived: first basis direction perpendicular to + # ``n_komega`` in the conventional order + # ``(+longitudinal, +vertical, +transverse)``. + # The conventional choice is ``+longitudinal``: every standard + # 4-/6-circle Eulerian preset shipped with this package + # (fourcv, fourch, psic, sixc, fivec) puts its ``chi`` rotation + # about ``+longitudinal``. Aligning the kappa equivalent- + # Eulerian decomposition with that choice makes + # ``forward()`` reachability of a kappa preset match its + # non-kappa sister (fourcv↔kappa4cv, fourch↔kappa4ch, + # psic↔kappa6c). + if kappa_eulerian_chi is not None: + n_chi_eq = kappa_eulerian_chi + else: + n_chi_eq = _derive_kappa_eulerian_chi(n_komega, basis, source_label) + kappa_convention = KappaPseudoAngleConvention( n_komega=n_komega, n_kappa=n_kappa, n_kphi=n_kphi, - n_chi_eq=kappa_chi_eq, + n_chi_eq=n_chi_eq, ) return AdHocDiffractometer( diff --git a/src/ad_hoc_diffractometer/kappa.py b/src/ad_hoc_diffractometer/kappa.py index 3ca7c51c..9b18dad7 100644 --- a/src/ad_hoc_diffractometer/kappa.py +++ b/src/ad_hoc_diffractometer/kappa.py @@ -142,9 +142,18 @@ class KappaPseudoAngleConvention: R(n_komega, κω) · R(n_kappa, κ) · R(n_kphi, κφ) = R(n_komega, ω) · R(n_chi_eq, χ) · R(n_kphi, φ). - This identity holds for **arbitrary signed stage axes** as long - as ``n_kappa`` lies in the plane spanned by ``n_komega`` and - ``n_chi_eq`` (which is the geometric definition of a kappa arm). + The decomposition is solvable for any ``n_chi_eq`` that is + perpendicular to ``n_komega`` (and not parallel to ``n_kphi``); + it does **not** require ``n_chi_eq`` to lie in the kappa-arm + tilt plane. Different ``n_chi_eq`` choices yield different + (and equally valid) virtual-Eulerian parametrizations of the + same kappa rotation, but with different ``(ω, χ, φ)`` values + and different bisecting-reachability sets. Issue #284 records + the choice this package adopts (``+longitudinal`` for every + shipped kappa preset, to match the corresponding non-kappa + Eulerian preset's ``chi`` axis); see + :mod:`ad_hoc_diffractometer.geometries` for the broader + discussion. Parameters ---------- @@ -155,8 +164,10 @@ class KappaPseudoAngleConvention: n_kphi : numpy.ndarray, shape (3,) Unit axis of the inner kappa stage (= virtual φ axis). n_chi_eq : numpy.ndarray, shape (3,) - Unit axis of the equivalent Eulerian χ rotation, perpendicular - to ``n_komega`` and coplanar with ``n_komega`` and ``n_kappa``. + Unit axis of the equivalent Eulerian χ rotation. Must be + perpendicular to ``n_komega`` and not parallel to ``n_kphi``; + does **not** need to lie in the kappa-arm tilt plane (see + issue #284 for the discussion). Notes ----- diff --git a/tests/test_factories.py b/tests/test_factories.py index ca2b1120..792aa5f1 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -181,9 +181,15 @@ def test_make_geometry_kappa_alpha_forwarded(): The kappa axis lies in the transverse-vertical plane, tilted ``alpha`` degrees from +T toward +V (Walko 2016 Fig. 3 and Thorkildsen et al. 2006 Table 1; see issue #252 for the - correction to the v0.9.1 ``n_chi_eq=+LONGITUDINAL`` choice). - For ``kappa4cv`` (BL basis: T=+x, L=+y, V=+z) the kappa axis is - therefore ``+x̂·cos(α) + ẑ·sin(α)``. + correction of the kappa-arm tilt direction). For ``kappa4cv`` + (BL basis: T=+x, L=+y, V=+z) the kappa axis is therefore + ``+x̂·cos(α) + ẑ·sin(α)``. + + Issue #284 separated the kappa-arm tilt direction (which the YAML + ``kappa_chi_eq`` field still controls; tested here) from the + equivalent-Eulerian chi pseudo-angle axis (controlled by the + new YAML ``kappa_eulerian_chi`` field; auto-derived to + ``+longitudinal`` for every shipped kappa preset). """ g = make_geometry("kappa4cv", alpha_deg=45.0) expected = np.cos(np.deg2rad(45)) * np.array([1, 0, 0]) + np.sin( diff --git a/tests/test_geometry_loader.py b/tests/test_geometry_loader.py index 0e1d1f97..b436075a 100644 --- a/tests/test_geometry_loader.py +++ b/tests/test_geometry_loader.py @@ -873,7 +873,18 @@ def test_kappa_chi_eq_invalid_form(): def test_kappa_chi_eq_numeric_vector_form(): - """kappa_chi_eq may be a length-3 numeric vector.""" + """kappa_chi_eq may be a length-3 numeric vector. + + With issue #284 the ``kappa_chi_eq`` field controls only the + kappa-arm tilt direction (input to Walko's formula in + ``_resolve_axis``). This test verifies the numeric-vector form + parses without error and that the kappa stage axis is built + correctly from it; the synthesized convention's ``n_chi_eq`` is + a separate concept (auto-derived from the basis, here + ``+longitudinal``) and is verified by other tests. + """ + import math + import numpy as np doc = { @@ -920,8 +931,17 @@ def test_kappa_chi_eq_numeric_vector_form(): }, } g = load_geometry_file(_yaml_doc_to_text(doc)) + # The kappa stage axis is built from the unsigned outer (+transverse) + # tilted alpha_deg toward kappa_chi_eq (+vertical): + expected_kappa = np.cos(math.radians(50.0)) * np.array([1.0, 0.0, 0.0]) + np.sin( + math.radians(50.0) + ) * np.array([0.0, 0.0, 1.0]) + np.testing.assert_allclose(g.stage("kappa").axis, expected_kappa, atol=1e-12) + # The synthesized convention's n_chi_eq is auto-derived as the + # first basis direction perpendicular to n_komega; for BL with + # n_komega = -transverse that yields +longitudinal. np.testing.assert_allclose( - g.kappa_pseudo_angle_convention.n_chi_eq, [0.0, 0.0, 1.0], atol=1e-12 + g.kappa_pseudo_angle_convention.n_chi_eq, [0.0, 1.0, 0.0], atol=1e-12 ) diff --git a/tests/test_regression_issue_252.py b/tests/test_regression_issue_252.py index d49711fe..8ebf2e2c 100644 --- a/tests/test_regression_issue_252.py +++ b/tests/test_regression_issue_252.py @@ -212,7 +212,7 @@ def test_kappa_axis_orthogonal_to_third_basis_direction( ) def test_eulerian_kappa_round_trip(factory, omega, chi, phi, branch): """Round-trip ``kappa_to_eulerian_axes ∘ eulerian_to_kappa_axes`` - is the identity on the +1 branch for every kappa preset. + preserves the physical sample rotation for every kappa preset. The closed-form solver in ``kappa.py`` works directly from the four signed stage axes stored on the geometry; the round-trip @@ -220,22 +220,27 @@ def test_eulerian_kappa_round_trip(factory, omega, chi, phi, branch): convention. This is the strongest internal-consistency guard for the kappa pseudoangle layer. - The −1 branch round-trips onto the chi-mirrored point and is - therefore not tested as an identity here (the geometry-aware - decomposition encodes the chi sign in the kappa branch - parameter). + The round-trip is asserted at the **rotation-matrix** level + rather than at the angle level: each branch is a valid kappa + decomposition of the same physical sample rotation, but the + geometry-dependent ``branch`` semantics (``+1`` = smaller + ``|κ|``) may land on the chi-mirrored Eulerian angles for some + kappa presets (e.g. ``kappa6c`` with ``n_komega × n_chi_eq`` + anti-parallel to the kappa-arm in-plane vector — see issue + #284 for the discussion). The rotation matrix is the + convention-independent invariant. """ + import numpy as np + + from ad_hoc_diffractometer.kappa import _eulerian_rotation + g = factory() convention = g.kappa_pseudo_angle_convention ko, k, kp = eulerian_to_kappa_axes(omega, chi, phi, convention, branch=branch) om, ch, ph = kappa_to_eulerian_axes(ko, k, kp, convention) - assert om == pytest.approx(omega, abs=1e-10) - if abs(chi) < 1e-10: - assert ch == pytest.approx(0.0, abs=1e-10) - assert ph == pytest.approx(phi, abs=1e-10) - else: - assert ch == pytest.approx(chi, abs=1e-10) - assert ph == pytest.approx(phi, abs=1e-10) + R_in = _eulerian_rotation(convention, omega, chi, phi) + R_out = _eulerian_rotation(convention, om, ch, ph) + np.testing.assert_allclose(R_in, R_out, atol=1e-10) # --------------------------------------------------------------------------- diff --git a/tests/test_regression_issue_267.py b/tests/test_regression_issue_267.py index ddc43ba0..c57a97a7 100644 --- a/tests/test_regression_issue_267.py +++ b/tests/test_regression_issue_267.py @@ -391,6 +391,7 @@ def _reference_kappa4cv( basis = BASIS_BL TRANSVERSE = basis["transverse"] VERTICAL = basis["vertical"] + LONGITUDINAL = basis["longitudinal"] kax = kappa_axis_from_eulerian(+TRANSVERSE, +VERTICAL, alpha_deg) stages = [ Stage("komega", -TRANSVERSE, parent=None, role="sample"), @@ -398,11 +399,14 @@ def _reference_kappa4cv( Stage("kphi", -TRANSVERSE, parent="kappa", role="sample"), Stage("ttheta", -TRANSVERSE, parent=None, role="detector"), ] + # Equivalent-Eulerian chi pseudo-angle axis: +LONGITUDINAL for + # kappa4cv (matches fourcv's chi axis; issue #284). Distinct from + # the kappa-arm tilt direction (+VERTICAL) used to build ``kax``. convention = KappaPseudoAngleConvention( n_komega=-TRANSVERSE, n_kappa=kax, n_kphi=-TRANSVERSE, - n_chi_eq=+VERTICAL, + n_chi_eq=+LONGITUDINAL, ) modes = { "bisecting": ConstraintSet( @@ -465,6 +469,7 @@ def _reference_kappa6c( basis = BASIS_YOU VERTICAL = basis["vertical"] TRANSVERSE = basis["transverse"] + LONGITUDINAL = basis["longitudinal"] kax = kappa_axis_from_eulerian(+TRANSVERSE, +VERTICAL, alpha_deg) stages = [ Stage("mu", +VERTICAL, parent=None, role="sample"), @@ -474,11 +479,14 @@ def _reference_kappa6c( Stage("nu", +VERTICAL, parent=None, role="detector"), Stage("delta", -TRANSVERSE, parent="nu", role="detector"), ] + # Equivalent-Eulerian chi pseudo-angle axis: +LONGITUDINAL for + # kappa6c (matches psic's chi axis; issue #284). Distinct from + # the kappa-arm tilt direction (+VERTICAL) used to build ``kax``. convention = KappaPseudoAngleConvention( n_komega=-TRANSVERSE, n_kappa=kax, n_kphi=-TRANSVERSE, - n_chi_eq=+VERTICAL, + n_chi_eq=+LONGITUDINAL, ) modes = { "bisecting_vertical": ConstraintSet( diff --git a/tests/test_regression_issue_284.py b/tests/test_regression_issue_284.py new file mode 100644 index 00000000..ed05388b --- /dev/null +++ b/tests/test_regression_issue_284.py @@ -0,0 +1,706 @@ +# Copyright (c) 2026 UChicago Argonne, LLC +# SPDX-License-Identifier: LicenseRef-ANL-Open-Source-License +""" +Regression tests for issue #284. + +Issue #284 reported that ``kappa4cv bisecting`` and +``kappa6c bisecting_vertical`` lost solutions for several sapphire +asymmetric reflections in v0.11.0. The reproducer's exact failure +set: ``(0, 1, 2)``, ``(0, 0, 6)``, ``(1, 1, 3)`` on both geometries +all return zero solutions; the symmetric ``(1, 0, 0)`` and +``(1, 1, 0)`` still solve. + +Root cause: issue #252 conflated two distinct concepts in the single +YAML field ``kappa_chi_eq``: + +1. The **kappa-arm tilt direction** that defines the kappa stage's + geometric axis (input to Walko's formula ``n_kappa = cos(α)· + n_komega_unsigned + sin(α)·tilt_direction``). For ``kappa4cv`` + this is ``+vertical`` (kappa arm lies in the transverse-vertical + plane). + +2. The **equivalent-Eulerian chi pseudo-angle axis** the kappa→ + Eulerian decomposition rotates about for the virtual chi angle. + For ``kappa4cv`` this should be ``+longitudinal`` to match + ``fourcv``'s chi axis (the analogous non-kappa Eulerian preset + that ``kappa4cv`` is mechanically equivalent to). + +Setting both to the same value (``+vertical``) broke the second +role: the kappa equivalent-Eulerian decomposition's reachable Bragg +locus shrank away from the locus reachable by ``fourcv bisecting``, +making asymmetric reflections like sapphire ``(0, 1, 2)`` decline. + +The fix separates the two: + +- ``kappa_chi_eq`` in the YAML still defines the kappa-arm tilt + direction (used by ``_resolve_axis``). +- A new optional YAML field ``kappa_eulerian_chi`` defines the + equivalent-Eulerian chi axis. When absent, the loader derives it + as the first basis direction perpendicular to ``n_komega`` in the + conventional order ``(+longitudinal, +vertical, +transverse)`` — + which yields ``+longitudinal`` for every kappa geometry shipped + with the package. + +These regression tests cover: + +- the exact ``forward()`` reproducers from the issue body; +- the broader kappa-vs-fourcv equivalence (the kappa bisecting + reachability now matches the equivalent fourcv/fourch/psic + bisecting reachability); +- the auto-derivation rule of the new ``kappa_eulerian_chi`` field; +- the explicit ``kappa_eulerian_chi`` YAML override. +""" + +from __future__ import annotations + +from contextlib import nullcontext as does_not_raise + +import numpy as np +import pytest +import yaml + +import ad_hoc_diffractometer as ahd +from ad_hoc_diffractometer.geometry_loader import KIND_KEY +from ad_hoc_diffractometer.geometry_loader import GeometrySchemaError +from ad_hoc_diffractometer.geometry_loader import load_geometry_file +from ad_hoc_diffractometer.kappa import KappaPseudoAngleConvention +from ad_hoc_diffractometer.kappa import eulerian_to_kappa_axes +from ad_hoc_diffractometer.orientation import angles_to_phi_vector + +SAPPHIRE = dict(a=4.7589, b=4.7589, c=12.99119, alpha=90.0, beta=90.0, gamma=120.0) +WAVELENGTH = 1.54 + + +# --------------------------------------------------------------------------- +# Reproducer: exact sapphire reflections from the issue body +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "geom_name, mode_name", + [ + pytest.param("kappa4cv", "bisecting", id="kappa4cv-bisecting"), + pytest.param("kappa6c", "bisecting_vertical", id="kappa6c-bisecting_vertical"), + ], +) +@pytest.mark.parametrize( + "hkl, expected_min_sols, context", + [ + pytest.param((0, 1, 2), 1, does_not_raise(), id="sapphire-012-solves"), + pytest.param((0, 0, 6), 1, does_not_raise(), id="sapphire-006-solves"), + pytest.param((1, 1, 3), 1, does_not_raise(), id="sapphire-113-solves"), + pytest.param((1, 1, 0), 1, does_not_raise(), id="sapphire-110-solves"), + pytest.param((1, 0, 0), 1, does_not_raise(), id="sapphire-100-solves"), + ], +) +def test_kappa_bisecting_sapphire_reflections_solve( + geom_name, + mode_name, + hkl, + expected_min_sols, + context, +): + """Issue #284 reproducer: sapphire asymmetric reflections solve. + + Direct copy of the reproducer block in the issue body. Pre-fix + these returned ``n_sols=0`` for the three asymmetric cases on + both ``kappa4cv bisecting`` and ``kappa6c bisecting_vertical``. + """ + g = ahd.make_geometry(geom_name) + g.sample.lattice = ahd.Lattice(**SAPPHIRE) + g.wavelength = WAVELENGTH + ahd.ub_identity(g.sample) + g.mode_name = mode_name + with context: + sols = g.forward(*hkl) + assert len(sols) >= expected_min_sols, ( + f"{geom_name} / {mode_name} {hkl}: expected at least " + f"{expected_min_sols} solution(s), got {len(sols)}." + ) + # Each returned solution must hit the target Q_phi. + target = g.sample.UB @ np.asarray(hkl, dtype=float) + for sol in sols: + angles = {k: sol[k] for k in sol} + Q = angles_to_phi_vector(g, **angles) + np.testing.assert_allclose(Q, target, atol=1e-8) + + +# --------------------------------------------------------------------------- +# Cross-geometry equivalence: kappa bisecting reachability matches the +# corresponding non-kappa Eulerian bisecting reachability. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kappa_name, eulerian_name, kappa_mode, eulerian_mode, context", + [ + pytest.param( + "kappa4cv", + "fourcv", + "bisecting", + "bisecting", + does_not_raise(), + id="kappa4cv-fourcv", + ), + pytest.param( + "kappa6c", + "psic", + "bisecting_vertical", + "bisecting_vertical", + does_not_raise(), + id="kappa6c-psic", + ), + pytest.param( + "kappa4ch", + "fourch", + "bisecting", + "bisecting", + does_not_raise(), + id="kappa4ch-fourch", + ), + ], +) +@pytest.mark.parametrize( + "hkl", + [ + pytest.param((0, 1, 2), id="sapphire-012"), + pytest.param((0, 0, 6), id="sapphire-006"), + pytest.param((1, 1, 3), id="sapphire-113"), + pytest.param((1, 1, 0), id="sapphire-110"), + pytest.param((1, 0, 0), id="sapphire-100"), + ], +) +def test_kappa_bisecting_matches_eulerian_reachability( + kappa_name, + eulerian_name, + kappa_mode, + eulerian_mode, + hkl, + context, +): + """The kappa equivalent-Eulerian decomposition reaches every + reflection that the corresponding non-kappa Eulerian bisecting + mode reaches. + + Pre-fix the kappa preset's bisecting reachability set was a + strict subset of the sister Eulerian preset's set for several + sapphire reflections (issue #284). Post-fix the two sets agree + on every test reflection. + + The angle values themselves differ (the kappa→Eulerian + decomposition has its own (komega, kappa, kphi) branches), but + each kappa solution must produce the same target ``Q_phi`` as + the Eulerian solution. + """ + g_k = ahd.make_geometry(kappa_name) + g_k.sample.lattice = ahd.Lattice(**SAPPHIRE) + g_k.wavelength = WAVELENGTH + ahd.ub_identity(g_k.sample) + g_k.mode_name = kappa_mode + + g_e = ahd.make_geometry(eulerian_name) + g_e.sample.lattice = ahd.Lattice(**SAPPHIRE) + g_e.wavelength = WAVELENGTH + ahd.ub_identity(g_e.sample) + g_e.mode_name = eulerian_mode + + with context: + eul_sols = g_e.forward(*hkl) + kap_sols = g_k.forward(*hkl) + # If the Eulerian sister solves, the kappa preset must also + # solve (within its kappa-arm reachability — every test + # reflection here is within that range). + if eul_sols: + assert kap_sols, ( + f"{eulerian_name} {hkl} has {len(eul_sols)} solution(s) " + f"but {kappa_name} returns 0." + ) + + +# --------------------------------------------------------------------------- +# kappa→Eulerian Q-equivalence: the geometry-aware decomposition +# preserves the scattering vector across a sweep of pseudoangles. +# +# This restores the central invariant from the pre-#252 issue-#241 +# regression file (deleted by #252 PR #253); under the corrected +# convention the invariant holds for every kappa preset across the +# full reachable chi range. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kappa_name, eulerian_name", + [ + pytest.param("kappa4cv", "fourcv", id="kappa4cv-fourcv"), + pytest.param("kappa4ch", "fourch", id="kappa4ch-fourch"), + pytest.param("kappa6c", "psic", id="kappa6c-psic"), + ], +) +@pytest.mark.parametrize( + "omega, chi, phi, ttheta, context", + [ + pytest.param(0, 0, 0, 30, does_not_raise(), id="origin"), + pytest.param(10, 0, 0, 30, does_not_raise(), id="omega-only"), + pytest.param(0, 5, 0, 30, does_not_raise(), id="small-chi"), + pytest.param(0, 30, 0, 30, does_not_raise(), id="chi-30"), + pytest.param(0, 60, 0, 30, does_not_raise(), id="chi-60"), + pytest.param(0, -30, 0, 30, does_not_raise(), id="chi-negative-30"), + pytest.param(10, 20, 30, 30, does_not_raise(), id="general-1"), + pytest.param(5, 45, 15, 30, does_not_raise(), id="general-2"), + ], +) +def test_eulerian_to_kappa_axes_preserves_q( + kappa_name, + eulerian_name, + omega, + chi, + phi, + ttheta, + context, +): + """``eulerian_to_kappa_axes`` produces a kappa motor triple + whose physical ``Q_phi`` matches the equivalent non-kappa + Eulerian preset's ``Q_phi`` at the same virtual (omega, chi, + phi). + + Pre-#252 this invariant held by construction. #252's + conflation of the kappa-arm tilt direction and the equivalent- + Eulerian chi axis broke it for ``kappa4cv`` and ``kappa6c`` + (the test it lived in was deleted). Post-#284 the invariant + holds again across every kappa preset. + """ + g_e = ahd.make_geometry(eulerian_name) + g_e.wavelength = 1.5 + g_k = ahd.make_geometry(kappa_name) + g_k.wavelength = 1.5 + + convention = g_k.kappa_pseudo_angle_convention + sample_names_eul = [s.name for s in g_e.sample_stages] + detector_name_eul = g_e.detector_stages[-1].name + + # Build the Eulerian motor dict. The sister Eulerian geometry + # may have outer/inner stages beyond the (omega, chi, phi) triple + # (e.g. mu on psic); set those to zero. + eul_angles = {n: 0.0 for n in sample_names_eul} + eul_angles[detector_name_eul] = ttheta + # Map (omega, chi, phi) onto the inner three sample stages. + eul_angles[sample_names_eul[-3]] = omega + eul_angles[sample_names_eul[-2]] = chi + eul_angles[sample_names_eul[-1]] = phi + for s in g_e.detector_stages[:-1]: + eul_angles[s.name] = 0.0 + + Q_eul = angles_to_phi_vector(g_e, **eul_angles) + + with context: + ko, k, kp = eulerian_to_kappa_axes(omega, chi, phi, convention, branch=+1) + + sample_names_kap = [s.name for s in g_k.sample_stages] + detector_name_kap = g_k.detector_stages[-1].name + kap_angles = {n: 0.0 for n in sample_names_kap} + kap_angles[detector_name_kap] = ttheta + kap_angles[sample_names_kap[-3]] = ko + kap_angles[sample_names_kap[-2]] = k + kap_angles[sample_names_kap[-1]] = kp + for s in g_k.detector_stages[:-1]: + kap_angles[s.name] = 0.0 + + Q_kap = angles_to_phi_vector(g_k, **kap_angles) + + np.testing.assert_allclose(Q_kap, Q_eul, atol=1e-10) + + +# --------------------------------------------------------------------------- +# Convention values: all shipped kappa presets now use n_chi_eq = +# +longitudinal (matching the corresponding fourcv/fourch/psic chi +# axis). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kappa_name, expected_basis_label, context", + [ + pytest.param("kappa4cv", "longitudinal", does_not_raise(), id="kappa4cv"), + pytest.param("kappa4ch", "longitudinal", does_not_raise(), id="kappa4ch"), + pytest.param("kappa6c", "longitudinal", does_not_raise(), id="kappa6c"), + ], +) +def test_n_chi_eq_matches_basis_longitudinal( + kappa_name, + expected_basis_label, + context, +): + """Every shipped kappa preset's equivalent-Eulerian chi axis is + ``+longitudinal`` (issue #284). + + Aligns the kappa equivalent-Eulerian decomposition's virtual chi + axis with the corresponding non-kappa Eulerian preset's chi + axis: ``fourcv``, ``fourch``, ``psic``, ``sixc``, and ``fivec`` + all put ``chi`` about ``+longitudinal``. + """ + g = ahd.make_geometry(kappa_name) + with context: + np.testing.assert_allclose( + g.kappa_pseudo_angle_convention.n_chi_eq, + g.basis[expected_basis_label], + atol=1e-12, + ) + + +# --------------------------------------------------------------------------- +# The new YAML field kappa_eulerian_chi: explicit override and +# auto-derivation. +# --------------------------------------------------------------------------- + + +def _kappa_yaml_doc( + *, + kappa_eulerian_chi: str | None = None, + kappa_chi_eq: str = "+vertical", +): + """Minimal kappa4cv-like YAML doc, parametrized for these tests.""" + doc = { + KIND_KEY: {"schema_revision": 1}, + "name": "kappa_test", + "documentation": "test", + "basis": "BL", + "parameters": {"alpha_deg": 50.0}, + "kappa_chi_eq": kappa_chi_eq, + "stages": [ + { + "name": "komega", + "axis": "-transverse", + "parent": None, + "role": "sample", + }, + { + "name": "kappa", + "axis": {"kappa_eulerian": "+transverse"}, + "parent": "komega", + "role": "sample", + }, + { + "name": "kphi", + "axis": "-transverse", + "parent": "kappa", + "role": "sample", + }, + { + "name": "ttheta", + "axis": "-transverse", + "parent": None, + "role": "detector", + }, + ], + "modes": { + "bisecting": { + "default": True, + "constraints": [ + {"type": "virtual_bisect", "stage1": "omega", "stage2": "ttheta"} + ], + "computed": ["komega", "kappa", "kphi", "ttheta"], + } + }, + } + if kappa_eulerian_chi is not None: + doc["kappa_eulerian_chi"] = kappa_eulerian_chi + return yaml.safe_dump(doc) + + +def test_kappa_eulerian_chi_explicit_override(): + """When ``kappa_eulerian_chi`` is declared the loader uses it + verbatim for the convention's ``n_chi_eq`` (issue #284).""" + text = _kappa_yaml_doc(kappa_eulerian_chi="+vertical") + g = load_geometry_file(text) + # Explicit override: n_chi_eq is +vertical (not the auto-derived + # +longitudinal). + np.testing.assert_allclose( + g.kappa_pseudo_angle_convention.n_chi_eq, [0.0, 0.0, 1.0], atol=1e-12 + ) + + +def test_kappa_eulerian_chi_auto_derived_when_absent(): + """Without ``kappa_eulerian_chi`` the loader derives ``n_chi_eq`` + as ``+longitudinal`` for a standard kappa4cv-style preset.""" + text = _kappa_yaml_doc() # no kappa_eulerian_chi + g = load_geometry_file(text) + np.testing.assert_allclose( + g.kappa_pseudo_angle_convention.n_chi_eq, [0.0, 1.0, 0.0], atol=1e-12 + ) + + +def test_kappa_eulerian_chi_numeric_vector_form(): + """``kappa_eulerian_chi`` accepts a length-3 numeric vector.""" + doc = yaml.safe_load(_kappa_yaml_doc()) + doc["kappa_eulerian_chi"] = [0.0, 0.0, 1.0] + g = load_geometry_file(yaml.safe_dump(doc)) + np.testing.assert_allclose( + g.kappa_pseudo_angle_convention.n_chi_eq, [0.0, 0.0, 1.0], atol=1e-12 + ) + + +def test_kappa_eulerian_chi_invalid_form_rejected(): + """Non-string non-vector ``kappa_eulerian_chi`` is rejected.""" + doc = yaml.safe_load(_kappa_yaml_doc()) + doc["kappa_eulerian_chi"] = 42 # neither a string nor a vector + with pytest.raises( + GeometrySchemaError, match="'kappa_eulerian_chi' must be" + ): + load_geometry_file(yaml.safe_dump(doc)) + + +def test_kappa_eulerian_chi_auto_derivation_walks_basis_fallback(): + """The auto-derivation rule walks the basis directions in order + ``(+longitudinal, +vertical, +transverse)`` and returns the first + one perpendicular to ``n_komega``. When the conventional first + choice (``+longitudinal``) is parallel to ``n_komega``, the rule + falls through to ``+vertical``. + """ + # Construct a kappa-arm geometry with ``n_komega = +longitudinal`` + # (an unconventional but valid choice). The auto-derivation + # cannot pick +longitudinal (parallel to n_komega), so it must + # pick the next perpendicular basis direction (+vertical). + doc = { + KIND_KEY: {"schema_revision": 1}, + "name": "kappa_long_omega", + "documentation": "test", + "basis": "BL", + "parameters": {"alpha_deg": 50.0}, + "kappa_chi_eq": "+transverse", # arm in (long, trans) plane + "stages": [ + { + "name": "komega", + "axis": "+longitudinal", + "parent": None, + "role": "sample", + }, + { + "name": "kappa", + "axis": {"kappa_eulerian": "+longitudinal"}, + "parent": "komega", + "role": "sample", + }, + { + "name": "kphi", + "axis": "+longitudinal", + "parent": "kappa", + "role": "sample", + }, + { + "name": "ttheta", + "axis": "+longitudinal", + "parent": None, + "role": "detector", + }, + ], + "modes": { + "fixed_kphi": { + "default": True, + "constraints": [{"type": "sample", "stage": "kphi", "value": 0.0}], + "computed": ["komega", "kappa", "ttheta"], + } + }, + } + g = load_geometry_file(yaml.safe_dump(doc)) + # +longitudinal is parallel to n_komega; +vertical is the next + # perpendicular basis direction. + np.testing.assert_allclose( + g.kappa_pseudo_angle_convention.n_chi_eq, [0.0, 0.0, 1.0], atol=1e-12 + ) + + +def test_kappa_eulerian_chi_auto_derivation_no_perpendicular_basis_rejected(): + """When the outer kappa axis is not aligned with any single basis + direction the auto-derivation has no perpendicular candidate and + must raise. Callers must declare ``kappa_eulerian_chi`` for + such geometries. + """ + # Construct a kappa-arm geometry whose n_komega is a (1, 1, 1)/√3 + # diagonal: not perpendicular to any of +longitudinal, +vertical, + # +transverse within tolerance. Skip the ``kappa_eulerian`` arm- + # construction shortcut (its own perpendicularity check rejects + # this case) by supplying a numeric kappa-axis vector directly. + s3 = float(1.0 / np.sqrt(3.0)) + oblique = [s3, s3, s3] + # Numeric kappa-arm axis: tilted toward an arbitrary perpendicular + # direction; the value does not matter here because the test + # exercises only the auto-derivation of n_chi_eq. + kappa_axis = [ + float(np.cos(np.deg2rad(50.0)) * s3 + np.sin(np.deg2rad(50.0)) / np.sqrt(2.0)), + float(np.cos(np.deg2rad(50.0)) * s3 - np.sin(np.deg2rad(50.0)) / np.sqrt(2.0)), + float(np.cos(np.deg2rad(50.0)) * s3), + ] + doc = { + KIND_KEY: {"schema_revision": 1}, + "name": "kappa_oblique_omega", + "documentation": "test", + "basis": "BL", + "parameters": {"alpha_deg": 50.0}, + "kappa_chi_eq": "+transverse", + "stages": [ + { + "name": "komega", + "axis": oblique, + "parent": None, + "role": "sample", + }, + { + "name": "kappa", + "axis": kappa_axis, + "parent": "komega", + "role": "sample", + }, + { + "name": "kphi", + "axis": oblique, + "parent": "kappa", + "role": "sample", + }, + { + "name": "ttheta", + "axis": oblique, + "parent": None, + "role": "detector", + }, + ], + "modes": { + "fixed_kphi": { + "default": True, + "constraints": [{"type": "sample", "stage": "kphi", "value": 0.0}], + "computed": ["komega", "kappa", "ttheta"], + } + }, + } + with pytest.raises( + GeometrySchemaError, match="cannot derive the equivalent-Eulerian chi" + ): + load_geometry_file(yaml.safe_dump(doc)) + + +# --------------------------------------------------------------------------- +# Kappa-arm tilt direction is unchanged: the YAML's ``kappa_chi_eq`` +# still drives Walko's formula for the kappa stage axis. This guards +# against accidentally re-conflating the two fields. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kappa_name, expected_kappa_axis_in_plane, context", + [ + # kappa4cv: kappa arm in (transverse, vertical) plane. + # Expected axis = cos(50)*+x + sin(50)*+z (BL: x=trans, z=vert). + pytest.param( + "kappa4cv", + ( + np.cos(np.deg2rad(50.0)) * np.array([1.0, 0.0, 0.0]) + + np.sin(np.deg2rad(50.0)) * np.array([0.0, 0.0, 1.0]) + ), + does_not_raise(), + id="kappa4cv-arm-in-TV-plane", + ), + # kappa4ch: kappa arm in (vertical, longitudinal) plane. + # Expected axis = cos(50)*+z + sin(50)*+y (BL: z=vert, y=long). + pytest.param( + "kappa4ch", + ( + np.cos(np.deg2rad(50.0)) * np.array([0.0, 0.0, 1.0]) + + np.sin(np.deg2rad(50.0)) * np.array([0.0, 1.0, 0.0]) + ), + does_not_raise(), + id="kappa4ch-arm-in-VL-plane", + ), + # kappa6c: kappa arm in (transverse, vertical) plane, + # same as kappa4cv but in YOU basis (x=vert, z=trans). + pytest.param( + "kappa6c", + ( + np.cos(np.deg2rad(50.0)) * np.array([0.0, 0.0, 1.0]) + + np.sin(np.deg2rad(50.0)) * np.array([1.0, 0.0, 0.0]) + ), + does_not_raise(), + id="kappa6c-arm-in-TV-plane", + ), + ], +) +def test_kappa_arm_axis_in_canonical_plane( + kappa_name, + expected_kappa_axis_in_plane, + context, +): + """The kappa stage axis lies in the canonical kappa-arm tilt + plane per the published references (Walko 2016, Wyckoff 1985, + Thorkildsen 2006). This is unchanged by issue #284, which + only affected the equivalent-Eulerian chi pseudo-angle axis. + """ + g = ahd.make_geometry(kappa_name) + with context: + np.testing.assert_allclose( + g.stage("kappa").axis, expected_kappa_axis_in_plane, atol=1e-12 + ) + + +# --------------------------------------------------------------------------- +# Equivalent-Eulerian chi axis must be perpendicular to n_komega for +# the closed-form decomposition to be well-posed. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kappa_name, context", + [ + pytest.param("kappa4cv", does_not_raise(), id="kappa4cv"), + pytest.param("kappa4ch", does_not_raise(), id="kappa4ch"), + pytest.param("kappa6c", does_not_raise(), id="kappa6c"), + ], +) +def test_n_chi_eq_perpendicular_to_n_komega(kappa_name, context): + """Every shipped kappa preset's ``n_chi_eq`` is perpendicular to + its ``n_komega`` (required for the closed-form + ``eulerian_to_kappa_axes`` decomposition; see issue #284 and the + ``KappaPseudoAngleConvention`` docstring). + """ + g = ahd.make_geometry(kappa_name) + convention = g.kappa_pseudo_angle_convention + with context: + assert abs(float(np.dot(convention.n_komega, convention.n_chi_eq))) < 1e-12 + + +# --------------------------------------------------------------------------- +# KappaPseudoAngleConvention with a custom n_chi_eq survives the +# constructor's validation step (no in-plane requirement after #284). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "n_chi_eq, context", + [ + # +longitudinal (perpendicular to the T-V kappa-arm plane). + pytest.param([0.0, 1.0, 0.0], does_not_raise(), id="long-perpendicular"), + # +vertical (in the T-V plane, perpendicular to n_komega). + pytest.param([0.0, 0.0, 1.0], does_not_raise(), id="vert-in-plane"), + ], +) +def test_kappa_convention_accepts_any_perpendicular_n_chi_eq(n_chi_eq, context): + """The ``KappaPseudoAngleConvention`` constructor accepts any + ``n_chi_eq`` perpendicular to ``n_komega`` and not parallel to + ``n_kphi`` — it does **not** require the in-plane kappa-arm + relation that ``kappa_axis_from_eulerian`` uses. Issue #284 + relies on this freedom. + """ + import math + + n_komega = np.array([-1.0, 0.0, 0.0]) # kappa4cv-like + n_kappa = np.cos(math.radians(50.0)) * np.array([1.0, 0.0, 0.0]) + np.sin( + math.radians(50.0) + ) * np.array([0.0, 0.0, 1.0]) + n_kphi = np.array([-1.0, 0.0, 0.0]) + with context: + KappaPseudoAngleConvention( + n_komega=n_komega, + n_kappa=n_kappa, + n_kphi=n_kphi, + n_chi_eq=np.array(n_chi_eq, dtype=float), + ) + + +