Polarization Analyzer on the Detector Arm#

A polarization analyzer (often just polarizer) is a passive optical element on the detector arm. It is a crystal analyzer chosen so that the Bragg angle is close to 45° — i.e. the scattering angle 2θ is near 90° — which suppresses radiation polarized in the diffraction plane. Rotating the polarizer’s diffraction plane around the beam (the peta axis) selects which polarization direction is suppressed, allowing the beam polarization state to be probed.

The control system operates the polarizer motors; hklpy2 treats them as auxiliary positioners that are read alongside the diffractometer but do not affect reciprocal-space calculations.

Objective

Add three polarization-analyzer axes (pth, ptth, peta) to an E4CV diffractometer, demonstrate their use in a Bluesky session, and show how to save and restore the diffractometer configuration.

Polarizer axes (nominal names used here)

axis

description

pth

polarizer crystal Bragg angle (θ)

ptth

polarizer detector arm angle (2θ ≈ 90°)

peta

rotation of the polarizer diffraction plane around the beam

These names are conventional; substitute the names used at your beamline. Some installations add a fourth motor (pchi) for crystal alignment; it is added to reals exactly like the others and is otherwise invisible to the diffractometer.

Scope. Only polarizers mounted between the sample and the detector are in scope. Upstream (incident-beam) polarizers are out of scope for hklpy2.

Setup#

Create an E4CV diffractometer with the three polarizer axes declared alongside the four standard real motors. Pass all seven axes in the reals dictionary; the four that the solver knows about (omega, chi, phi, tth) are mapped automatically; the remaining three (pth, ptth, peta) appear as auxiliaries.

The solver axes (omega, chi, phi, tth) must appear in the order the solver expects — giving them out of order will cause incorrect motor mapping. Auxiliary axes may be appended in any order after the solver axes. See the Diffractometer axes guide for details.

import hklpy2

e4cv = hklpy2.creator(
    name="e4cv",
    reals=dict(
        omega=None,  # simulated motor
        chi=None,
        phi=None,
        tth=None,
        pth=None,  # polarizer theta
        ptth=None,  # polarizer 2-theta
        peta=None,  # rotation about the beam
    ),
)

Show the diffractometer position summary. The polarizer axes appear under auxiliaries.

e4cv.wh()
wavelength=1.0
pseudos: h=0, k=0, l=0
reals: omega=0, chi=0, phi=0, tth=0
auxiliaries: pth=0, ptth=0, peta=0

Show all ophyd component names.

print(f"{e4cv.component_names=}")
e4cv.component_names=('beam', 'h', 'k', 'l', 'omega', 'chi', 'phi', 'tth', 'pth', 'ptth', 'peta')

Orient the diffractometer#

Add a sample and two orientation reflections, then compute the UB matrix. The polarizer axes play no role in orientation.

e4cv.core.add_sample("vibranium", 4.04)

r1 = e4cv.core.add_reflection(
    dict(h=4, k=0, l=0),
    dict(omega=-145.451, chi=0, phi=0, tth=69.066),
)
r2 = e4cv.core.add_reflection(
    dict(h=0, k=4, l=0),
    dict(omega=-145.451, chi=0, phi=90, tth=69.066),
)
e4cv.core.calc_UB(r1, r2)
print("UB matrix computed.")
UB matrix computed.

Set the wavelength and position the polarizer near 90°#

The polarizer crystal is chosen so that its Bragg angle for the incident wavelength is close to 45°, putting ptth near 90°. The example below uses pyrolytic graphite PG(002) with d-spacing 3.355 Å; a wavelength of 4.7 Å gives pth ≈ 44.5° and ptth ≈ 89°.

At a real beamline the wavelength is typically determined by the monochromator and is read-only; the polarizer crystal and pth/ptth are chosen accordingly.

import math

d_spacing = 3.355  # PG(002) d-spacing in angstroms

# Set incident wavelength.  At a beamline this may be read-only.
e4cv.beam.wavelength.put(4.7)  # angstroms

# Compute polarizer Bragg angle (pth) and arm angle (ptth ≈ 90°).
wavelength = e4cv.beam.wavelength.get()
e4cv.pth.move(math.degrees(math.asin(wavelength / (2 * d_spacing))))
e4cv.ptth.move(e4cv.pth.position * 2)
e4cv.peta.move(0)

print(f"incident wavelength : {wavelength:.4f} Å")
print(f"PG(002) d-spacing   : {d_spacing:.4f} Å")
print(f"pth                 : {e4cv.pth.position:.4f} deg")
print(f"ptth                : {e4cv.ptth.position:.4f} deg")
print(f"peta                : {e4cv.peta.position:.4f} deg")
e4cv.wh()
incident wavelength : 4.7000 Å
PG(002) d-spacing   : 3.3550 Å
pth                 : 44.4629 deg
ptth                : 88.9258 deg
peta                : 0.0000 deg
wavelength=4.7
pseudos: h=0, k=0, l=0
reals: omega=0, chi=0, phi=0, tth=0
auxiliaries: pth=44.4629, ptth=88.9258, peta=0

Rotate the diffraction plane around the beam#

With ptth close to 90°, radiation polarized in the polarizer’s diffraction plane is suppressed. Rotating peta rotates that diffraction plane around the beam, selecting which linear polarization component is suppressed. pth and ptth are unchanged by peta.

The cells below move peta to several positions. In a real session this would be wrapped in a Bluesky plan (e.g. bp.scan over peta) and the detector signal would be recorded at each step.

