Concepts#

Key ideas behind ad_hoc_diffractometer. Each section gives a brief overview and links to richer detail in the how-to guides and background pages.


Coordinate convention#

Diffractometer stages are described in terms of three observable physical directions that can be identified directly in the laboratory:

Physical direction

Lab meaning

vertical

opposite to gravitational acceleration

longitudinal

a chosen direction in the plane perpendicular to vertical, conventionally aligned with the nominal incident beam; a property of the instrument installation

transverse

orthogonal to both; positive sense completes a right-handed system (vertical × longitudinal)

The package uses a right-handed Cartesian frame internally. Different authors assigned different Cartesian letters (x, y, z) to these physical directions — historically a source of confusion when diffractometer geometries are compared. The package accepts any right-handed orthogonal basis via the basis argument to AdHocDiffractometer.

Physical direction

Cartesian

Constant

vertical

+x

XHAT

longitudinal

+y

YHAT

transverse

+z

ZHAT

Used by: psic, sixc, kappa6c, zaxis, s2d2, fivec

Pass basis=BASIS_YOU (the default for these geometries).

Physical direction

Cartesian

Constant

vertical

+z

ZHAT

longitudinal

+y

YHAT

transverse

+x

XHAT

Convention of Busing & Levy.

Used by: fourcv, fourch, kappa4cv, kappa4ch

Also used by:

Pass basis=BASIS_BL (the default for these geometries).

Physical direction

Cartesian

Constant

vertical

+y

YHAT

longitudinal

+z

ZHAT

transverse

+x

XHAT

Used by: NeXus

Also used by:

Physical direction

Cartesian

Constant

vertical

+z

ZHAT

longitudinal

+x

XHAT

transverse

+y

YHAT

Used by: Hkl

The BASIS_YOU and BASIS_BL constants are exported from the package.


Axis sign convention#

Each stage’s rotation axis is a signed unit vector: +nHat means right-handed rotation, -nHat means left-handed (equivalent to right-handed about the negated axis). Physical direction names ("vertical", "transverse", "longitudinal") are resolved against the geometry’s basis dict.

See parse_axis().


Stage stacking#

Stages are stacked: each stage sits on its parent and its rotation modifies the orientation of everything above it. The parent attribute names the stage directly below (None for floor-mounted stages). The combined sample rotation matrix is the ordered product from floor to innermost stage.

See Stage.


Monochromatic radiation#

The package assumes monochromatic radiation throughout — all calculations are performed at a fixed wavelength. Energy and wavelength are related by \(hc = 12.3984\,\text{keV·Å}\) exactly (2019 SI redefinition).

g.wavelength = 1.5406  # Å  (Cu Kα)

See Set Wavelength / Energy and radiation.


The B, U, and UB matrices#

Three matrices connect Miller indices to motor angles:

Symbol

Name

Role

B

B matrix

Encodes the reciprocal lattice; maps hkl → crystal Cartesian frame

U

U matrix

Orthonormal; encodes crystal mounting on the diffractometer

UB

UB matrix

Maps hkl → phi-axis frame; determined from orienting reflections

The B matrix is constructed from unit-cell parameters \((a, b, c, \alpha, \beta, \gamma)\). U is determined by measuring two or more Bragg reflections. UB = U × B maps Miller indices directly to the lab frame.

See Orient a Crystal, Define the Sample Lattice, Case Study: Coordinate Convention and UB Matrix, and Lattice.


Diffraction modes#

A diffraction mode is a ConstraintSet that describes how forward() resolves the free degrees of freedom: which stages are fixed, which are coupled, and which are solved freely. Available modes depend on the geometry.

# Four-circle geometries use "bisecting"
g = ahd.presets.fourcv()
g.mode_name = "bisecting"

# Six-circle psic uses named variants
g = ahd.presets.psic()
g.mode_name = "bisecting_vertical"   # vertical scattering plane
g.mode_name = "bisecting_horizontal" # horizontal scattering plane

Modes can also be added at run time:

from ad_hoc_diffractometer import ConstraintSet, SampleConstraint

g.modes["my_chi45"] = ConstraintSet([SampleConstraint("chi", 45.0)])
g.mode_name = "my_chi45"

