Skip to content
Draft
79 changes: 79 additions & 0 deletions diffractometer-geometry.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# CDI 7-circle diffractometer prototype.
# Schema reference: docs/source/reference/declarative_geometry_schema.md
# How-to: docs/source/howto/custom_geometry.md

ad_hoc_diffractometer_geometry:
schema_revision: 1

name: cdi-geometry

documentation: |
CDI 7-circle diffractometer prototype.

This is a working first model of the CDI stack from beamline notes.
The top two sample rotations are fixed at zero in the initial mode.


The solver has special handling for an Eulerian inner pair named
``chi`` and ``phi``. These names are solver-facing aliases for the
two lower CDI sample stages noted as omega/chi during beamline
discussion:

chi -> gon.sam.c_lg.lrx / omega1 in earlier notes
phi -> gon.sam.c_lg.lrz / chi1 in earlier notes


Basis used here: vertical=+Y, longitudinal=+Z, transverse=+X.


Stack (floor first):
mu : vertical
--> chi : longitudinal
--> phi : transverse
--> omega2 : longitudinal, fixed at 0 in fixed_alpha
--> chi2 : transverse, fixed at 0 in fixed_alpha
gamma1 : transverse detector axis, fixed at 0 in fixed_alpha
--> delta1 : vertical detector axis

basis:
vertical: [0.0, 1.0, 0.0]
longitudinal: [0.0, 0.0, 1.0]
transverse: [1.0, 0.0, 0.0]

stages:
# TODO - check +/- signs
- { name: mu, axis: +vertical, parent: null, role: sample } # Ry gon.sam.ry
- { name: chi, axis: +longitudinal, parent: mu, role: sample } # Rx gon.sam.c_lg.lrx / omega1
# omega about the transverse axis
# phi is about the vertical when everything else is at 0, but would change as everythhing else moves
# we do not have a phi installed, so it should be zero or not included in the geometry.
# phi is on top of omega and chi and mu
- { name: phi, axis: +transverse, parent: chi, role: sample } # Rz gon.sam.c_lg.lrz / chi1

# do not need to worry about second omega and chi; assume they are fixed
# we have double omega and double chi installed, but do not need to incorporate them right now
- { name: omega2, axis: +longitudinal, parent: phi, role: sample } # Rx gon.sam.c_sm.lrx
- { name: chi2, axis: +transverse, parent: omega2, role: sample } # Rz gon.sam.c_sm.lrz
- { name: gamma1, axis: +transverse, parent: null, role: detector }
- { name: delta1, axis: +vertical, parent: gamma1, role: detector }
# - {name: gamma2, axis: +transverse, parent: null, role: detector }
# - {name: delta2, axis: +vertical, parent: gamma2, role: detector }

# 7 real axes, so N - 3 = 4 constraints are needed for a unique forward solve.
modes:
fixed_alpha:
default: true
constraints:
- { type: sample, stage: chi2, value: 0.0 }
- { type: sample, stage: omega2, value: 0.0 }
- { type: sample, stage: phi, value: 0.0 }
- { type: reference, name: incidence, value: 5.0 } # this will be user provided, 5.0 is just a placeholder
# - { type: detector, stage: gamma1, value: 0.0 }
computed:
[mu, chi, gamma1, delta1]
# computed: [mu, chi, phi, delta1]
# mu fixed at 0 mode / constraint
extras:
n_hat: REQUIRED
incidence: null
emergence: null
1,741 changes: 1,724 additions & 17 deletions pixi.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@ xrayutilities = ">=1.7.12,<2"

[pypi-dependencies]
cditools = { path = ".", editable = true }
ad_hoc_diffractometer = "*"
hklpy2-solvers = ">=0.3.9, <0.4"

[target.linux-64.dependencies]
ophyd-async = { version = ">=0.21", extras = ["ca"] }
hklpy2 = "*"
hkl = "*"

# It is necessary to split ophyd-async to pypi on mac because
# epicscorelibs is not available for osx-arm64 on conda-forge
[target.osx-arm64.pypi-dependencies]
ophyd-async = { version = ">=0.21", extras = ["ca"] }
hklpy2 = ">=1.0.0, <2"

