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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ src/ad_hoc_diffractometer/_version.py
docs/build/
docs/_build/
docs/source/autoapi/
.coverage
.coverage*
htmlcov/
.pytest_cache/
__pycache__/
Expand Down
9 changes: 9 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<!--
## Unreleased

Note any unreleased items inside the comment here. Not visible until release.

### Fixed

- Kappa equivalent-Eulerian chi axis now matches fourcv/fourch/psic. (#284)

-->

## Release v0.11.0

Released 2026-05-19
Expand Down
7 changes: 6 additions & 1 deletion src/ad_hoc_diffractometer/forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -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|)
Expand Down
29 changes: 23 additions & 6 deletions src/ad_hoc_diffractometer/geometries/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``).
Expand All @@ -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
---------------------

Expand Down
8 changes: 6 additions & 2 deletions src/ad_hoc_diffractometer/geometries/kappa4ch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 13 additions & 4 deletions src/ad_hoc_diffractometer/geometries/kappa4cv.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion src/ad_hoc_diffractometer/geometries/kappa6c.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions src/ad_hoc_diffractometer/geometries/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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: <axis>}, 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: <axis>}, valid only when parameters.alpha_deg and the top-level kappa_chi_eq are both declared.",
"oneOf": [
{
"type": "string",
Expand Down
121 changes: 116 additions & 5 deletions src/ad_hoc_diffractometer/geometry_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def get_schema() -> dict:
"basis",
"parameters",
"kappa_chi_eq",
"kappa_eulerian_chi",
"stages",
"modes",
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"]
Expand All @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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(
Expand Down
21 changes: 16 additions & 5 deletions src/ad_hoc_diffractometer/kappa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -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
-----
Expand Down
12 changes: 9 additions & 3 deletions tests/test_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading