Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5b97d4f
added osx-arm64 workspace. this branch should parallel main, but with…
dihenriksen Apr 28, 2026
9489e21
finished most of the logic for the attenuator bank, and started writi…
dihenriksen May 8, 2026
2dd5b96
small cleanups
dihenriksen May 8, 2026
a4a93a8
fixed formatting issues
dihenriksen May 8, 2026
8454b50
moved xraylib dependency
dihenriksen May 8, 2026
5fd4afa
added open, close, and get_status methods with corresponding signals
dihenriksen May 8, 2026
a046034
added test, added test env dependency
dihenriksen May 9, 2026
205f2c7
fixed dependency structure, formatted
dihenriksen May 9, 2026
981e290
require python <3.14 because pydantic is complaining
dihenriksen May 9, 2026
52e162f
try pinning pythong version in dist job in cd.yml to 3.13
dihenriksen May 9, 2026
6aab1d4
try this
dihenriksen May 9, 2026
3450021
try this
dihenriksen May 9, 2026
61477f6
try this
dihenriksen May 9, 2026
a8ef9bf
typo
dihenriksen May 9, 2026
0660377
might not even need that
dihenriksen May 9, 2026
8f605d1
simplified finding closest attenuator logic
dihenriksen May 11, 2026
5135f45
added set_attenuation method
dihenriksen May 11, 2026
7d7c92f
minor fixes
dihenriksen May 12, 2026
08a33a4
fixed style things
dihenriksen May 12, 2026
f341268
add movable protocol to both Attenuator classes, wrap set method in A…
dihenriksen May 12, 2026
2a216ac
remove python 3.10 from version checks because latest version of ophy…
dihenriksen May 12, 2026
eba6dd9
fixed read, write pvs for position
dihenriksen May 12, 2026
e2f16d3
more comments, use asyncmovable protocol, changed cmd to position
dihenriksen May 12, 2026
2473895
realized that DeviceVector can have arbitrary integer keys, so does n…
dihenriksen May 13, 2026
dc71ab7
made thicknesses a constant
dihenriksen May 13, 2026
e5616dc
corrected terms transmission and attenuation, added some tests
dihenriksen May 14, 2026
faaf823
corrected terms transmission and attenuation, added some tests
dihenriksen May 14, 2026
dcdfb3f
made photon_energy and units parameters to pass into calculations, us…
dihenriksen May 14, 2026
50f999b
fully removed xraylib; minor refactoring
dihenriksen May 14, 2026
f52c573
added tests
dihenriksen May 14, 2026
5bbb671
pass energy object into AttenuatorBank on creation, so it can poll fo…
dihenriksen May 14, 2026
d0fb3d2
more comprehensive status for attenuator bank
dihenriksen May 15, 2026
9667175
fixed terminology so it is clear that the value set on the attenuator…
dihenriksen May 15, 2026
beffc82
made prefix an argument for bank creation
dihenriksen May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ jobs:
fetch-depth: 0
persist-credentials: false

- uses: hynek/build-and-inspect-python-package@c52c3a4710070b50470d903818a7b25115dcd076 # v2.13.0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put the SHA pinning back.

- uses: hynek/build-and-inspect-python-package@v2
with:
python-version: "3.13"

publish:
needs: [dist]
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.11", "3.12", "3.13"]
runs-on: [ubuntu-latest]

steps:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,6 @@ Thumbs.db
# IDEs
.vscode/
.cursor/

# Other
notes.*
7,017 changes: 5,630 additions & 1,387 deletions pixi.lock

Large diffs are not rendered by default.