[feature.dev.dependencies]
entrypoints = ">=0.4,<0.5"
Expand Down
115 changes: 115 additions & 0 deletions src/cditools/diffractometer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from __future__ import annotations

import contextlib
import math

import ad_hoc_diffractometer as ahd
import bluesky.plans as bp
import hklpy2
import hklpy2.blocks.reflection
import hklpy2.user
from bluesky import RunEngine
from bluesky.callbacks.best_effort import BestEffortCallback
from hklpy2.user import cahkl_table
from hklpy2.utils import pick_closest_solution

"""
1. Export diffractometer ophyd object to profile collection
2. Create two ophyd objects, one for each arm
3. Connect ophyd objects to PVs for arm
4. Verify simulated movement
5. Verify real movement

Extras
- figure out how to adjust tolerance of incidence
- set energy/wavelength with ophyd object
"""

# Register geometry
try:
ahd.register_geometry_file("./diffractometer-geometry.yml", name="cdi-geometry")
except ValueError: # geometry already registered
contextlib.suppress(ValueError)

# Create diffractometer
diffr = hklpy2.creator(name="cdi-geometry", geometry="cdi-geometry", solver="ad_hoc")

# Add Sample
hklpy2.user.set_diffractometer(diffr)
hklpy2.user.add_sample("silicon", a=hklpy2.SI_LATTICE_PARAMETER)

# Add beam
# TODO - add conversion between our energy and beam energy
diffr.beam.wavelength.put(1.54) # Angstroms

# Add orientation reflections
theta = math.degrees(math.asin(1.54 / (2 * 5.431 / 4))) # ≈ 34.55° for (400)
tth = 2 * theta

# Have to specify all seven angles for an orientation reflection
try:
r1 = hklpy2.user.setor(
4,
0,
0,
mu=0,
chi=0,
phi=0,
omega2=theta,
chi2=0,
gamma1=tth,
delta1=0,
)
r2 = hklpy2.user.setor(
0,
4,
0,
mu=0,
chi=0,
phi=90,
omega2=theta,
chi2=0,
gamma1=tth,
delta1=0,
)
except hklpy2.blocks.reflection.ReflectionError:
pass

# To check on status of things, run pa() and wh()
# pa()
# wh()

# Calculate UB matrix
hklpy2.user.calc_UB(r1, r2)

# Add constraints
diffr.core.constraints["chi"].limits = (0, 180)
diffr.core.constraints["omega2"].limits = (180, 0)

# Set surface normal
diffr.core.extras = {"n_hat": (1, 1, 1)}

# Get solutions
hkl_or = (4, 0, 0)
# diffr.core.forward gives a list of solutions
# diffr.forward gives the first solution
solutions = diffr.core.forward(hkl_or)
# print table of solutions:
cahkl_table(hkl_or)

# optionally change solution picker before moving:

diffr._forward_solution = pick_closest_solution
diffr.move((4, 0, 0))

# see full state of diffractometer:
diffr.wh(full=True)


# Scan in reciprocal space
bec = BestEffortCallback()
bec.disable_plots()

RE = RunEngine({})
RE.subscribe(bec)
RE(bp.scan([diffr], diffr.h, 3.9, 4.1, 5))
135 changes: 135 additions & 0 deletions src/cditools/hkl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
from pathlib import Path
from typing import Any

from hklpy2.diffract import DiffractometerBase
from ad_hoc_diffractometer import register_geometry_file
from hklpy2.diffract import DiffractometerBase
from hklpy2.incident import WavelengthXray

from cditools.motors import Energy, GON

CUSTOM_GEOMETRY_YAML_PATH = Path(__file__).resolve().parent / "config/cdi-geometry.yml"

class PseudoMonochromator(WavelengthXray):
"""Incident beam whose energy tracks an ophyd ``PseudoSingle``.

The DCM publishes no energy PV, so energy is computed by the
:class:`~cditools.motors.Energy` pseudo-positioner. Subscribing to its
readback keeps ``self.energy`` (and hence ``self.wavelength``) live, which
in turn drives hklpy2's solver-update machinery.
"""

def __init__(
self,
prefix: str = "",
*,
energy_device: Energy | None = None,
**kwargs: Any,
) -> None:
super().__init__(prefix, energy_units="keV", **kwargs)
self._energy_device = energy_device
if energy_device is not None:
energy_device.energy.readback.subscribe(self._energy_changed)

