Coordinate Systems and Transformations#

This guide explains coordinate system handling in CDIutils, covering the transformations between detector, reciprocal space, and direct space frames.

Critical for: Quantitative strain analysis, coordinate-dependent plotting, multi-Bragg peak analysis.

Overview of Coordinate Systems#

CDIutils manages three primary coordinate systems:

System

Convention

Usage

Detector Frame

Matrix indices (i, j, k)

Raw data, ROI selection

Reciprocal Space (Lab)

CXI or XU convention

Q-space gridding, resolution

Direct Space (Lab)

CXI or XU convention

Reconstruction, strain fields

The Geometry and SpaceConverter classes handle all transformations.

Geometry Class#

The Geometry class defines experimental geometry:

  • Beam direction

  • Sample rotation axes

  • Detector rotation axes

  • Detector pixel orientation

  • Sample surface normal (horizontal vs vertical mounting)

Custom Geometry#

For custom or modified geometries:

geometry = Geometry(
    sample_circles=["x-", "y-"],          # eta, phi
    detector_circles=["y-", "x-"],        # nu, delta
    detector_axis0_orientation="y-",      # rows
    detector_axis1_orientation="x+",      # columns
    beam_direction=[1, 0, 0],             # along x
    sample_surface_normal=[0, 1, 0],      # vertical is y
    is_cxi=True                           # using CXI convention
)

Axis orientation notation:

  • "x+": Positive x direction

  • "y-": Negative y direction

  • "z+": Positive z direction

CXI ↔ xrayutilities Conversion#

The geometry can be converted between conventions:

# Start in CXI convention
geometry = Geometry.from_setup(beamline="ID01")
print(geometry.is_cxi)  # True

# Convert to xrayutilities convention
geometry.cxi_to_xu()
print(geometry.is_cxi)  # False

# Convert back
geometry.xu_to_cxi()
print(geometry.is_cxi)  # True

When to convert:

  • CXI: For output, visualisation, CXI file writing

  • XU: For xrayutilities gridding (internal to SpaceConverter)

SpaceConverter Class#

The SpaceConverter performs coordinate transformations using xrayutilities under the hood.

Initialization#

from cdiutils import Geometry, SpaceConverter

geometry = Geometry.from_setup(beamline="ID01")

converter = SpaceConverter(
    geometry=geometry,
    energy=9000,  # eV
    roi=[100, 400, 50, 450, 80, 380],  # detector ROI
    det_calib_params={
        "distance": 1.2,         # metres
        "pwidth1": 55e-6,        # metres
        "pwidth2": 55e-6,
        "cch1": 512,             # pixels
        "cch2": 512,
        "tiltazimuth": 0.0,      # radians
        "tilt": 0.0
    }
)

Detector → Reciprocal Space#

Transform detector coordinates (pixels + angles) to reciprocal space (Qx, Qy, Qz):

import numpy as np

# Set motor angles for the scan
angles = {
    "eta": np.linspace(30.0, 32.0, 100),  # degrees
    "phi": 0.0,
    "nu": 35.5,
    "delta": 32.1
}
converter.angles = angles

# Initialise Q-space transformation
converter.init_qspace_transformations(
    scan_shape=data.shape  # (nz, ny, nx)
)

# Get Q coordinates for each detector pixel
qx, qy, qz = converter.get_qspace_coords()

# Or use interpolator for gridding
converter.init_interpolator(
    target_voxel_size=0.05,  # 1/nm
    space="reciprocal"
)

# Grid to orthogonal Q-space
data_q = converter.detector_to_lab_q(data)

Reciprocal → Direct Space#

After phase retrieval (which outputs in reciprocal space), transform to direct space:

# Assuming 'obj' is complex 3D reconstruction from phasing

# Initialise direct space interpolator
converter.init_interpolator(
    target_voxel_size=5e-9,  # metres (5 nm)
    space="direct"
)

# Transform to orthogonal direct space
obj_direct = converter.q_to_lab_direct(obj)

# Access voxel size
voxel_size = converter.direct_lab_voxel_size
print(f"Resolution: {voxel_size[0]*1e9:.2f} nm/voxel")

Coordinate Transformations Summary#

# Detector → Reciprocal space (Q)
data_q = converter.detector_to_lab_q(data_detector)

# Reciprocal → Direct space (real space)
obj_direct = converter.q_to_lab_direct(obj_q)