22 changes: 12 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ authors = [
]
description = "CDI Tools Package"
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.9,<3.14"
classifiers = [
"Development Status :: 1 - Planning",
"Intended Audience :: Science/Research",
Expand All @@ -31,8 +31,9 @@ classifiers = [
dynamic = ["version"]
dependencies = [
"ophyd",
"ophyd-async >=0.10.0a4",
"ophyd-async[ca] >=0.17.0a2",
"h5py",
"xrayutilities>=1.7.12,<2"
]

[project.optional-dependencies]
Expand All @@ -45,6 +46,7 @@ test = [
"tiled[minimal-client]",
"tiled[minimal-server]",
"ophyd >=v1.10.6",
"pytest-watcher",
]
dev = [
"caproto[standard] >=0.4.2rc1,!=1.2.0",
Expand Down Expand Up @@ -92,15 +94,12 @@ dev-dependencies = [

[tool.pytest.ini_options]
minversion = "6.0"
addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
# TODO - fix the eiger_async module and tests
addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config", "--ignore=tests/test_eiger_async.py"]
xfail_strict = true
filterwarnings = [
"ignore",
]
filterwarnings = "ignore"
log_cli_level = "INFO"
testpaths = [
"tests",
]
testpaths = "tests"

[tool.coverage]
run.source = ["cditools"]
Expand Down Expand Up @@ -161,11 +160,14 @@ isort.required-imports = ["from __future__ import annotations"]

[tool.pixi.project]
channels = ["conda-forge"]
platforms = ["linux-64"]
platforms = ["linux-64", "osx-arm64"]

[tool.pixi.pypi-dependencies]
cditools = { path = ".", editable = true }

[tool.pixi.dependencies]
xrayutilities = ">=1.7.12,<2"

[tool.pixi.environments]
default = { solve-group = "default" }
dev = { features = ["dev"], solve-group = "default" }
Expand Down
246 changes: 246 additions & 0 deletions src/cditools/attenuator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
from __future__ import annotations

import asyncio
import math
from dataclasses import dataclass

import numpy as np
import xrayutilities as xu
from ophyd_async.core import (
AsyncMovable,
AsyncStatus,
DeviceVector,
StandardReadable,
StrictEnum,
)
from ophyd_async.epics.core import EpicsDevice, epics_signal_r, epics_signal_rw

from cditools.motors import Energy


@dataclass
class AttenuatorCombination:
transmission: float
attenuators: list[int]

@property
def attenuation(self):
return 1 - self.transmission


THICKNESSES = (16, 24, 66, 124) # microns
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make each of these a combination of (material, thickness), the generalization is pretty easy to do up front.



class AttenuatorStatusEnum(StrictEnum):
LOW = "Low" # off / not obstructing
HIGH = "High" # on / obstructing


class Attenuator(EpicsDevice, AsyncMovable[AttenuatorStatusEnum]):
filter_material = xu.materials.Al

def __init__(self, prefix: str, num: int, thickness: int):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would type this as float.

"""
prefix - the common prefix for the attenuator bank
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use numpydoc conventions

num - an integer denoting which attenuator within the bank this is
thickness - the thickness of the attenuator in microns

position - the read / write PV to open and close the attenuator
"""
self.prefix = prefix
self.num = num
self.thickness = thickness # microns

self.position = epics_signal_rw(
AttenuatorStatusEnum,
f"{self.prefix}:DO{self.num}-Sts",
write_pv=f"{self.prefix}:DO{self.num}-Cmd",
)
self.mode = epics_signal_rw(bool, f"{self.prefix}:DIO{self.num}-Mode")
self.in_status = epics_signal_r(
AttenuatorStatusEnum, f"{self.prefix}:DI{self.num}-Sts"
)

super().__init__(prefix=self.prefix)

def __repr__(self):
return f"{self.thickness!s} microns, {self.filter_material}"

@property
def name(self):
return f"attenuator_{self.num}"

@AsyncStatus.wrap
async def set(self, value: AttenuatorStatusEnum):
await self.position.set(value)

async def open(self):
"""Open means open to allowing the beam to pass unobstructed"""
await self.position.set(AttenuatorStatusEnum.LOW)

async def close(self):
"""Closed means obstructing the beam"""
await self.position.set(AttenuatorStatusEnum.HIGH)

def transmission(self, photon_energy: float, egu: str = "KeV"):
"""Transmission is the fraction of beam remaining"""
abs_len = self._absorption_length(photon_energy, egu=egu)
return np.exp(-self.thickness / abs_len)

def attenuation(self, photon_energy: float, egu: str = "KeV"):
"""Attenuation is the fraction of the beam removed"""
return 1 - self.transmission(photon_energy, egu=egu)

def _absorption_length(self, photon_energy: float, egu: str = "KeV") -> float:
"""
Calculates L, the characteristic absorption length of this material,
at this beam energy.

photon energy: the beam energy
egu: the engineering units of the beam energy (KeV or eV)
absorption length: the characteristic absorption length of the
filter material (microns)
"""
if egu == "KeV":
photon_energy = photon_energy * 1e3
elif egu != "eV":
msg = "Photon energy units must be eV or KeV"
raise RuntimeError(msg)
return self.filter_material.absorption_length(photon_energy) # type: ignore[reportArgumentType]


class AttenuatorBank(StandardReadable, EpicsDevice, AsyncMovable[float]):
"""
The ioc for the iologik1 lives on xf09id1-inst-ioc1.nsls2.bnl.gov
"""

thicknesses = THICKNESSES

def __init__(self, prefix: str, energy: Energy):
self.prefix = prefix
self.energy = energy

with self.add_children_as_readables():
self.attenuators = DeviceVector(
{
i: Attenuator(self.prefix, i, self.thicknesses[i - 1])
for i in range(1, len(self.thicknesses) + 1)
Comment on lines +126 to +127
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
i: Attenuator(self.prefix, i, self.thicknesses[i - 1])
for i in range(1, len(self.thicknesses) + 1)
i: Attenuator(self.prefix, i, thickness)
for i, thickness in enumerate(self.thicknesses, start=1)

Slightly more idiomatic Python

}
)
super().__init__(prefix=self.prefix)

@property
def photon_energy(self):
return self.energy.energy.readback.get()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after a lot of back-and-forth we have avoided wrapping epics I/O in properties. The main arguments against are that it violates the expectation that properties are attributes which are fast (not arbitrarily long) and is not uniform across other devices.


@property
def egu(self):
return self.energy.egu

async def get_status(self): # type: ignore[reportUnknownParameterType]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be async def read ? That way we can toss this in a baseline list and it will "just work".

"""
Status polls the bluesky energy object for the current beam energy, and
returns that energy, each filter position, each transmission, and
the total transmission.
"""
status = {}
active_attens = []
en = self.photon_energy
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are already in an async function here so

Suggested change
en = self.photon_energy
en = await self.energy.readback.value()

I may be wrong about the exact name, ophyd-async has a verb for "get me the one true number for this or explode".

egu = self.egu
positions = await asyncio.gather(
*(a.position.get_value() for _, a in self.attenuators.items())
)
for i, pos in zip(self.attenuators, positions):
atten = self.attenuators[i]
is_active = pos == AttenuatorStatusEnum.HIGH
if is_active:
active_attens.append(atten)
transmission = atten.transmission(en, egu) if is_active else 0
status[atten.name] = {"active": is_active, "transmission": transmission}
status["active_attenuators"] = [a.num for a in active_attens]
status["photon_energy"] = en
status["egu"] = egu
status["total_transmission"] = self._calculate_total_transmission(
*active_attens
)
return status

@AsyncStatus.wrap
async def set(self, value: float):
"""Set the transmission for the attenuator bank"""
attenuation_combination = self.find_closest_transmission(value)
coros = []
for (
num,
atten,
) in self.attenuators.items():
if num in attenuation_combination.attenuators:
coros.append(atten.close())
else:
coros.append(atten.open())
await asyncio.gather(*coros)

def find_closest_transmission(
self, target_transmission: float
) -> AttenuatorCombination:
"""
This could be faster if we implemented binary search,
but that seems like overkill for our use case. The search space
is small, so we start in the middle, and work up or down.
"""
available_attenuations = self._calculate_available_transmissions()
best_idx = len(available_attenuations) // 2
atten = available_attenuations[best_idx].transmission
diff = float("inf")
new_diff = abs(target_transmission - atten)
inc = 1 if target_transmission > atten else -1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

best_inx = np.argmax([_.transmission for _ in available_attenuations])

or np.insert_sorted or https://docs.python.org/3/library/bisect.html

As there are physical attenuator banks we are always going to be in the realm of small numbers and my expectation on the relative runtimes is that computing the transmission is >> more expensive that this search.

To some degree, the sort in _calculate_available_transmissions is a false economy as that is at best O(n * log(n)), but if we just need to find the max we can do that with on O(n) scan (which given that n here is < 256 it it all a bit moot!).

while new_diff < diff:
diff = new_diff
# break if we are about to check outside the list
if best_idx + inc >= len(available_attenuations) or best_idx + inc < 0:
break
atten = available_attenuations[best_idx + inc].transmission
new_diff = abs(target_transmission - atten)
if new_diff < diff:
best_idx += inc
else: # if diff did not change, then we have found the best option
break
# TODO - should return just the found attentuation? or also the
# requested attenuation and/or the difference?
return available_attenuations[best_idx]

def _calculate_available_transmissions(self) -> list[AttenuatorCombination]:
"""
Calculates all possible transmissions for the attenuator bank, using
the powerset of the available attenuators.
"""
available_transmissions = []
for combination in self._powerset():
attens = [self.attenuators[a] for a in self.attenuators if a in combination]
total_transmission = self._calculate_total_transmission(*attens)
available_transmissions.append(
AttenuatorCombination(total_transmission, combination)
)
# We want the available attenuations sorted so we can efficiently search through them
available_transmissions.sort(key=lambda a: a.transmission) # type: ignore[attr-defined]
return available_transmissions

def _calculate_total_transmission(self, *attenuators: Attenuator) -> float:
transmissions = [
a.transmission(self.photon_energy, self.egu) for a in attenuators
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than get the energy here, pass it in. This would both open up a path to having an extra API of "what is the best combination if I changed to a different energy" without having to actually change the energy.

In both set/read you are in an async function so can do the read and push it down.

]
return round(float(math.prod(transmissions)), 3)

def _powerset(self) -> list[list[int]]:
"""
This is a famously O(n*2^n) problem.
"""
powerset = []
for i in range(1 << len(self.attenuators)):
combination = []
for j in range(len(self.attenuators)):
if i & (1 << j):
combination.append(j + 1) # +1 because attenuators are 1-indexed
powerset.append(combination)
return powerset
Loading
Loading