for angle in (0, 45, 90):
    e4cv.peta.move(angle)
    print(
        f"peta = {e4cv.peta.position:6.2f} deg | "
        f"pth = {e4cv.pth.position:.4f} | "
        f"ptth = {e4cv.ptth.position:.4f}"
    )
peta =   0.00 deg | pth = 44.4629 | ptth = 88.9258
peta =  45.00 deg | pth = 44.4629 | ptth = 88.9258
peta =  90.00 deg | pth = 44.4629 | ptth = 88.9258

Read all axes together#

Because the polarizer axes are ophyd components on the diffractometer device, they are included in read() and will be recorded in every Bluesky run document automatically.

e4cv.read()
OrderedDict([('e4cv_beam_wavelength',
              {'value': 4.7, 'timestamp': 1777396392.54133}),
             ('e4cv_beam_energy',
              {'value': 2.63796166890571, 'timestamp': 1777396392.2805746}),
             ('e4cv_h', {'value': 0, 'timestamp': 1777396392.2809193}),
             ('e4cv_h_setpoint',
              {'value': 0, 'timestamp': 1777396392.2809606}),
             ('e4cv_k', {'value': 0, 'timestamp': 1777396392.2811275}),
             ('e4cv_k_setpoint',
              {'value': 0, 'timestamp': 1777396392.2811594}),
             ('e4cv_l', {'value': 0, 'timestamp': 1777396392.281286}),
             ('e4cv_l_setpoint',
              {'value': 0, 'timestamp': 1777396392.2813118}),
             ('e4cv_omega', {'value': 0, 'timestamp': 1777396392.5514412}),
             ('e4cv_chi', {'value': 0, 'timestamp': 1777396392.5514452}),
             ('e4cv_phi', {'value': 0, 'timestamp': 1777396392.551448}),
             ('e4cv_tth', {'value': 0, 'timestamp': 1777396392.5514514}),
             ('e4cv_pth',
              {'value': 44.462885420892945, 'timestamp': 1777396392.5514534}),
             ('e4cv_ptth',
              {'value': 88.92577084178589, 'timestamp': 1777396392.5514557}),
             ('e4cv_peta', {'value': 90, 'timestamp': 1777396392.551458})])

Verify the polarizer wavelength#

Back-compute the polarizer wavelength from the current pth position using Bragg’s law (λ = 2 d sin θ) and confirm it matches the incident wavelength.

polarizer_wavelength = 2 * d_spacing * math.sin(math.radians(e4cv.pth.position))
print(f"pth                  : {e4cv.pth.position:.4f} deg")
print(f"Polarizer wavelength : {polarizer_wavelength:.4f} Å")
print(f"Incident wavelength  : {e4cv.beam.wavelength.get():.4f} Å")
pth                  : 44.4629 deg
Polarizer wavelength : 4.7000 Å
Incident wavelength  : 4.7000 Å

Save the diffractometer configuration#

Export the diffractometer configuration (orientation, sample, reflections, constraints) to a YAML file. The auxiliary axis names (pth, ptth, peta) are recorded in the file so the diffractometer can be rebuilt with the same axes. Auxiliary axis positions are not stored and must be restored separately (e.g. from EPICS PV readback, or by moving them explicitly after restore).

config_file = "e4cv-polarizer.yml"
e4cv.export(config_file, comment="E4CV with polarizer axes example")
print(f"Configuration saved to {config_file}")
Configuration saved to e4cv-polarizer.yml

Restore the configuration#

Restore the diffractometer from the saved file. The auxiliary axes (pth, ptth, peta) are recreated automatically from the configuration — no reals= argument is needed. Their positions default to zero and must be set explicitly.

e4cv2 = hklpy2.simulator_from_config(config_file)
e4cv2.wh()
wavelength=4.7
pseudos: h=0, k=0, l=0
reals: omega=0, chi=0, phi=0, tth=0
auxiliaries: pth=0, ptth=0, peta=0

The orientation (UB matrix, sample, reflections) and the solver mode are fully restored. Two items are not stored in the configuration file and must be set explicitly as part of session startup:

  • Polarizer positions — default to zero; move them as shown above.

  • Incident wavelength — restored to the value at export time, but at a beamline this is typically driven live by the monochromator.

(The peta rotation around the beam is also independent of the diffractometer orientation; it can be set at any time without affecting forward()/inverse().)

Verify the restored orientation#

Confirm that forward() gives the same result on both the original and restored diffractometers.

hkl = dict(h=1, k=0, l=0)
print(f"original: forward{hkl} -> {e4cv.forward(**hkl)[0]:.4f}")
print(f"restored: forward{hkl} -> {e4cv2.forward(**hkl)[0]:.4f}")
original: forward{'h': 1, 'k': 0, 'l': 0} -> -35.5690
restored: forward{'h': 1, 'k': 0, 'l': 0} -> -35.5690

Optional fourth axis: pchi#

Some installations add a fourth motor (pchi) for crystal alignment. It is added to reals exactly like the others:

e4cv = hklpy2.creator(
    name="e4cv",
    reals=dict(
        omega=None, chi=None, phi=None, tth=None,
        pth=None, ptth=None, peta=None, pchi=None,
    ),
)

pchi is invisible to the diffractometer crystallography and is read, saved, and restored alongside the other auxiliaries.

Clean up the example file#

import pathlib

pathlib.Path(config_file).unlink(missing_ok=True)