Switch back to pixi, update ophyd-async version & fix phantom ophyd class, add ophyd classes for monochromater and filters, add change energy plan" - #76
Conversation
Jakub Wlodek (jwlodek)
commented
Aug 12, 2026
- Update gitignore
- Add motors module remove unused files
- Switch back to pixi from uv. Fix phantom ophyd object with new ophyd-async version. Add classes for Filters and Monochromater. Add plan for changing energies, with auto-tuning of the second crystal pitch
…async version. Add classes for Filters and Monochromater. Add plan for changing energies, with auto-tuning of the second crystal pitch
Anthony Sligar (sligara7)
left a comment
There was a problem hiding this comment.
Thanks Jakub — the change-energy plan is great: the coarse→fine camera-feedback auto-tune of the crystal-2 pitch is exactly the compensation the mono needs for the encoder/rail imperfections, and the approach-from-below for backlash plus waiting on the shutter position status (not the command status) are both real improvements over the pyepics lib. Inline comments below; the two biggest are the ophyd-async version story (0.21 here vs 0.19.4 in hex-ob and the beamline profile env) and making sure the phantom fixes that were just validated against the simulated beamline in hex-ob PR #12 survive the API migration. CI reds understood as the in-flight pixi move.
| rich = ">=15.0.0,<16" | ||
| pyyaml = ">=6.0.3,<7" | ||
| numpy = ">=2.4.6,<3" | ||
| ophyd-async = ">=0.21.1,<0.22" |
There was a problem hiding this comment.
This pin moves hextools to ophyd-async 0.21, but hex-ob and the beamline profile environment are on 0.19.4 — and the phantom device that just merged into hex-ob (PR #12, validated end-to-end against the simulated beamline) speaks the 0.19 API. Merging this as-is forks the phantom across two API generations.
What's the version story — does the beamline env move to 0.21 (and on what timeline relative to the October user-ready date), or should the phantom migration split out of this PR so the mono/filters work isn't blocked on that decision?
| self.driver = driver | ||
|
|
||
| async def arm(self): | ||
| async def start_acquiring(self): |
There was a problem hiding this comment.
This migration starts from the pre-hex-ob version of the phantom, so two fixes that were found against the simulated beamline (hex-ob PR #12) don't appear here:
- the external-trigger deadlock chain fix — busy-record arm handling in the acquire path (without it, an externally-triggered tomo flyscan deadlocks; it reproduced identically in sim and would at the beamline);
- the download-watch race fix in the RAM-download flow.
It's possible the 0.21 acquire-logic contract absorbs part of (1) — but that needs to be shown rather than assumed. Suggest porting the hex-ob lib/phantom.py (fixes included) forward to the 0.21 API, rather than migrating the pre-fix version, so the sim-validated behavior is the thing that survives.
| await asyncio.gather(*coros) | ||
|
|
||
|
|
||
| def change_energy( |
There was a problem hiding this comment.
energy: float = 0.0 is a footgun — calling with the default hits np.arcsin(1.977/0.0). Suggest making energy required, and validating energy > 1.977 (arcsin domain for Si(111)) with a clear error before any motor moves.
|
|
||
| # Create a PeakStats object to monitor the fluorescence screen camera signal | ||
| # and find the position of the crystal 2 pitch that produces a peak. | ||
| ps = PeakStats(dclm.xtal2_pitch.name, f"{fs_camera.name}_stats1_mean") |
There was a problem hiding this comment.
f"{fs_camera.name}_stats1_mean" is the ophyd-v1 naming convention — ophyd-async data keys are dash-separated (<name>-stats1-mean), so as written PeakStats never sees the signal, ps.com stays None, and the bps.mv(dclm.xtal2_pitch, ps.com) below fails cryptically.
Two asks: derive the key from the device rather than hardcoding the string, and guard the ps.com is None case (no peak found — e.g. no beam, screen out) with a clear error before any move.
| await wait_for_value(self.in_position, True, timeout=10) | ||
|
|
||
|
|
||
| class Slits(StandardReadable, EpicsDevice): |
There was a problem hiding this comment.
The slit motors are created after super().__init__ with bare suffix strings ("I}Mtr" etc.) — do they actually inherit the device prefix? If child prefix propagation only happens via PvSuffix annotations, these connect to literal PV names like I}Mtr. A test that connects one axis against the mock backend would settle it either way.
| ) | ||
| self.xtal1_pitch = AsyncEpicsMotor(prefix + "C1P}Mtr") | ||
| self.xtal2_pitch = AsyncEpicsMotor(prefix + "C2P}Mtr") | ||
| self.flourescence_screen = AsyncEpicsMotor(prefix + "FS}Mtr") |
There was a problem hiding this comment.
Typo that's about to become public API surface: flourescence_screen → fluorescence_screen (also referenced from the plan below). Cheap to fix now, breaking to fix later.
| return closest[1].description or closest[0].name.lower().replace("_", " ") | ||
|
|
||
| @AsyncStatus.wrap | ||
| async def set(self, value: FilterPosition): |
There was a problem hiding this comment.
self.positions[value] raises a bare KeyError if someone passes FilterPosition.UPPER_LIMIT/LOWER_LIMIT (or any position not in the dict). Suggest either excluding the limit markers from the settable enum or raising a descriptive error listing the valid positions.
| return BeamMode.WHITE | ||
| return BeamMode.MONOCHROMATIC | ||
|
|
||
| def _get_energy(self, pitch_angle: float) -> float: |
There was a problem hiding this comment.
The Si(111) constants (35.2544 offset, 1.977 = hc/2d) are duplicated here and in change_energy. Worth hoisting to module-level named constants so the energy readback and the plan can't drift apart.