orientation#

Import: ad_hoc_diffractometer.orientation

Functions#

angles_to_phi_vector(geometry, **motor_angles)

Convert a set of motor angles to the scattering vector expressed in the phi-axis (innermost sample-stage) frame. This is the foundational computation needed for U and UB matrix determination.

ub_from_one_reflection(sample, reflection, reference_hkl, reference_stage)

Compute a provisional U and UB from one reflection using the Rodrigues rotation that takes the crystal direction B @ reference_hkl to the lab direction given by reference_stage. Sets sample.U and sample.UB in-place; returns UB.

ub_from_two_reflections_bl1967(sample, r1, r2)

Compute U and UB from two orienting reflections using the Busing & Levy (1967) algorithm (eqs. 23-27). Sets sample.U and sample.UB in-place; returns UB.

ub_from_three_reflections_bl1967(sample, r1, r2, r3)

Compute UB directly from three reflections using the Busing & Levy (1967) direct method (eqs. 29-31), without prior knowledge of the lattice. Sets sample.UB in-place; also sets sample.U if a lattice B is available. Returns UB.

ub_identity(sample)

Set U = I, UB = B; return UB. The crudest assumption.

Future functions (separate issues):

ub_from_three_reflections_bl1967 — Busing & Levy 1967, eqs. 29-31 (#6)

References

  • Busing & Levy, Acta Cryst. 22, 457-464 (1967)

  • You, J. Appl. Cryst. 32, 614-623 (1999)

Functions#

_compute_q_phi(→ numpy.ndarray)

Compute Q_phi from angle values without mutating any geometry state.

_compute_q_phi_cached(→ numpy.ndarray)

Compute Q_phi using cached rotation matrices for fixed stages.

_gram_schmidt_triple(→ numpy.ndarray)

Build a right-handed orthonormal 3×3 matrix from two linearly independent

angles_to_phi_vector(→ numpy.ndarray)

Convert a set of motor angles to the scattering vector in the phi frame.

ub_from_one_reflection([reference_stage])

Compute a provisional U and UB from one reflection (Rodrigues method).

ub_from_three_reflections_bl1967(→ numpy.ndarray)

Compute UB directly from three reflections (Busing & Levy 1967, eqs. 29-31).

ub_from_two_reflections_bl1967(→ numpy.ndarray)

Compute U and UB from two orienting reflections (Busing & Levy 1967, eqs. 23-27).

ub_identity(→ numpy.ndarray)

Set U so the crystal-Cartesian frame aligns with the physical

Module Contents#

ad_hoc_diffractometer.orientation._compute_q_phi(sample_stages: list, detector_stages: list, angles: dict[str, float], two_pi_over_lambda: float, y_eff: ndarray) ndarray[source]#

Import: ad_hoc_diffractometer.orientation._compute_q_phi

Compute Q_phi from angle values without mutating any geometry state.

This is the inner hot-path function called hundreds of times per forward() invocation. It avoids all attribute mutation, dict copying, and save/restore overhead.

Parameters:
  • sample_stages (list of Stage) – Sample stages in stacking order (floor-most first).

  • detector_stages (list of Stage) – Detector stages in stacking order (floor-most first).

  • angles (dict[str, float]) – Motor angles in degrees, keyed by stage name. Stages not present in the dict use the stage’s current angle attribute.

  • two_pi_over_lambda (float) – Pre-computed 2 * pi / wavelength.

  • y_eff (numpy.ndarray, shape (3,)) – Pre-computed effective beam direction R_inc.T @ y_hat.

Returns:

Q_phi – Scattering vector in the phi frame, in Å⁻¹.

Return type:

numpy.ndarray, shape (3,)

ad_hoc_diffractometer.orientation._compute_q_phi_cached(sample_stages: list, detector_stages: list, angles: dict[str, float], two_pi_over_lambda: float, y_eff: ndarray, cached_Z_prefix: ndarray | None, free_sample_indices: list[int] | None, cached_D: ndarray | None) ndarray[source]#

Import: ad_hoc_diffractometer.orientation._compute_q_phi_cached

Compute Q_phi using cached rotation matrices for fixed stages.

When some stages have fixed angles across all Newton iterations, their rotation matrices are constant and can be pre-computed. This function uses the cached prefix product for the fixed portion and only computes rotation matrices for the free stages.

Parameters:
  • sample_stages (list of Stage) – All sample stages in stacking order.

  • detector_stages (list of Stage) – All detector stages in stacking order.

  • angles (dict[str, float]) – Motor angles in degrees.

  • two_pi_over_lambda (float)

  • y_eff (numpy.ndarray, shape (3,))

  • cached_Z_prefix (numpy.ndarray or None) – Pre-computed product of rotation matrices for all sample stages before the first free stage. None means no caching (compute all).

  • free_sample_indices (list of int or None) – Indices into sample_stages of the free (varying) stages. None means all stages are free (no caching).

  • cached_D (numpy.ndarray or None) – Pre-computed detector rotation matrix. None means compute it.

Returns:

Q_phi

Return type:

numpy.ndarray, shape (3,)

ad_hoc_diffractometer.orientation._gram_schmidt_triple(v1: ndarray, v2: ndarray) ndarray[source]#

Import: ad_hoc_diffractometer.orientation._gram_schmidt_triple

Build a right-handed orthonormal 3×3 matrix from two linearly independent vectors using Gram-Schmidt orthogonalisation.

The columns of the returned matrix T are:

t1 = v1 / |v1|
t3 = t1 × v2 / |t1 × v2|
t2 = t3 × t1

so that t1 v1, t2 lies in the plane of v1 and v2, and t3 is perpendicular to that plane. The triple (t1, t2, t3) is right-handed and orthonormal.

Parameters:
  • v1 (numpy.ndarray, shape (3,)) – Primary vector (must be non-zero).

  • v2 (numpy.ndarray, shape (3,)) – Secondary vector (must not be parallel to v1).

Returns:

T – Columns are [t1, t2, t3], forming a right-handed orthonormal basis.

Return type:

numpy.ndarray, shape (3, 3)

Raises:

ValueError – If v1 is the zero vector or v1 and v2 are parallel (cross product is zero).

Notes

This is the Gram-Schmidt construction used in Busing & Levy (1967) to build the orthonormal triples Tc (crystal frame) and (phi frame) for the two-reflection orientation algorithm (eqs. 23-27).

ad_hoc_diffractometer.orientation.angles_to_phi_vector(geometry, **motor_angles: float) ndarray[source]#

Import: ad_hoc_diffractometer.orientation.angles_to_phi_vector

Convert a set of motor angles to the scattering vector in the phi frame.

The “phi frame” is the coordinate system seen from the innermost sample stage — the frame in which crystal reflections are expressed when computing the orientation (U) matrix.

Algorithm (Busing & Levy 1967, section “The phi-axis frame”):

  1. Compute the total sample rotation matrix Z as the product of all sample stage rotation matrices in outermost-leftmost order (BL1967 standard convention):

    Z = R_0 @ R_1 @ ... @ R_{n-1}
    

    where R_0 is the floor-most (outermost) stage and R_{n-1} is the innermost stage closest to the sample. Z then maps phi-frame vectors to lab-frame vectors: v_lab = Z @ v_phi. Stages not supplied keep their current angle attribute.

  2. Compute the total detector rotation matrix D in the same outermost-leftmost order.

  3. The incident-beam unit vector in the lab frame is ŷ (longitudinal direction, geometry.basis["longitudinal"]).

  4. The scattered-beam unit vector in the lab frame is D @ ŷ.

  5. The scattering vector in the lab frame is:

    Q_lab = (2π / λ) * (D @ ŷ - ŷ)
    
  6. Rotate Q_lab back through the sample stack:

    Q_phi = Z⁻¹ @ Q_lab = Zᵀ @ Q_lab
    

    (Z is orthogonal, so Z⁻¹ = Zᵀ.)

Parameters:
  • geometry (AdHocDiffractometer) – The diffractometer geometry. Must have wavelength set (not None).

  • **motor_angles (float) – Motor angles in degrees, keyed by stage name. All stages present in the geometry may be supplied; stages not supplied keep their current angle attribute. Only sample and detector stages affect the result; other stages (if any) are ignored.

Returns:

Q_phi – Scattering vector in the phi frame, in units of Å⁻¹.

Return type:

numpy.ndarray, shape (3,)

Raises:
  • KeyError – If a supplied stage name does not exist in the geometry.

  • ValueError – If geometry.wavelength is None.

  • ValueError – If the geometry has no sample stages.

  • ValueError – If the geometry has no detector stages.

Notes

The function is stateless: it does not modify the geometry’s stage angles. It computes rotation matrices directly from the supplied motor_angles values, so it is safe to call from multiple threads on the same geometry instance.

The scattering vector Q_phi is independent of which sample stage is designated the “phi” axis; it is expressed in the frame of the last sample stage in the stacking order (the one closest to the sample).

Examples

>>> import ad_hoc_diffractometer as ahd
>>> g = ahd.psic()
>>> g.wavelength = 1.5406          # Cu Kα in Å
>>> Q_phi = ahd.angles_to_phi_vector(
...     g,
...     mu=0, eta=20.97, chi=90, phi=0, nu=0, delta=41.94,
... )
>>> Q_phi  # scattering vector for sapphire (006) in phi frame
array([...])

References

Busing & Levy, Acta Cryst. 22, 457-464 (1967) — phi-axis frame You, J. Appl. Cryst. 32, 614-623 (1999) — psic geometry conventions

ad_hoc_diffractometer.orientation.ub_from_one_reflection(sample, reflection, reference_hkl: tuple[float, float, float] = (0.0, 0.0, 1.0), reference_stage=None) ndarray[source]#

Import: ad_hoc_diffractometer.orientation.ub_from_one_reflection

Compute a provisional U and UB from one reflection (Rodrigues method).

A common first step in a diffractometer alignment session: assume that a known high-symmetry crystal direction (reference_hkl) is parallel to a specific diffractometer axis (reference_stage). This gives a “fake” UB sufficient to predict where to scan for a second reflection.

The algorithm:

  1. Compute the crystal-frame direction: Bh = B @ reference_hkl (normalized to Bh_hat).

  2. Extract the lab-frame direction from reference_stage (normalized to r_hat).

  3. Find the minimal rotation (Rodrigues) that takes Bh_hat to r_hat: axis  = cross(Bh_hat, r_hat) angle = arccos(clip(dot(Bh_hat, r_hat), -1, 1)) U     = rotation_matrix(axis, degrees(angle))

  4. UB = U @ B

  5. Store sample.U = U and sample.UB = UB; return UB.

Edge cases:

  • Parallel (angle 0): Bh_hat already points along r_hat; U = I.

  • Anti-parallel (angle π): choose an arbitrary perpendicular axis (the first vector from [XHAT, YHAT, ZHAT] not parallel to Bh_hat); rotate 180° about it.

Note

The result is approximate. If reference_hkl is not truly parallel to reference_stage.axis (e.g. χ = 89.32° rather than 90.00°), predicted angles for subsequent reflections will be slightly wrong. Refine with ub_from_two_reflections_bl1967() once a second reflection is measured.

Parameters:
  • sample (Sample) – The sample whose U and UB attributes are updated in-place. sample.lattice must be set. If sample.parent is set, it is used to resolve a string reference_stage.

  • reflection (Reflection or str) – A Reflection object or the name of a reflection in sample.reflections.

  • reference_hkl (tuple of float, optional) – Miller indices of the crystal direction assumed to be aligned with reference_stage. Default (0, 0, 1) (c-axis).

  • reference_stage (Stage, str, or None, optional) –

    The diffractometer axis assumed to be parallel to reference_hkl.

    • Stage object: stage.axis is used directly (recommended; the sign convention is already encoded in the Stage).

    • str: looked up as sample.parent.stage(name).

    • None and sample.parent is set: defaults to sample.parent.stage("phi").

    • None and sample.parent is None: raises ValueError.

Returns:

UB – Sets sample.U and sample.UB in-place before returning.

Return type:

numpy.ndarray, shape (3, 3)

Raises:
  • KeyError – If reflection is a string not found in sample.reflections.

  • ValueError – If reference_stage cannot be resolved (no parent, no stage).

  • ValueError – If reference_hkl maps to the zero vector under B.

Examples

>>> g = psic()
>>> g.add_sample("sapphire", Lattice(a=4.758, c=12.991))
>>> g.sample = "sapphire"
>>> g.add_reflection("r1", hkl=(0, 0, 6),
...                  angles={"mu": 0, "eta": 20.97, "chi": 90,
...                          "phi": 0, "nu": 0, "delta": 41.94})
>>> g.sample.reflections.setor0("r1")
>>> UB = ub_from_one_reflection(
...     g.sample, "r1",
...     reference_hkl=(0, 0, 1),
...     reference_stage=g.stage("phi"),
... )
ad_hoc_diffractometer.orientation.ub_from_three_reflections_bl1967(sample, r1, r2, r3) ndarray[source]#

Import: ad_hoc_diffractometer.orientation.ub_from_three_reflections_bl1967

Compute UB directly from three reflections (Busing & Levy 1967, eqs. 29-31).

This method requires no prior knowledge of the lattice: it computes UB directly by matrix inversion from three measured reflections. If a lattice B matrix is available on the sample, U is also derived.

Algorithm (BL1967 eqs. 28-31):

  1. For each reflection i compute the phi-frame scattering vector hiφ = angles_to_phi_vector(geometry, **ri.angles) (eq. 28 gives the magnitude; angles_to_phi_vector already carries the full vector in Å⁻¹).

  2. Stack as column matrices:

    Hφ = [h1φ | h2φ | h3φ]    (3×3, columns are phi-frame vectors)
    H  = [h1  | h2  | h3 ]    (3×3, columns are Miller-index triples)
    
  3. UB = @ inv(H) (eq. 31).

  4. If sample.lattice is set: U = UB @ inv(B) (derived from UB).

  5. Store sample.UB = UB (first) and sample.U = U; return UB.

Parameters:
  • sample (Sample) – The sample whose UB (and U) attributes are updated in-place. sample.parent must be a geometry with wavelength set.

  • r1 (Reflection or str) – Three orienting reflections. Each may be a Reflection object or the name of a reflection in sample.reflections.

  • r2 (Reflection or str) – Three orienting reflections. Each may be a Reflection object or the name of a reflection in sample.reflections.

  • r3 (Reflection or str) – Three orienting reflections. Each may be a Reflection object or the name of a reflection in sample.reflections.

Returns:

UBsample.UB is set first (directly, via eq. 31), then sample.U = UB @ inv(B) is derived. Both are set in-place.

Return type:

numpy.ndarray, shape (3, 3)

Raises:
  • KeyError – If any of r1, r2, r3 is a string not found in sample.reflections.

  • TypeError – If any argument is not a Reflection or a string.

  • ValueError – If sample.parent is None.

  • ValueError – If the three Miller-index column matrix H is singular (|det(H)| < tol), i.e. the three hkl vectors are coplanar.

  • ValueError – If sample.parent.wavelength is None.

Warns:

UserWarning – If det(H) < 0, the hkl triples form a left-handed system. The computation proceeds but the sign convention may give U with det(U) = -1; consider swapping r1 and r2 to make det(H) > 0.

Notes

UB is computed first (sample.UB = @ H⁻¹). U is then derived as sample.U = UB @ B⁻¹. This is the opposite order from ub_from_two_reflections_bl1967, where U is computed first.

The method does not require a known lattice: H is formed from the raw hkl indices, not from B @ hkl. However, if sample.lattice is the package default (cubic, a = 1 Å) rather than a measured lattice, the derived U will not be physically meaningful.

If det(H) is exactly zero (degenerate reflections), numpy.linalg.inv will raise LinAlgError, which is caught and re-raised as ValueError.

Examples

>>> import ad_hoc_diffractometer as ahd
>>> import math
>>> g = ahd.psic()
>>> g.wavelength = 2 * math.pi
>>> g.sample.lattice = ahd.Lattice(a=2 * math.pi)
>>> g.add_reflection("r1", hkl=(1, 0, 0),
...     angles={"mu": 0, "eta": 30, "chi": 0, "phi": 0, "nu": 0, "delta": 60})
>>> g.add_reflection("r2", hkl=(0, 1, 0),
...     angles={"mu": 0, "eta": 30, "chi": 0, "phi": 90, "nu": 0, "delta": 60})
>>> g.add_reflection("r3", hkl=(0, 0, 1),
...     angles={"mu": 0, "eta": 30, "chi": 90, "phi": 30, "nu": 0, "delta": 60})
>>> UB = ahd.ub_from_three_reflections_bl1967(g.sample, "r1", "r2", "r3")

References

Busing & Levy, Acta Cryst. 22, 457-464 (1967), eqs. 28-31.

ad_hoc_diffractometer.orientation.ub_from_two_reflections_bl1967(sample, r1=None, r2=None) ndarray[source]#

Import: ad_hoc_diffractometer.orientation.ub_from_two_reflections_bl1967

Compute U and UB from two orienting reflections (Busing & Levy 1967, eqs. 23-27).

Given two reflections with known hkl and measured motor angles, and a known lattice (B matrix), this function computes the orientation matrix U and then UB = U @ B, storing both on the sample.

Algorithm (BL1967 eqs. 23-27):

  1. For each reflection, call angles_to_phi_vector() to get the scattering vector in the phi frame: u1φ, u2φ.

  2. From hkl and the lattice B matrix: h1c = B @ h1, h2c = B @ h2.

  3. Build orthonormal triple Tc in the crystal frame via Gram-Schmidt: t1c h1c, t2c in the plane of h1c and h2c, t3c = t1c × t2c.

  4. Build the matching triple in the phi frame from u1φ, u2φ.

  5. Compute U = @ Tc.T (eq. 27; Tc is orthogonal so Tc⁻¹ = Tc.T).

  6. Compute UB = U @ B.

  7. Store sample.U = U, sample.UB = UB; return UB.

Parameters:
  • sample (Sample) – The sample whose U and UB attributes are updated in-place. sample.lattice must be set. sample.parent must be a geometry with wavelength set (it is used to call angles_to_phi_vector).

  • r1 (Reflection, str, or None) – Primary orienting reflection. If None, defaults to sample.reflections.orienting_reflections[0].

  • r2 (Reflection, str, or None) – Secondary orienting reflection. If None, defaults to sample.reflections.orienting_reflections[1].

Returns:

UB – Sets sample.U (first) and sample.UB = sample.U @ B in-place before returning.

Return type:

numpy.ndarray, shape (3, 3)

Raises:
  • KeyError – If r1 or r2 is a string not found in sample.reflections.

  • ValueError – If r1 or r2 is None and the required orienting reflection has not been designated (setor0/setor1 not called).

  • ValueError – If sample.parent is None (needed to call angles_to_phi_vector).

  • ValueError – If sample.parent.wavelength is None.

  • ValueError – If the two reflections are parallel in the crystal frame (h1c and h2c collinear) or in the phi frame (u1φ and u2φ collinear).

  • TypeError – If r1 or r2 is not a Reflection, string, or None.

Notes

U is computed first (sample.U = @ Tc.T), then UB is derived from it (sample.UB = sample.U @ B).

The wavelength used for angles_to_phi_vector is taken from sample.parent.wavelength. If a reflection carries its own wavelength attribute, that is not used here; the geometry’s wavelength governs the conversion from motor angles to Q_phi.

References

Busing & Levy, Acta Cryst. 22, 457-464 (1967), eqs. 23-27.

Examples

>>> import ad_hoc_diffractometer as ahd
>>> g = ahd.psic()
>>> g.wavelength = 1.5406
>>> g.add_sample("sapphire", ahd.Lattice(a=4.758, c=12.991))
>>> g.sample = "sapphire"
>>> g.add_reflection("r1", hkl=(0, 0, 6),
...     angles={"mu": 0, "eta": 20.97, "chi": 90, "phi": 0,
...             "nu": 0, "delta": 41.94})
>>> g.add_reflection("r2", hkl=(1, 0, 4),
...     angles={"mu": 0, "eta": 23.72, "chi": 57.04, "phi": 0,
...             "nu": 0, "delta": 48.13})
>>> g.sample.reflections.setor0("r1")
>>> g.sample.reflections.setor1("r2")
>>> UB = ahd.ub_from_two_reflections_bl1967(g.sample)
ad_hoc_diffractometer.orientation.ub_identity(sample) ndarray[source]#

Import: ad_hoc_diffractometer.orientation.ub_identity

Set U so the crystal-Cartesian frame aligns with the physical (longitudinal, vertical, transverse) triple, and UB = U @ B.

The default UB placeholder used before a real UB is set by either reflection-based computation (ub_from_one_reflection(), ub_from_two_reflections_bl1967(), ub_from_three_reflections_bl1967()) or direct assignment (sample.UB = ... or sample.U = ...).

Definition (issue #280)#

The columns of U are the geometry’s physical-direction unit vectors expressed in basis-Cartesian coordinates:

U[:, 0] = +longitudinal   (the beam direction)
U[:, 1] = +vertical
U[:, 2] = +transverse

Under the Busing & Levy 1967 crystal-Cartesian convention (a* along crystal-x, b* in the crystal-x-y plane, c* completing the right-handed triple), this places:

  • the crystal a* axis physically along the beam,

  • the crystal-y axis physically along vertical,

  • the crystal-z axis physically along transverse.

This matches the hkl_soleil convention (U = identity aligns a* along the beam +x; see https://people.debian.org/~picca/hkl/hkl.html).

Why this is basis-independent#

The geometry’s basis assigns Cartesian unit vectors to the three physical-direction labels. The columns of U are taken directly from those physical-direction vectors, so the resulting physical crystal orientation does not depend on which Cartesian direction the basis labels assign to (vertical, longitudinal, transverse). Two geometries representing the same physical equipment under different basis labelings (e.g. BASIS_BL vs BASIS_YOU) produce numerically different U matrices but identical physical Bragg orientations — and therefore identical forward() results.

The pre-#280 implementation set U = numpy.eye(3) in basis coordinates, which made the physical crystal orientation basis-dependent: under BASIS_BL the crystal a-axis ended up physically along transverse, but under BASIS_YOU the same code put it along vertical, breaking basis invariance of forward() reachability and |2θ| for the same equipment.

Why this is inclination-independent#

U is derived from the geometry’s declared basis vectors (a property of the YAML, set once), not from the inclination-tilted effective beam y_eff = R_inc.T @ y_hat. Tilting the entire diffractometer relative to the beamline (issue #15 inclination_matrix) does not silently re-orient the crystal: the crystal is mounted on the diffractometer, not on the beamline, so its reference orientation lives in the diffractometer’s intrinsic frame.

param sample:

The sample whose U and UB attributes are updated in-place. sample.parent must be set (the parent AdHocDiffractometer provides the basis vectors).

type sample:

Sample

returns:

UBUB = U @ B. Also stored on the sample as sample.UB.

rtype:

numpy.ndarray, shape (3, 3)

raises ValueError:

If sample.parent is None (cannot resolve the geometry’s basis without a parent).

References

  • Busing & Levy, Acta Cryst. 22, 457-464 (1967) — the crystal-Cartesian convention used in the B matrix.

  • Issue #280 — the rotation-composition-order audit that exposed the pre-existing basis bias of the old U = I placeholder.

  • hkl_soleil documentation — the published external convention this implementation matches.