See Switch Diffraction Modes, Work with Constraints and Diffraction Modes, and mode.


Diffraction constraints#

Specifying (h, k, l) provides exactly 3 equations on the motor angles. A geometry with N real axes therefore has N − 3 free parameters that must each be resolved by a constraint. Every mode is a ConstraintSet — an ordered list of constraints equal in length to N − 3.

Three constraint categories exist:

Sample constraints fix one sample motor angle at a declared value, or express the bisecting relational condition:

from ad_hoc_diffractometer import SampleConstraint, BisectConstraint

SampleConstraint("chi", 90.0)       # chi fixed at 90°
BisectConstraint("eta", "delta")    # eta = delta / 2  (psic bisecting)

Detector constraints fix one detector stage at a declared value, or constrain the azimuthal angle of Q (the "qaz" pseudo-angle from You 1999 eq. 18: tan(qaz) = tan(delta) / sin(nu)):

from ad_hoc_diffractometer import DetectorConstraint

DetectorConstraint("nu", 0.0)       # nu fixed at 0°
DetectorConstraint("qaz", 90.0)     # Q in the vertical plane

Reference constraints express a condition between Q and an external reference vector n̂ (surface normal, polarisation axis, etc.):

from ad_hoc_diffractometer import ReferenceConstraint

ReferenceConstraint("alpha_i", 5.0)  # incidence angle fixed
ReferenceConstraint("a_eq_b", True)  # alpha_i = beta_out (symmetric)

Taxonomy rules: at most one DetectorConstraint, at most one ReferenceConstraint.

Two checks distinguish solver availability from prerequisite satisfaction:

  • constraint.is_implemented(geometry) — returns True when a forward solver exists for this constraint on this geometry.

  • rc.has_reference_vector(geometry) — returns True when the required n̂ vector is set on the geometry (a prerequisite for reference constraints, independent of solver availability).

See Work with Constraints and Diffraction Modes and mode.


Kappa virtual angles#

Kappa geometries (kappa4cv, kappa4ch, kappa6c) have real motor angles (komega, kappa, kphi) and virtual Eulerian pseudoangles (omega, chi, phi) that are more intuitive to specify.

Geometry-aware decomposition#

The conversion is derived directly from the preset’s actual signed stage axes via the rotation-matrix identity

\[ R(\hat{n}_{\kappa\varphi},\,\kappa\varphi) \cdot R(\hat{n}_{\kappa},\,\kappa) \cdot R(\hat{n}_{\kappa\omega},\,\kappa\omega) \;=\; R(\hat{n}_{\kappa\varphi},\,\varphi) \cdot R(\hat{n}_{\chi,\,\text{eq}},\,\chi) \cdot R(\hat{n}_{\kappa\omega},\,\omega). \]

Each kappa preset declares the four signed axis vectors (n_komega, n_kappa, n_kphi, n_chi_eq) in a KappaPseudoAngleConvention instance attached to geometry.kappa_pseudo_angle_convention. The conversion functions eulerian_to_kappa_axes() and kappa_to_eulerian_axes() solve the identity above analytically — no Newton iteration is required:

from ad_hoc_diffractometer.kappa import (
    eulerian_to_kappa_axes, kappa_to_eulerian_axes,
)

g = ahd.presets.kappa4cv()
convention = g.kappa_pseudo_angle_convention

# Virtual Eulerian angles → real kappa motor angles (two branches)
komega, kappa, kphi = eulerian_to_kappa_axes(
    omega, chi, phi, convention, branch=+1
)

# Real kappa angles → virtual Eulerian pseudoangles
omega, chi, phi = kappa_to_eulerian_axes(komega, kappa, kphi, convention)

Branch selection: branch=+1 (default) returns the kappa solution with the smaller |κ| (the natural identity branch); branch=-1 returns the chi-mirrored solution.

The kappa rotation axis itself is computed from the convention via

\[ \hat{n}_{\kappa} \;=\; \cos\alpha \cdot \hat{n}_{\kappa\omega} \;+\; \sin\alpha \cdot \hat{n}_{\chi,\,\text{eq}} \]

(see kappa_axis_from_eulerian()).

Divergence from Walko (2016) eq. [16]#