def _energy_changed(self, value: float, **_kwargs: Any) -> None:
self.energy.put(value)

# class CDIDiffractometer(DiffractometerBase):

# def __init__(self, gon: GON, energy: Energy, **kwargs):
# # configure goniometer ophyd signals
# self.mu = gon.sam.ry
# self.chi = gon.sam.c_lg.lrx
# self.phi = gon.sam.c_lg.lrz
# self.omega2 = gon.sam.c_sm.lrx
# self.chi2 = gon.sam.c_sm.lrz

# # configure detector ophyd signals
# # self.gamma1 = ...
# # self.delta1 = ...

# # configure energy readout from Energy device
# # self.beam = {'class': PseudoMonochromator,
# # 'energy_device': energy}

# super().__init__(
# prefix='',
# name='cdi',
# geometry=register_geometry_file(CUSTOM_GEOMETRY_YAML_PATH),
# solver='ad_hoc',
# reals=["mu", "chi", "phi", "omega2", "chi2", "gamma1", "delta1"],
# pseudos=["h", "k", "l"],
# **kwargs
# )

# def initialize_hkl_diffractometer(name: str = "cdi") -> DiffractometerBase:
# # initialize hklpy2 diffractometer
# cdi = hklpy2.creator(prefix = "XF:09IDC-OP:1{",
# name=name,
# geometry=register_geometry_file(CUSTOM_GEOMETRY_YAML_PATH),
# solver='ad_hoc',
# reals={
# "mu": ,
# "chi": ,
# "phi": ,
# "omega2": ,
# "chi2": ,
# "gamma1": ,
# "delta1": ,
# })

# # configure energy in keV
# cdi.beam.energy.put(energy_pseudo.energy)


# return cdi

from typing import ClassVar

from ophyd import Component as Cpt
from ophyd import EpicsMotor
from ophyd import Kind
from ophyd import SoftPositioner

import hklpy2
from hklpy2.diffract import Hklpy2PseudoAxis
from hklpy2.incident import EpicsMonochromatorRO

NORMAL_HINTED = Kind.hinted | Kind.normal


class CDIDiffractometer(DiffractometerBase):

# Pseudo-space axes, in order expected by hkl_soleil E4CV, engine="hkl"
h = Cpt(Hklpy2PseudoAxis, "", kind=NORMAL_HINTED)
k = Cpt(Hklpy2PseudoAxis, "", kind=NORMAL_HINTED)
l = Cpt(Hklpy2PseudoAxis, "", kind=NORMAL_HINTED)

# Real-space axes, in our own order..
# Use different names than the solver for some axes
mu = Cpt(EpicsMotor, "Gon:1-Ax:Ry}Mtr", kind=NORMAL_HINTED)
chi = Cpt(EpicsMotor, "Gon:1-Ax:Rx2}Mtr", kind=NORMAL_HINTED)
phi = Cpt(EpicsMotor, "Gon:1-Ax:Rz2}Mtr", kind=NORMAL_HINTED)
omega2 = Cpt(EpicsMotor, "Gon:1-Ax:Rx1}Mtr", kind=NORMAL_HINTED)
chi2 = Cpt(EpicsMotor, "Gon:1-Ax:Rz1}Mtr", kind=NORMAL_HINTED)
gamma1 = Cpt(EpicsMotor, "", kind=NORMAL_HINTED)
delta1 = Cpt(EpicsMotor, "", kind=NORMAL_HINTED)

# Just the axes in expected order by hkl_soleil E4CV.
_pseudo: ClassVar[list[str]] = ["h", "k", "l"]
_real: ClassVar[list[str]] = ["mu", "chi", "phi", "omega2", "chi2", "gamma1", "delta1"]


def __init__(self, **kwargs):
# kwargs["prefix"] = prefix
super().__init__(
prefix="XF:09IDC-OP:1{",
solver="ad_hoc",
name='cdi',
geometry=register_geometry_file(CUSTOM_GEOMETRY_YAML_PATH),
pseudos=["h", "k", "l"],
reals=["mu", "chi", "phi", "omega2", "chi2", "gamma1", "delta1"],
**kwargs,
)


Loading
Loading