# Direct → Reciprocal (for forward modelling)
obj_q = converter.lab_direct_to_q(obj_direct)

Practical Examples#

Example 1: Manual Coordinate Gridding#

from cdiutils import Geometry, SpaceConverter
from cdiutils.io import ID01Loader
import numpy as np

# Load data
loader = ID01Loader(
    sample_name="S0001",
    scan=42,
    data_dir="/path/to/data"
)
data, angles = loader.load_data(roi=[100, 400, 50, 450, 80, 380])
energy = loader.load_energy()

# Setup geometry
geometry = Geometry.from_setup(beamline="ID01")

# Create converter
converter = SpaceConverter(
    geometry=geometry,
    energy=energy,
    roi=loader.roi,
    det_calib_params=loader.load_det_calib_params()
)
converter.angles = angles

# Transform to Q-space
converter.init_qspace_transformations(scan_shape=data.shape)
converter.init_interpolator(target_voxel_size=0.05, space="reciprocal")
data_q = converter.detector_to_lab_q(data)

print(f"Q-space shape: {data_q.shape}")
print(f"Q voxel size: {converter.q_lab_interpolator.target_voxel_size}")

Example 2: Save/Load Converter State#

The converter can be saved to HDF5 for reproducibility:

# Save converter configuration
converter.to_file("converter_config.h5")

# Later, reload
from cdiutils import SpaceConverter

converter = SpaceConverter.from_file("converter_config.h5")

# All settings preserved:
# - Geometry
# - Energy, ROI
# - Detector calibration
# - Transformation matrices

Example 3: Coordinate-Dependent Plotting#

from cdiutils.plot import plot_volume_slices

# Plot in CXI convention with physical units
plot_volume_slices(
    data=strain,
    voxel_size=converter.direct_lab_voxel_size,  # metres
    convention="cxi",
    views=("z+", "y-", "x+"),
    title="Strain field (CXI convention)"
)

# Plot in XU convention
plot_volume_slices(
    data=strain,
    voxel_size=converter.direct_lab_voxel_size,
    convention="xu",
    views=("x-", "y+", "z-"),
    title="Strain field (XU convention)"
)

Common Pitfalls#

1. Mixing conventions inconsistently

❌ Wrong:

geometry = Geometry.from_setup(beamline="ID01")  # CXI
geometry.cxi_to_xu()  # Convert to XU
# ... later ...
plot_volume_slices(data, convention="cxi")  # Mismatch!

✅ Correct:

geometry = Geometry.from_setup(beamline="ID01")
# Keep in CXI throughout, or track conversions explicitly

2. Incorrect sample orientation

❌ Wrong:

# Sample mounted vertically, but declared horizontal
geometry = Geometry.from_setup(
    beamline="ID01",
    sample_orientation="horizontal"  # ❌
)

✅ Correct:

geometry = Geometry.from_setup(
    beamline="ID01",
    sample_orientation="vertical"  # ✅
)

3. Forgetting to set angles

❌ Wrong:

converter = SpaceConverter(geometry=geometry, energy=9000)
converter.init_qspace_transformations(scan_shape=data.shape)
# ❌ converter.angles not set!

✅ Correct:

converter = SpaceConverter(geometry=geometry, energy=9000)
converter.angles = angles  # ✅ Set angles first
converter.init_qspace_transformations(scan_shape=data.shape)

4. Incompatible voxel sizes

When transforming reciprocal → direct space, voxel sizes are related by:

\[\begin{split}\\Delta x \\cdot \\Delta Q_x = 2\\pi / N_x\end{split}\]

Requesting arbitrary voxel size may require interpolation.

Advanced Topics#

Custom Sample Surface Normal#

For non-standard sample orientations:

geometry = Geometry.from_setup(beamline="ID01")

# Override sample surface normal (in CXI frame)
geometry.sample_surface_normal = [0.707, 0.707, 0]  # 45° tilted

converter = SpaceConverter(geometry=geometry, energy=9000)

Multi-Bragg Peak Analysis#

For full 3D displacement, measure multiple Bragg peaks:

# Measure (111) peak
converter_111 = SpaceConverter(geometry=geometry, energy=9000)
# ... process ...
displacement_111 = get_displacement(...)

# Measure (200) peak
converter_200 = SpaceConverter(geometry=geometry, energy=9000)
# ... process ...
displacement_200 = get_displacement(...)

# Combine to get full 3D displacement
# (requires alignment and inversion, see literature)

See Also#