The original Walko closed form

\[ \sin(\chi/2) = \sin(\kappa/2) \cdot \sin(\alpha_0), \qquad \text{offset} = \arccos\bigl(\cos(\kappa/2)/\cos(\chi/2)\bigr) \]

is correct only for the axis convention assumed in Walko’s derivation — omega about the transverse axis, chi about the longitudinal axis, phi about the transverse axis, all with a specific handedness. No preset shipped with this package matches that convention exactly: kappa4cv (BL) places komega along -TRANSVERSE; kappa4ch (BL) along -VERTICAL; kappa6c (You) along -TRANSVERSE with a horizontal mu base. The textbook formula therefore does not preserve the scattering vector for any non-zero chi in any of these presets, which manifested as silent "No solutions" returns from the kappa virtual-angle solver (issue #241).

The textbook helpers eulerian_to_kappa() and kappa_to_eulerian() are retained as reference implementations of the published closed form (with deprecation warnings in their docstrings) but are not used inside the solver.

Modes accept virtual angle names#

Kappa modes accept the virtual angle names directly in SampleConstraint:

from ad_hoc_diffractometer import ConstraintSet, SampleConstraint

g = ahd.presets.kappa4cv()
# "chi" is a virtual angle — the kappa inversion solver handles it
g.modes["fixed_chi"] = ConstraintSet([SampleConstraint("chi", 90.0)])

See eulerian_to_kappa_axes(), kappa_to_eulerian_axes(), KappaPseudoAngleConvention, and the Work with Constraints and Diffraction Modes guide.


Surface geometry and reference vector#

Some diffraction modes and pseudo-angle functions require an external reference vector supplied as Miller indices (h, k, l). Two separate vectors may be set:

  • surface_normal — direction perpendicular to the sample surface; used by incidence/exit angle functions and surface diffraction modes.

  • azimuthal_reference — direction defining ψ = 0; used by psi_angle and fixed_psi_* modes.

g.surface_normal = (0, 0, 1)    # (001)-cut sample
g.azimuthal_reference = (1, 0, 0)

Vectors are stored as Miller indices and converted to the lab frame internally via the UB matrix.

See Surface Geometry and the Reference Vector and reference.


Custom exceptions#

Two exceptions signal specific failure modes of the forward solver:

EwaldSphereViolation : Raised when |Q| > 4π/λ — the requested reflection cannot be reached at the current wavelength regardless of motor angles. Carries attributes q_mag, q_max, and wavelength.

ConstraintViolation : Raised when a solver returns a solution that violates a declared constraint beyond the display-precision tolerance (indicates a solver error or an unimplemented virtual-angle constraint). Carries attributes solution_index, constraint_repr, residual, and tolerance.

from ad_hoc_diffractometer import EwaldSphereViolation, ConstraintViolation

try:
    solutions = g.forward(10, 10, 10)   # likely unreachable
except EwaldSphereViolation as e:
    print(f"|Q| = {e.q_mag:.3f} Å⁻¹ exceeds Ewald sphere (max {e.q_max:.3f} Å⁻¹)")

See Forward and Inverse Computations and mode.


Forward and inverse computations#

  • Forward (forward()): given (h, k, l), find the motor angles satisfying the Bragg condition. Returns a list of 0 to ~12 solutions depending on geometry and mode.

  • Inverse (inverse()): given motor angles, find the unique (h, k, l) in the Bragg condition. Requires a UB matrix.

See Forward and Inverse Computations.


The ψ angle#

Two definitions of ψ appear in the literature:

  • You (1999): azimuthal angle of a reference vector about Q — constant for a given (hkl, UB); a crystal-orientation diagnostic. See psi().

  • Busing & Levy (1967): angle of sample rotation about Q relative to a reference orientation — the quantity physically varied in a ψ scan. See psi_trajectory().

See Plan a Trajectory.


Serialization#

The complete diffractometer state — geometry, wavelength, lattice, reflections, UB matrix, and all parameters — can be saved and restored via to_dict() / from_dict() on AdHocDiffractometer. The dict contains only JSON-compatible types; save to JSON (stdlib) or YAML (pyyaml) without loss.

See Save and Restore a Diffractometer Configuration.