From 4ba62dedb7b9d28fabab51d64f9dbb241a331324 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Tue, 9 Jul 2024 14:26:22 -0500 Subject: [PATCH 01/26] Added fly-scan methods to the HavenMotor class. --- src/haven/instrument/aerotech.py | 32 ++---- src/haven/instrument/motor.py | 174 +++++++++++++++++++++++++++++- src/haven/tests/test_motor.py | 176 +++++++++++++++++++++++++++++-- 3 files changed, 348 insertions(+), 34 deletions(-) diff --git a/src/haven/instrument/aerotech.py b/src/haven/instrument/aerotech.py index dadb3528..aabb1438 100644 --- a/src/haven/instrument/aerotech.py +++ b/src/haven/instrument/aerotech.py @@ -19,13 +19,14 @@ from .delay import DG645Delay from .device import make_device from .stage import XYStage +from .motor import HavenMotor log = logging.getLogger(__name__) ureg = pint.UnitRegistry() -class AerotechFlyer(EpicsMotor, flyers.FlyerInterface): +class AerotechFlyer(HavenMotor): """Allow an Aerotech stage to fly-scan via the Ophyd FlyerInterface. Set *start_position*, *end_position*, and *step_size* in units of @@ -110,19 +111,7 @@ class AerotechFlyer(EpicsMotor, flyers.FlyerInterface): encoder_window_min: int = -8388607 encoder_window_max: int = 8388607 - # Extra motor record components - encoder_resolution = Cpt(EpicsSignal, ".ERES", kind=Kind.config) - - # Desired fly parameters - start_position = Cpt(Signal, name="start_position", kind=Kind.config) - end_position = Cpt(Signal, name="end_position", kind=Kind.config) - step_size = Cpt(Signal, name="step_size", value=1, kind=Kind.config) - dwell_time = Cpt(Signal, name="dwell_time", value=1, kind=Kind.config) - - # Calculated signals - slew_speed = Cpt(Signal, value=1, kind=Kind.config) - taxi_start = Cpt(Signal, kind=Kind.config) - taxi_end = Cpt(Signal, kind=Kind.config) + # Calculated fly-scan signals pso_start = Cpt(Signal, kind=Kind.config) pso_end = Cpt(Signal, kind=Kind.config) encoder_step_size = Cpt(Signal, kind=Kind.config) @@ -137,17 +126,12 @@ class AerotechFlyer(EpicsMotor, flyers.FlyerInterface): def __init__(self, *args, axis: str, encoder: int, **kwargs): super().__init__(*args, **kwargs) - self.axis = axis - self.encoder = encoder - # Set up auto-calculations for the flyer - self.motor_egu.subscribe(self._update_fly_params) - self.start_position.subscribe(self._update_fly_params) - self.end_position.subscribe(self._update_fly_params) - self.step_size.subscribe(self._update_fly_params) - self.dwell_time.subscribe(self._update_fly_params) + # Set up extra calculations for the flyer self.encoder_resolution.subscribe(self._update_fly_params) - self.acceleration.subscribe(self._update_fly_params) self.disable_window.subscribe(self._update_fly_params) + # Save needed axis/encoder values + self.axis = axis + self.encoder = encoder def kickoff(self): """Start a flyer @@ -401,7 +385,7 @@ def _update_fly_params(self, *args, **kwargs): " parameters." ) return - # Determine the desired direction of travel and overal sense + # Determine the desired direction of travel and overall sense # +1 when moving in + encoder direction, -1 if else direction = 1 if start_position < end_position else -1 overall_sense = direction * self.encoder_direction diff --git a/src/haven/instrument/motor.py b/src/haven/instrument/motor.py index 984dbb15..bbb41258 100644 --- a/src/haven/instrument/motor.py +++ b/src/haven/instrument/motor.py @@ -1,11 +1,14 @@ import asyncio import logging import warnings -from typing import Mapping, Sequence +from typing import Mapping, Sequence, Generator, Dict +from scipy.interpolate import CubicSpline +import numpy as np from apstools.utils.misc import safe_ophyd_name from ophyd import Component as Cpt -from ophyd import EpicsMotor, EpicsSignal, EpicsSignalRO +from ophyd import EpicsMotor, EpicsSignal, EpicsSignalRO, Signal, Kind +from ophyd.flyers import FlyerInterface from .._iconfig import load_config from .device import make_device, resolve_device_names @@ -15,12 +18,17 @@ log = logging.getLogger(__name__) -class HavenMotor(EpicsMotor): +class HavenMotor(FlyerInterface, EpicsMotor): """The default motor for haven movement. + This motor also implements the flyer interface and so can be used + in a fly scan, though no hardware trigger is supported. + Returns to the previous value when being unstaged. - """ + """ + # Extra motor record components + encoder_resolution = Cpt(EpicsSignal, ".ERES", kind=Kind.config) description = Cpt(EpicsSignal, ".DESC", kind="omitted") tweak_value = Cpt(EpicsSignal, ".TWV", kind="omitted") tweak_forward = Cpt(EpicsSignal, ".TWF", kind="omitted", tolerance=2) @@ -28,6 +36,28 @@ class HavenMotor(EpicsMotor): motor_stop = Cpt(EpicsSignal, ".STOP", kind="omitted", tolerance=2) soft_limit_violation = Cpt(EpicsSignalRO, ".LVIO", kind="omitted") + # Desired fly parameters + start_position = Cpt(Signal, name="start_position", kind=Kind.config) + end_position = Cpt(Signal, name="end_position", kind=Kind.config) + # step_size = Cpt(Signal, name="step_size", value=1, kind=Kind.config) + num_points = Cpt(Signal, name="num_points", value=1, kind=Kind.config) + dwell_time = Cpt(Signal, name="dwell_time", value=1, kind=Kind.config) + + # Calculated fly parameters + slew_speed = Cpt(Signal, value=1, kind=Kind.config) + taxi_start = Cpt(Signal, kind=Kind.config) + taxi_end = Cpt(Signal, kind=Kind.config) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Set up auto-calculations for the flyer + self.motor_egu.subscribe(self._update_fly_params) + self.start_position.subscribe(self._update_fly_params) + self.end_position.subscribe(self._update_fly_params) + self.num_points.subscribe(self._update_fly_params) + self.dwell_time.subscribe(self._update_fly_params) + self.acceleration.subscribe(self._update_fly_params) + def stage(self): super().stage() # Save starting position to restore later @@ -38,6 +68,142 @@ def unstage(self): # Restore the previously saved position after the scan ends self.set(self._old_value, wait=True) + def kickoff(self): + """Start the motor as a flyer. + + The status object return is marked as done once flying + is ready. + + Returns + ------- + kickoff_status : StatusBase + Indicate when flying is ready. + + """ + return self.move(self.taxi_start, wait=False) + + def complete(self): + """Start the motor flying and wait for it to complete. + + Returns + ------- + complete_status : StatusBase + Indicate when flying has completed + + """ + # Record real motor positions for later evaluation + self._fly_data = [] + self.user_readback.subscribe(self.record_datum) + return self.move(self.taxi_end.get(), wait=False) + + def record_datum(self, *, old_value, value, timestamp, **kwargs): + """Record a fly-scan data point so we can report it later.""" + self._fly_data.append((timestamp, value)) + + def collect(self) -> Generator[Dict, None, None]: + """Retrieve data from the flyer as proto-events + + Yields + ------ + event_data : dict + Must have the keys {'time', 'timestamps', 'data'}. + + """ + times, positions = np.asarray(self._fly_data).transpose() + model = CubicSpline(positions, times, bc_type="clamped") + # Create the data objects + for position in self.pixel_positions: + timestamp = model(position) + yield { + "time": timestamp, + "timestamps": { + self.user_readback.name: timestamp, + }, + "data": { + self.user_readback.name: position, + }, + } + + def describe_collect(self): + """Describe details for the collect() method""" + desc = OrderedDict() + desc.update(self.describe()) + return {"positions": desc} + + def _update_fly_params(self, *args, **kwargs): + """Calculate new fly-scan parameters based on signal values. + + Computes several parameters describing the fly scan motion. + These include the actual start position of the motor, the + actual distance between points, and the end position of the + motor. + + Several fields are set in the class: + + direction + 1 if we are moving positive in user coordinates, −1 if + negative + taxi_start + The starting point for motor movement during flying, accounts + for needed acceleration of the motor. + taxi_end + The target point for motor movement during flying, accounts + for needed acceleration of the motor. + pixel_positions + array of places where pixels are, should occur calculated from + encoder counts then translated to motor positions + + """ + # Grab any neccessary signals for calculation + egu = self.motor_egu.get() + start_position = self.start_position.get() + end_position = self.end_position.get() + dwell_time = self.dwell_time.get() + num_points = self.num_points.get() + accel_time = self.acceleration.get() + # Check for sane values + if dwell_time == 0: + log.warning( + f"{self} dwell_time is zero. Could not update fly scan parameters." + ) + return + if accel_time <= 0: + log.warning( + f"{self} acceleration is non-positive. Could not update fly scan" + " parameters." + ) + return + # Determine the desired direction of travel: + # +1 when moving in + encoder direction, -1 if else + direction = 1 if start_position < end_position else -1 + # Determine taxi distance to accelerate to req speed, v^2/(2*a) = d + # x1.5 for safety margin + step_size = abs((start_position - end_position) / (num_points-1)) + if step_size <= 0: + log.warning( + f"{self} step_size is non-positive. Could not update fly scan" + " parameters." + ) + return + slew_speed = step_size / dwell_time + motor_accel = slew_speed / accel_time + taxi_dist = slew_speed**2 / (2 * motor_accel) * 1.5 + step_size / 2 + taxi_start = start_position - (direction * taxi_dist) + taxi_end = end_position + (direction * taxi_dist) + # Tranforms from pulse positions to pixel centers + pixel_positions = np.linspace(start_position, end_position, num=num_points) + # Set all the calculated variables + [ + status.wait() + for status in [ + self.slew_speed.set(slew_speed), + self.taxi_start.set(taxi_start), + self.taxi_end.set(taxi_end), + ] + ] + self.pixel_positions = pixel_positions + + def load_motors( config: Mapping = None, registry: InstrumentRegistry = default_registry diff --git a/src/haven/tests/test_motor.py b/src/haven/tests/test_motor.py index 68740de0..f77a5cf0 100644 --- a/src/haven/tests/test_motor.py +++ b/src/haven/tests/test_motor.py @@ -1,6 +1,10 @@ import pytest +from ophyd.sim import instantiate_fake_device +from ophyd.flyers import FlyerInterface +from ophyd import StatusBase +import numpy as np -from haven.instrument import motor +from haven.instrument.motor import HavenMotor, load_motors @pytest.fixture() @@ -15,13 +19,20 @@ async def resolve_device_names(defns): ) +@pytest.fixture() +def motor(sim_registry): + m1 = instantiate_fake_device(HavenMotor, name="m1") + m1.user_setpoint._use_limits = False + return m1 + + def test_load_vme_motors(sim_registry, mocked_device_names): # Load the Ophyd motor definitions - motor.load_motors() + load_motors() # Were the motors imported correctly motors = list(sim_registry.findall(label="motors")) assert len(motors) == 3 - # assert type(motors[0]) is motor.HavenMotor + # assert type(motors[0]) is HavenMotor motor_names = [m.name for m in motors] assert "SLT_V_Upper" in motor_names assert "SLT_V_Lower" in motor_names @@ -37,11 +48,11 @@ def test_skip_existing_motors(sim_registry, mocked_device_names): """ # Create an existing fake motor - m1 = motor.HavenMotor( + m1 = HavenMotor( "255idVME:m1", name="kb_mirrors_horiz_upstream", labels={"motors"} ) # Load the Ophyd motor definitions - motor.load_motors() + load_motors() # Were the motors imported correctly motors = list(sim_registry.findall(label="motors")) print([m.prefix for m in motors]) @@ -56,7 +67,7 @@ def test_skip_existing_motors(sim_registry, mocked_device_names): def test_motor_signals(): - m = motor.HavenMotor("motor_ioc", name="test_motor") + m = HavenMotor("motor_ioc", name="test_motor") assert m.description.pvname == "motor_ioc.DESC" assert m.tweak_value.pvname == "motor_ioc.TWV" assert m.tweak_forward.pvname == "motor_ioc.TWF" @@ -64,6 +75,159 @@ def test_motor_signals(): assert m.soft_limit_violation.pvname == "motor_ioc.LVIO" +def test_motor_flyer(motor): + """Check that the haven motor implements the flyer interface.""" + assert motor is not None + assert isinstance(motor, FlyerInterface) + + +def test_fly_params_forward(motor): + """Test that the fly-scan parameters are correct when going from + lower to higher positions. + + """ + # Set some example positions + motor.motor_egu.set("micron").wait() + motor.acceleration.set(0.5).wait() # sec + motor.start_position.set(10.).wait() # µm + motor.end_position.set(20.).wait() # µm + motor.encoder_resolution.set(0.001).wait() # µm + motor.num_points.set(101).wait() # µm + motor.dwell_time.set(1).wait() # sec + + # Check that the fly-scan parameters were calculated correctly + assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec + assert motor.taxi_start.get(use_monitor=False) == pytest.approx(9.9125) # µm + assert motor.taxi_end.get(use_monitor=False) == pytest.approx(20.0875) # µm + i = 10. + pixel = [] + while i <= 20.005: + pixel.append(i) + i = i + 0.1 + np.testing.assert_allclose(motor.pixel_positions, pixel) + + +def test_fly_params_reverse(motor): + """Test that the fly-scan parameters are correct when going from + higher to lower positions. + + """ + # Set some example positions + motor.motor_egu.set("micron").wait() + motor.acceleration.set(0.5).wait() # sec + motor.start_position.set(20.0).wait() # µm + motor.end_position.set(10.0).wait() # µm + motor.num_points.set(101).wait() # µm + motor.dwell_time.set(1).wait() # sec + + # Check that the fly-scan parameters were calculated correctly + assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec + assert motor.taxi_start.get(use_monitor=False) == pytest.approx(20.0875) # µm + assert motor.taxi_end.get(use_monitor=False) == pytest.approx(9.9125) # µm + i = 20.0 + pixel = [] + while i >= 9.995: + pixel.append(i) + i = i - 0.1 + np.testing.assert_allclose(motor.pixel_positions, pixel) + + +def test_kickoff(motor): + motor.dwell_time.set(1.0).wait() + # Start flying + status = motor.kickoff() + # Check status behavior matches flyer interface + assert isinstance(status, StatusBase) + # Make sure the motor moved to its taxi position + assert motor.user_setpoint.get() == motor.taxi_start + + +def test_complete(motor): + # Set up fake flyer with mocked fly method + assert motor.user_setpoint.get() == 0 + motor.taxi_end.set(10).wait() + # Complete flying + status = motor.complete() + # Check that the motor was moved + assert isinstance(status, StatusBase) + assert motor.user_setpoint.get() == 10 + + +def test_collect(motor): + # Set up needed parameters + motor.pixel_positions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + # Set up some fake positions from camonitors + motor._fly_data = [ + # timestamp, position + (1.125, 0.5), + (2.125, 1.5), + (3.125, 2.5), + (4.125, 3.5), + (5.125, 4.5), + (6.125, 5.5), + (7.125, 6.5), + (8.125, 7.5), + (9.125, 8.5), + (10.125, 9.5), + (11.125, 10.5), + ] + expected_timestamps = [ + 1.625, + 2.625, + 3.625, + 4.625, + 5.625, + 6.625, + 7.625, + 8.625, + 9.625, + 10.625, + ] + payload = list(motor.collect()) + # Confirm data have the right structure + for datum, value, timestamp in zip( + payload, motor.pixel_positions, expected_timestamps + ): + assert datum == { + "data": { + "m1": value, + }, + "timestamps": { + "m1": timestamp, + }, + "time": timestamp, + } + + +def test_describe_collect(aerotech_flyer): + expected = { + "positions": OrderedDict( + [ + ( + "aerotech_horiz", + { + "source": "SIM:aerotech_horiz", + "dtype": "integer", + "shape": [], + "precision": 3, + }, + ), + ( + "aerotech_horiz_user_setpoint", + { + "source": "SIM:aerotech_horiz_user_setpoint", + "dtype": "integer", + "shape": [], + "precision": 3, + }, + ), + ] + ) + } + + assert aerotech_flyer.describe_collect() == expected + + # ----------------------------------------------------------------------------- # :author: Mark Wolfman # :email: wolfman@anl.gov From 8b120ad1bb8e25b97883814b49afda6582bec1eb Mon Sep 17 00:00:00 2001 From: yannachen Date: Tue, 9 Jul 2024 15:47:45 -0500 Subject: [PATCH 02/26] Fixed the motor fly scanning at the beamline. --- src/haven/instrument/motor.py | 36 ++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/haven/instrument/motor.py b/src/haven/instrument/motor.py index bbb41258..cd47a4f2 100644 --- a/src/haven/instrument/motor.py +++ b/src/haven/instrument/motor.py @@ -1,6 +1,7 @@ import asyncio import logging import warnings +from collections import OrderedDict from typing import Mapping, Sequence, Generator, Dict from scipy.interpolate import CubicSpline @@ -37,10 +38,10 @@ class HavenMotor(FlyerInterface, EpicsMotor): soft_limit_violation = Cpt(EpicsSignalRO, ".LVIO", kind="omitted") # Desired fly parameters - start_position = Cpt(Signal, name="start_position", kind=Kind.config) - end_position = Cpt(Signal, name="end_position", kind=Kind.config) + start_position = Cpt(Signal, name="start_position", value=0, kind=Kind.config) + end_position = Cpt(Signal, name="end_position", value=1, kind=Kind.config) # step_size = Cpt(Signal, name="step_size", value=1, kind=Kind.config) - num_points = Cpt(Signal, name="num_points", value=1, kind=Kind.config) + num_points = Cpt(Signal, name="num_points", value=2, kind=Kind.config) dwell_time = Cpt(Signal, name="dwell_time", value=1, kind=Kind.config) # Calculated fly parameters @@ -56,17 +57,12 @@ def __init__(self, *args, **kwargs): self.end_position.subscribe(self._update_fly_params) self.num_points.subscribe(self._update_fly_params) self.dwell_time.subscribe(self._update_fly_params) - self.acceleration.subscribe(self._update_fly_params) + self.acceleration.subscribe(self._update_fly_params) def stage(self): - super().stage() - # Save starting position to restore later - self._old_value = self.user_readback.value - - def unstage(self): - super().unstage() - # Restore the previously saved position after the scan ends - self.set(self._old_value, wait=True) + # Override some additional staged signals + self._original_vals.setdefault(self.user_setpoint, self.user_readback.get()) + self._original_vals.setdefault(self.velocity, self.velocity.get()) def kickoff(self): """Start the motor as a flyer. @@ -80,7 +76,9 @@ def kickoff(self): Indicate when flying is ready. """ - return self.move(self.taxi_start, wait=False) + self.move(self.taxi_start.get(), wait=True) + st = self.velocity.set(self.slew_speed.get()) + return st def complete(self): """Start the motor flying and wait for it to complete. @@ -93,8 +91,10 @@ def complete(self): """ # Record real motor positions for later evaluation self._fly_data = [] - self.user_readback.subscribe(self.record_datum) - return self.move(self.taxi_end.get(), wait=False) + cid = self.user_readback.subscribe(self.record_datum, run=False) + st = self.move(self.taxi_end.get(), wait=True) + self.user_readback.unsubscribe(cid) + return st def record_datum(self, *, old_value, value, timestamp, **kwargs): """Record a fly-scan data point so we can report it later.""" @@ -113,14 +113,16 @@ def collect(self) -> Generator[Dict, None, None]: model = CubicSpline(positions, times, bc_type="clamped") # Create the data objects for position in self.pixel_positions: - timestamp = model(position) + timestamp = float(model(position)) yield { "time": timestamp, "timestamps": { self.user_readback.name: timestamp, + self.user_setpoint.name: timestamp, }, "data": { self.user_readback.name: position, + self.user_setpoint.name: position, }, } @@ -128,7 +130,7 @@ def describe_collect(self): """Describe details for the collect() method""" desc = OrderedDict() desc.update(self.describe()) - return {"positions": desc} + return {self.user_readback.name: desc} def _update_fly_params(self, *args, **kwargs): """Calculate new fly-scan parameters based on signal values. From 079a37a185d809f11b180f02057b8a6392b60b75 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Tue, 9 Jul 2024 22:39:58 -0500 Subject: [PATCH 03/26] Added fly-scanning support to area detector base (probably broken, copied from xspress). --- src/haven/instrument/area_detector.py | 131 +++++++++++++++++++++++++- src/haven/tests/test_area_detector.py | 70 +++++++++++++- 2 files changed, 198 insertions(+), 3 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 5a90dddc..7ab0af17 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -1,8 +1,13 @@ import logging +import time from enum import IntEnum +from collections import OrderedDict +from typing import Dict +import numpy as np from apstools.devices import CamMixin_V34, SingleTrigger_V34 from ophyd import ADComponent as ADCpt +from ophyd import Component as Cpt from ophyd import DetectorBase as OphydDetectorBase from ophyd import ( EigerDetectorCam, @@ -11,7 +16,10 @@ OphydObject, SimDetectorCam, SingleTrigger, + Signal, + Device, ) +from ophyd.status import StatusBase, SubscriptionStatus from ophyd.areadetector.base import EpicsSignalWithRBV as SignalWithRBV from ophyd.areadetector.filestore_mixins import FileStoreHDF5IterativeWrite from ophyd.areadetector.plugins import ( @@ -28,6 +36,7 @@ from ophyd.areadetector.plugins import StatsPlugin_V31 as OphydStatsPlugin_V31 from ophyd.areadetector.plugins import StatsPlugin_V34 as OphydStatsPlugin_V34 from ophyd.areadetector.plugins import TIFFPlugin_V31 +from ophyd.flyers import FlyerInterface from .. import exceptions from .._iconfig import load_config @@ -56,6 +65,124 @@ class ImageMode(IntEnum): CONTINUOUS = 2 +class EraseState(IntEnum): + DONE = 0 + ERASE = 1 + +class AcquireState(IntEnum): + DONE = 0 + ACQUIRE = 1 + + +class TriggerMode(IntEnum): + SOFTWARE = 0 + INTERNAL = 1 + IDC = 2 + TTL_VETO_ONLY = 3 + TTL_BOTH = 4 + LVDS_VETO_ONLY = 5 + LVDS_BOTH = 6 + + + +class FlyingDetector(FlyerInterface, Device): + flyer_num_frames = Cpt(Signal) + + def save_fly_datum(self, *, value, timestamp, obj, **kwargs): + """Callback to save data from a signal during fly-scanning.""" + datum = (timestamp, value) + self._fly_data.setdefault(obj, []).append(datum) + + def kickoff(self) -> StatusBase: + # Set up subscriptions for capturing data + self._fly_data = {} + for walk in self.walk_fly_signals(): + sig = walk.item + sig.subscribe(self.save_fly_datum, run=True) + + # Set up the status for when the detector is ready to fly + def check_acquiring(*, old_value, value, **kwargs): + is_acquiring = value == self.detector_states.ACQUIRE + if is_acquiring: + self.start_fly_timestamp = time.time() + return is_acquiring + + status = SubscriptionStatus(self.cam.detector_state, check_acquiring) + # Set the right parameters + status &= self.cam.trigger_mode.set(TriggerMode.TTL_VETO_ONLY) + status &= self.cam.num_images.set(2**14) + status &= self.cam.acquire.set(AcquireState.ACQUIRE) + return status + + def complete(self) -> StatusBase: + """Wait for flying to be complete. + + This commands the Xspress to stop acquiring fly-scan data. + + Returns + ------- + complete_status : StatusBase + Indicate when flying has completed + """ + # Remove subscriptions for capturing fly-scan data + for walk in self.walk_fly_signals(): + sig = walk.item + sig.clear_sub(self.save_fly_datum) + return self.acquire.set(0) + + def collect(self) -> dict: + """Generate the data events that were collected during the fly scan.""" + # Load the collected data, and get rid of extras + fly_data, fly_ts = self.fly_data() + fly_data.drop("timestamps", inplace=True, axis="columns") + fly_ts.drop("timestamps", inplace=True, axis="columns") + # Yield each row one at a time + for data_row, ts_row in zip(fly_data.iterrows(), fly_ts.iterrows()): + payload = { + "data": {sig.name: val for (sig, val) in data_row[1].items()}, + "timestamps": {sig.name: val for (sig, val) in ts_row[1].items()}, + "time": float(np.median(np.unique(ts_row[1].values))), + } + yield payload + + def describe_collect(self) -> Dict[str, Dict]: + """Describe details for the flyer collect() method""" + desc = OrderedDict() + for walk in self.walk_fly_signals(): + desc.update(walk.item.describe()) + return {self.name: desc} + + def walk_fly_signals(self, *, include_lazy=False): + """Walk all signals in the Device hierarchy that are to be read during + fly-scanning. + + Parameters + ---------- + include_lazy : bool, optional + Include not-yet-instantiated lazy signals + + Yields + ------ + ComponentWalk + Where ancestors is all ancestors of the signal, including the + top-level device `walk_signals` was called on. + + """ + for walk in self.walk_signals(): + # Image counter has to be included for data alignment + if walk.item is self.cam.array_counter: + yield walk + continue + # Only include readable signals + if not bool(walk.item.kind & Kind.normal): + continue + # ROI sums do not get captured properly during flying + # Instead, they should be calculated at the end + # if self.roi_sums in walk.ancestors: + # continue + yield walk + + class AsyncCamMixin(OphydObject): """A mixin that allows for delayed evaluation of the connection status. @@ -120,7 +247,7 @@ def stage(self): super().stage() -class DetectorBase(OphydDetectorBase): +class DetectorBase(FlyingDetector, OphydDetectorBase): def __init__(self, *args, description=None, **kwargs): super().__init__(*args, **kwargs) if description is None: @@ -129,7 +256,7 @@ def __init__(self, *args, description=None, **kwargs): @property def default_time_signal(self): - return self.cam.acquire_time + return self.cam.acquire_time class StatsMixin: diff --git a/src/haven/tests/test_area_detector.py b/src/haven/tests/test_area_detector.py index db467b24..e578c198 100644 --- a/src/haven/tests/test_area_detector.py +++ b/src/haven/tests/test_area_detector.py @@ -1,4 +1,72 @@ -from haven.instrument.area_detector import load_area_detectors +import time + +import pytest +import numpy as np +from ophyd.sim import instantiate_fake_device +from ophyd import ADComponent as ADCpt +from ophyd.areadetector.cam import AreaDetectorCam + +from haven.instrument.area_detector import load_area_detectors, DetectorBase + + +class Detector(DetectorBase): + cam = ADCpt(AreaDetectorCam, "cam1:") + + +@pytest.fixture() +def detector(sim_registry): + det = instantiate_fake_device(Detector) + return det + + +def test_flyscan_kickoff(detector): + detector.flyer_num_frames.set(10) + status = detector.kickoff() + # detector.acquiring.set(1) + status.wait() + assert status.success + assert status.done + # Check that the device was properly configured for fly-scanning + assert detector.cam.acquire.get() == 1 + assert detector._fly_data == [] + # Check that timestamps get recorded when new data are available + detector.cam.num_images_counter.sim_put(1) + assert detector._fly_data[0] == pytest.approx(time.time()) + + +def test_flyscan_complete(sim_ion_chamber): + flyer = sim_ion_chamber + # Run the complete method + status = flyer.complete() + status.wait() + # Check that the detector is stopped + assert flyer.stop_all._readback == 1 + + +def test_flyscan_collect(sim_ion_chamber): + flyer = sim_ion_chamber + name = flyer.net_counts.name + flyer.start_timestamp = 988.0 + # Make fake fly-scan data + sim_data = np.zeros(shape=(8000,)) + sim_data[:6] = [3, 5, 8, 13, 2, 33] + flyer.mca.spectrum._readback = sim_data + sim_times = np.asarray([12.0e7, 4.0e7, 4.0e7, 4.0e7, 4.0e7, 4.0e7]) + flyer.mca_times.spectrum._readback = sim_times + flyer.frequency.set(1e7).wait() + # Ignore the first collected data point because it's during taxiing + expected_data = sim_data[1:] + # The real timestamps should be midway between PSO pulses + flyer.timestamps = [1000, 1004, 1008, 1012, 1016, 1020] + expected_timestamps = [1002.0, 1006.0, 1010.0, 1014.0, 1018.0] + payload = list(flyer.collect()) + # Confirm data have the right structure + for datum, value, timestamp in zip(payload, expected_data, expected_timestamps): + assert datum == { + "data": {name: [value]}, + "timestamps": {name: [timestamp]}, + "time": timestamp, + } def test_load_area_detectors(sim_registry): From 2426b659d52566f45f582d242303acd207290e88 Mon Sep 17 00:00:00 2001 From: yannachen Date: Wed, 10 Jul 2024 11:18:51 -0500 Subject: [PATCH 04/26] Tested the flyable area detector at the beamline. --- src/haven/instrument/area_detector.py | 69 +++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 7ab0af17..da40c38f 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -4,6 +4,7 @@ from collections import OrderedDict from typing import Dict +import pandas as pd import numpy as np from apstools.devices import CamMixin_V34, SingleTrigger_V34 from ophyd import ADComponent as ADCpt @@ -82,11 +83,27 @@ class TriggerMode(IntEnum): TTL_BOTH = 4 LVDS_VETO_ONLY = 5 LVDS_BOTH = 6 + + +class DetectorState(IntEnum): + IDLE = 0 + ACQUIRE = 1 + READOUT = 2 + CORRECT = 3 + SAVING = 4 + ABORTING = 5 + ERROR = 6 + WAITING = 7 + INITIALIZING = 8 + DISCONNECTED = 9 + ABORTED = 10 + class FlyingDetector(FlyerInterface, Device): flyer_num_frames = Cpt(Signal) + flyscan_trigger_mode = TriggerMode.SOFTWARE def save_fly_datum(self, *, value, timestamp, obj, **kwargs): """Callback to save data from a signal during fly-scanning.""" @@ -102,14 +119,16 @@ def kickoff(self) -> StatusBase: # Set up the status for when the detector is ready to fly def check_acquiring(*, old_value, value, **kwargs): - is_acquiring = value == self.detector_states.ACQUIRE + is_acquiring = value == DetectorState.ACQUIRE if is_acquiring: self.start_fly_timestamp = time.time() return is_acquiring status = SubscriptionStatus(self.cam.detector_state, check_acquiring) # Set the right parameters - status &= self.cam.trigger_mode.set(TriggerMode.TTL_VETO_ONLY) + self._original_vals.setdefault(self.cam.image_mode, self.cam.image_mode.get()) + status &= self.cam.image_mode.set(ImageMode.CONTINUOUS) + status &= self.cam.trigger_mode.set(self.flyscan_trigger_mode) status &= self.cam.num_images.set(2**14) status &= self.cam.acquire.set(AcquireState.ACQUIRE) return status @@ -128,7 +147,7 @@ def complete(self) -> StatusBase: for walk in self.walk_fly_signals(): sig = walk.item sig.clear_sub(self.save_fly_datum) - return self.acquire.set(0) + return self.cam.acquire.set(AcquireState.DONE) def collect(self) -> dict: """Generate the data events that were collected during the fly scan.""" @@ -152,6 +171,50 @@ def describe_collect(self) -> Dict[str, Dict]: desc.update(walk.item.describe()) return {self.name: desc} + def fly_data(self): + """Compile the fly-scan data into a pandas dataframe.""" + # Get the data for frame number as a reference + image_counter = pd.DataFrame( + self._fly_data[self.cam.array_counter], + columns=["timestamps", "image_counter"], + ) + image_counter["image_counter"] -= 2 # Correct for stray frames + # Build all the individual signals' dataframes + dfs = [] + for sig, data in self._fly_data.items(): + df = pd.DataFrame(data, columns=["timestamps", sig]) + old_shape = df.shape + nums = (df.timestamps - image_counter.timestamps).abs() + + # Assign each datum an image number based on timestamp + def get_image_num(ts): + """Get the image number taken closest to a given timestamp.""" + num = image_counter.iloc[ + (image_counter["timestamps"] - ts).abs().argsort()[:1] + ] + num = num["image_counter"].iloc[0] + return num + + im_nums = [get_image_num(ts) for ts in df.timestamps.values] + df.index = im_nums + # Remove duplicates and intermediate ROI sums + df.sort_values("timestamps") + df = df.groupby(df.index).last() + dfs.append(df) + # Combine frames into monolithic dataframes + data = image_counter.copy() + data = data.set_index("image_counter", drop=True) + timestamps = data.copy() + for df in dfs: + sig = df.columns[1] + data[sig] = df[sig] + timestamps[sig] = df["timestamps"] + # Fill in missing values, most likely because the value didn't + # change so no new camonitor reply was received + data = data.ffill(axis=0) + timestamps = timestamps.ffill(axis=1) + return data, timestamps + def walk_fly_signals(self, *, include_lazy=False): """Walk all signals in the Device hierarchy that are to be read during fly-scanning. From 06efc34e6f88894f719350306de47cbe1da2ce65 Mon Sep 17 00:00:00 2001 From: yannachen Date: Wed, 10 Jul 2024 11:40:07 -0500 Subject: [PATCH 05/26] Re-wrote the fly scan plan so it can be compatible with slew scanning. --- src/haven/instrument/area_detector.py | 2 +- src/haven/instrument/motor.py | 12 +++---- src/haven/plans/fly.py | 45 +++++++++++++++++++++------ 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index da40c38f..149212d8 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -102,7 +102,7 @@ class DetectorState(IntEnum): class FlyingDetector(FlyerInterface, Device): - flyer_num_frames = Cpt(Signal) + flyer_num_points = Cpt(Signal) flyscan_trigger_mode = TriggerMode.SOFTWARE def save_fly_datum(self, *, value, timestamp, obj, **kwargs): diff --git a/src/haven/instrument/motor.py b/src/haven/instrument/motor.py index cd47a4f2..a524be8e 100644 --- a/src/haven/instrument/motor.py +++ b/src/haven/instrument/motor.py @@ -41,8 +41,8 @@ class HavenMotor(FlyerInterface, EpicsMotor): start_position = Cpt(Signal, name="start_position", value=0, kind=Kind.config) end_position = Cpt(Signal, name="end_position", value=1, kind=Kind.config) # step_size = Cpt(Signal, name="step_size", value=1, kind=Kind.config) - num_points = Cpt(Signal, name="num_points", value=2, kind=Kind.config) - dwell_time = Cpt(Signal, name="dwell_time", value=1, kind=Kind.config) + flyer_num_points = Cpt(Signal, value=2, kind=Kind.config) + flyer_dwell_time = Cpt(Signal, value=1, kind=Kind.config) # Calculated fly parameters slew_speed = Cpt(Signal, value=1, kind=Kind.config) @@ -55,8 +55,8 @@ def __init__(self, *args, **kwargs): self.motor_egu.subscribe(self._update_fly_params) self.start_position.subscribe(self._update_fly_params) self.end_position.subscribe(self._update_fly_params) - self.num_points.subscribe(self._update_fly_params) - self.dwell_time.subscribe(self._update_fly_params) + self.flyer_num_points.subscribe(self._update_fly_params) + self.flyer_dwell_time.subscribe(self._update_fly_params) self.acceleration.subscribe(self._update_fly_params) def stage(self): @@ -160,8 +160,8 @@ def _update_fly_params(self, *args, **kwargs): egu = self.motor_egu.get() start_position = self.start_position.get() end_position = self.end_position.get() - dwell_time = self.dwell_time.get() - num_points = self.num_points.get() + dwell_time = self.flyer_dwell_time.get() + num_points = self.flyer_num_points.get() accel_time = self.acceleration.get() # Check for sane values if dwell_time == 0: diff --git a/src/haven/plans/fly.py b/src/haven/plans/fly.py index 0a17f028..45ec19a6 100644 --- a/src/haven/plans/fly.py +++ b/src/haven/plans/fly.py @@ -13,13 +13,34 @@ __all__ = ["fly_scan", "grid_fly_scan"] -def fly_line_scan(detectors: list, flyer, start, stop, num, extra_signals=()): - """A plan stub for fly-scanning a single trajectory.""" +def fly_line_scan(detectors: list, flyer, start, stop, num, extra_signals=(), combine_streams=True): + """A plan stub for fly-scanning a single trajectory. + + Parameters + ========== + detectors + List of 'readable' objects that support the flyer interface + flyer + The thing going to get moved. + start + The center of the first pixel in *flyer*. + stop + The center of the last measurement in *flyer*. + num + Number of measurements to take. + combine_streams + If true, the separate data streams will be combined into one + "primary" data stream (experimental). + extra_signals + If combining data streams, these signals will also get included + separately. + + """ # Calculate parameters for the fly-scan - step_size = abs(start - stop) / (num - 1) + # step_size = abs(start - stop) / (num - 1) yield from bps.mv(flyer.start_position, start) yield from bps.mv(flyer.end_position, stop) - yield from bps.mv(flyer.step_size, step_size) + yield from bps.mv(flyer.flyer_num_points, num) # Perform the fly scan flyers = [flyer, *detectors] for flyer_ in flyers: @@ -27,10 +48,16 @@ def fly_line_scan(detectors: list, flyer, start, stop, num, extra_signals=()): for flyer_ in flyers: yield from bps.complete(flyer_, wait=True) # Collect the data after flying - collector = FlyerCollector( - flyers=flyers, name="flyer_collector", extra_signals=extra_signals - ) - yield from bps.collect(collector) + if combine_streams: + # Collect data together as a single "primary" data stream + collector = FlyerCollector( + flyers=flyers, name="flyer_collector", extra_signals=extra_signals + ) + yield from bps.collect(collector) + else: + # Collect data into separate data streams + for flyer_ in flyers: + yield from bps.collect(flyer_) # @baseline_decorator() @@ -82,7 +109,7 @@ def fly_scan( } md_.update(md) # Execute the plan - line_scan = fly_line_scan(detectors, flyer, start, stop, num) + line_scan = fly_line_scan(detectors, flyer, start, stop, num, combine_streams=False) line_scan = bpp.run_wrapper(line_scan, md=md_) line_scan = bpp.stage_wrapper(line_scan, devices) yield from line_scan From 70ffeaa1a05748abf2ecdece3b88bb157ecaca00 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Wed, 10 Jul 2024 16:12:08 -0500 Subject: [PATCH 06/26] Camera now uses ophyd FileStore subclasses to set up AD plugins. --- src/haven/iconfig_testing.toml | 3 +++ src/haven/instrument/area_detector.py | 35 +++++++++++++++++++++------ src/haven/instrument/camera.py | 9 ++++--- src/haven/tests/test_camera.py | 25 +++++++++++++------ 4 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/haven/iconfig_testing.toml b/src/haven/iconfig_testing.toml index b7de7e21..d415b225 100644 --- a/src/haven/iconfig_testing.toml +++ b/src/haven/iconfig_testing.toml @@ -177,6 +177,9 @@ prefix = "255idcVME:" vertical_motor = "m26" horizontal_motor = "m25" +[area_detector] +root_path = "tmp" # Omit leading slash, will get added by ophyd + [area_detector.sim_det] prefix = "255idSimDet" diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 149212d8..0f5a9844 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -22,7 +22,7 @@ ) from ophyd.status import StatusBase, SubscriptionStatus from ophyd.areadetector.base import EpicsSignalWithRBV as SignalWithRBV -from ophyd.areadetector.filestore_mixins import FileStoreHDF5IterativeWrite +from ophyd.areadetector.filestore_mixins import FileStoreHDF5IterativeWrite, FileStoreTIFFIterativeWrite from ophyd.areadetector.plugins import ( HDF5Plugin_V31, HDF5Plugin_V34, @@ -101,7 +101,7 @@ class DetectorState(IntEnum): -class FlyingDetector(FlyerInterface, Device): +class FlyerMixin(FlyerInterface, Device): flyer_num_points = Cpt(Signal) flyscan_trigger_mode = TriggerMode.SOFTWARE @@ -297,7 +297,25 @@ def __init__(self, *args, **kwargs): self.stage_sigs[self.num_capture] = 0 -class MyHDF5Plugin(FileStoreHDF5IterativeWrite, HDF5Plugin_V34): +class DynamicFileStore(Device): + """File store mixin that alters the write_path_template based on + iconfig values. + + """ + def __init__(self, *args, write_path_template="/{root_path}/%Y/%m/{name}/", **kwargs): + super().__init__(*args, write_path_template=write_path_template, **kwargs) + # Format the file_write_template with per-device values + config = load_config() + try: + self.write_path_template = self.write_path_template.format( + name=self.parent.name, + root_path=config["area_detector"].get("root_path", "/tmp"), + ) + except KeyError: + warnings.warn(f"Could not format write_path_template {write_path_template}") + + +class HDF5FilePlugin(DynamicFileStore, FileStoreHDF5IterativeWrite, HDF5Plugin_V34): """ Add data acquisition methods to HDF5Plugin. * ``stage()`` - prepare device PVs befor data acquisition @@ -310,7 +328,11 @@ def stage(self): super().stage() -class DetectorBase(FlyingDetector, OphydDetectorBase): +class TIFFFilePlugin(DynamicFileStore, FileStoreTIFFIterativeWrite, HDF5Plugin_V34): + ... + + +class DetectorBase(FlyerMixin, OphydDetectorBase): def __init__(self, *args, description=None, **kwargs): super().__init__(*args, **kwargs) if description is None: @@ -356,10 +378,9 @@ class SimDetector(SingleTrigger_V34, DetectorBase): image = ADCpt(ImagePlugin_V34, "image1:") pva = ADCpt(PvaPlugin_V34, "Pva1:") hdf1 = ADCpt( - type("HDF5Plugin", (StageCapture, HDF5Plugin_V34), {}), + HDF5FilePlugin, "HDF1:", - # write_path_template="/tmp/", - # read_path_template=READ_PATH_TEMPLATE, + write_path_template="/tmp/", ) roi1 = ADCpt(ROIPlugin_V34, "ROI1:", kind=Kind.config) roi2 = ADCpt(ROIPlugin_V34, "ROI2:", kind=Kind.config) diff --git a/src/haven/instrument/camera.py b/src/haven/instrument/camera.py index 8cb80d21..25068a4f 100644 --- a/src/haven/instrument/camera.py +++ b/src/haven/instrument/camera.py @@ -20,6 +20,8 @@ SimDetector, SingleImageModeTrigger, StatsPlugin_V34, + HDF5FilePlugin, + TIFFFilePlugin, ) from .device import make_device @@ -39,7 +41,8 @@ class AravisDetector(SingleImageModeTrigger, DetectorBase): A gige-vision camera described by EPICS. """ - _default_configuration_attrs = ("cam", "hdf", "tiff") + _default_configuration_attrs = ("cam", "hdf", "tiff", "stats1", "stats2", "stats3", "stats4") + _default_read_attrs = ("cam", "hdf", "tiff") cam = ADCpt(AravisCam, "cam1:") image = ADCpt(ImagePlugin_V34, "image1:") @@ -54,8 +57,8 @@ class AravisDetector(SingleImageModeTrigger, DetectorBase): stats3 = ADCpt(StatsPlugin_V34, "Stats3:", kind=Kind.normal) stats4 = ADCpt(StatsPlugin_V34, "Stats4:", kind=Kind.normal) stats5 = ADCpt(StatsPlugin_V34, "Stats5:", kind=Kind.normal) - hdf = ADCpt(HDF5Plugin_V34, "HDF1:", kind=Kind.normal) - tiff = ADCpt(TIFFPlugin_V34, "TIFF1:", kind=Kind.normal) + hdf = ADCpt(HDF5FilePlugin, "HDF1:", kind=Kind.normal) + tiff = ADCpt(TIFFFilePlugin, "TIFF1:", kind=Kind.normal) def load_cameras(config=None) -> Sequence[DetectorBase]: diff --git a/src/haven/tests/test_camera.py b/src/haven/tests/test_camera.py index 0ed9c103..15105bcb 100644 --- a/src/haven/tests/test_camera.py +++ b/src/haven/tests/test_camera.py @@ -1,4 +1,6 @@ +import pytest from ophyd import DetectorBase +from ophyd.sim import instantiate_fake_device from haven import load_config, registry from haven.instrument.camera import AravisDetector, load_cameras @@ -14,22 +16,31 @@ def test_load_cameras(sim_registry): assert isinstance(cameras[0], DetectorBase) -def test_camera_device(): - camera = AravisDetector(PREFIX, name="camera") +@pytest.fixture() +def camera(sim_registry): + camera = instantiate_fake_device(AravisDetector, prefix="255idgigeA:", name="camera") + return camera + + +def test_camera_device(camera): assert isinstance(camera, DetectorBase) assert hasattr(camera, "cam") -def test_camera_in_registry(sim_registry): - camera = AravisDetector(PREFIX, name="camera") +def test_camera_in_registry(sim_registry, camera): # Check that all sub-components are accessible - camera = sim_registry.find(camera.name) sim_registry.find(f"{camera.name}_cam") sim_registry.find(f"{camera.name}_cam.gain") -def test_default_time_signal(sim_camera): - assert sim_camera.default_time_signal is sim_camera.cam.acquire_time +def test_default_time_signal(camera): + assert camera.default_time_signal is camera.cam.acquire_time + + +def test_hdf5_write_path(camera): + # The HDF5 file should get dynamically re-written based on config file + assert camera.hdf.write_path_template == "/tmp/%Y/%m/camera" + assert camera.tiff.write_path_template == "/tmp/%Y/%m/camera" # ----------------------------------------------------------------------------- From 74eef5181059ab31e6036c8fc8e94491e491b683 Mon Sep 17 00:00:00 2001 From: yannachen Date: Sat, 13 Jul 2024 12:05:37 -0500 Subject: [PATCH 07/26] Upgrade the APS device to the latest apstools version. Also disabled the shutter suspender since we can't test it yet. --- environment.yml | 5 ++--- src/haven/instrument/aps.py | 12 ++++++++---- src/haven/run_engine.py | 20 +++++++++++--------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/environment.yml b/environment.yml index 230f0bb9..22d463fd 100644 --- a/environment.yml +++ b/environment.yml @@ -84,8 +84,7 @@ dependencies: # --- Bluesky framework packages - adl2pydm - # apstools is installed from github by pip until DG645 and SRS570 settling are released - # - apstools >=1.6.16 + - apstools >=1.6.20 - area-detector-handlers - bluesky-queueserver - bluesky-queueserver-api @@ -135,7 +134,7 @@ dependencies: - xraydb >=4.5.0 - pytest-timeout # Get rid of this if tests are not hanging - git+https://github.com/pcdshub/pcdsdevices - - git+https://github.com/BCDA-APS/apstools.git@a165d24b2ae272ba0db3fb73d9feba4416f40631 + # - git+https://github.com/BCDA-APS/apstools.git@a165d24b2ae272ba0db3fb73d9feba4416f40631 # - git+https://github.com/BCDA-APS/apstools.git@50a142f1cc761553f14570c6c5f7e799846a0ddf # - https://github.com/BCDA-APS/adl2pydm/archive/main.zip # --- optional Bluesky framework packages for evaluation diff --git a/src/haven/instrument/aps.py b/src/haven/instrument/aps.py index b76c4c22..2d180bd9 100644 --- a/src/haven/instrument/aps.py +++ b/src/haven/instrument/aps.py @@ -1,6 +1,7 @@ import logging from apstools.devices.aps_machine import ApsMachineParametersDevice +from ophyd import Component as Cpt, EpicsSignalRO from .._iconfig import load_config from .device import make_device @@ -17,15 +18,18 @@ class ApsMachine(ApsMachineParametersDevice): "aps_cycle", "machine_status", "operating_mode", - "shutter_permit", + "shutter_status", "fill_number", "orbit_correction", - "global_feedback", - "global_feedback_h", - "global_feedback_v", + # Removed in apstools 1.6.20 + # "global_feedback", + # "global_feedback_h", + # "global_feedback_v", "operator_messages", ] + shutter_status = Cpt(EpicsSignalRO, "RF-ACIS:FePermit:Sect1To35IdM.RVAL") + def load_aps(config=None): """Load devices related to the synchrotron as a whole.""" diff --git a/src/haven/run_engine.py b/src/haven/run_engine.py index 93d2626b..e56483af 100644 --- a/src/haven/run_engine.py +++ b/src/haven/run_engine.py @@ -38,15 +38,17 @@ def run_engine(connect_databroker=True, use_bec=True) -> BlueskyRunEngine: log.warning("APS device not found, suspenders not installed.") else: # Suspend when shutter permit is disabled - RE.install_suspender( - suspenders.SuspendWhenChanged( - signal=aps.shutter_permit, - expected_value="PERMIT", - allow_resume=True, - sleep=3, - tripped_message="Shutter permit revoked.", - ) - ) + # Re-enable when the APS shutter permit signal is better understood + pass + # RE.install_suspender( + # suspenders.SuspendWhenChanged( + # signal=aps.shutter_permit, + # expected_value="PERMIT", + # allow_resume=True, + # sleep=3, + # tripped_message="Shutter permit revoked.", + # ) + # ) # Install databroker connection if connect_databroker: RE.subscribe(save_data) From fa9abcfa67c49b29857a2582215e86de0ba8753c Mon Sep 17 00:00:00 2001 From: yannachen Date: Sat, 13 Jul 2024 12:07:48 -0500 Subject: [PATCH 08/26] Dask is optional for tiled client and no longer uses local caching. --- src/haven/__init__.py | 2 +- src/haven/catalog.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/haven/__init__.py b/src/haven/__init__.py index 08623555..ad698038 100644 --- a/src/haven/__init__.py +++ b/src/haven/__init__.py @@ -11,7 +11,7 @@ # Top-level imports # from .catalog import load_catalog, load_data, load_result, tiled_client # noqa: F401 -from .catalog import catalog # noqa: F401 +from .catalog import load_catalog, tiled_client # noqa: F401 from .constants import edge_energy # noqa: F401 from .energy_ranges import ERange, KRange, merge_ranges # noqa: F401 from .instrument import ( # noqa: F401 diff --git a/src/haven/catalog.py b/src/haven/catalog.py index b2b4a73f..6e5af2f9 100644 --- a/src/haven/catalog.py +++ b/src/haven/catalog.py @@ -169,7 +169,7 @@ def write_safe(self): delete = with_thread_lock(Cache.delete) -def tiled_client(entry_node=None, uri=None, cache_filepath=None): +def tiled_client(entry_node=None, uri=None, cache_filepath=None, structure_clients="dask"): config = load_config() # Create a cache for saving local copies if cache_filepath is None: @@ -179,7 +179,7 @@ def tiled_client(entry_node=None, uri=None, cache_filepath=None): # Create the client if uri is None: uri = config["database"]["tiled"]["uri"] - client_ = from_uri(uri, "dask", cache=cache) + client_ = from_uri(uri, structure_clients) if entry_node is None: entry_node = config["database"]["tiled"]["entry_node"] client_ = client_[entry_node] From a1063b699e63ce51638a046d0d7015fc0bf2325a Mon Sep 17 00:00:00 2001 From: yannachen Date: Sat, 13 Jul 2024 12:09:10 -0500 Subject: [PATCH 09/26] Cleaned up support for tiff AD file plugin. - Made into proper FileStore subclass - Removed by default for area detectors - Fixed a bug when calling ``load_area_detectors()`` --- src/haven/instrument/area_detector.py | 11 ++++++++--- src/haven/instrument/camera.py | 6 +++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 0f5a9844..b08e1e2e 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -33,10 +33,11 @@ PvaPlugin_V34, ROIPlugin_V31, ROIPlugin_V34, + TIFFPlugin_V31, + TIFFPlugin_V34, ) from ophyd.areadetector.plugins import StatsPlugin_V31 as OphydStatsPlugin_V31 from ophyd.areadetector.plugins import StatsPlugin_V34 as OphydStatsPlugin_V34 -from ophyd.areadetector.plugins import TIFFPlugin_V31 from ophyd.flyers import FlyerInterface from .. import exceptions @@ -328,7 +329,7 @@ def stage(self): super().stage() -class TIFFFilePlugin(DynamicFileStore, FileStoreTIFFIterativeWrite, HDF5Plugin_V34): +class TIFFFilePlugin(DynamicFileStore, FileStoreTIFFIterativeWrite, TIFFPlugin_V34): ... @@ -469,7 +470,11 @@ def load_area_detectors(config=None) -> set: # Create the area detectors defined in the configuration devices = [] for name, adconfig in config.get("area_detector", {}).items(): - DeviceClass = globals().get(adconfig["device_class"]) + try: + DeviceClass = globals().get(adconfig["device_class"]) + except TypeError: + # Not a sub-dictionary, so move on + continue # Check that it's a valid device class if DeviceClass is None: msg = f"area_detector.{name}.device_class={adconfig['device_class']}" diff --git a/src/haven/instrument/camera.py b/src/haven/instrument/camera.py index 25068a4f..a7d569f1 100644 --- a/src/haven/instrument/camera.py +++ b/src/haven/instrument/camera.py @@ -41,8 +41,8 @@ class AravisDetector(SingleImageModeTrigger, DetectorBase): A gige-vision camera described by EPICS. """ - _default_configuration_attrs = ("cam", "hdf", "tiff", "stats1", "stats2", "stats3", "stats4") - _default_read_attrs = ("cam", "hdf", "tiff") + _default_configuration_attrs = ("cam", "hdf", "stats1", "stats2", "stats3", "stats4") + _default_read_attrs = ("cam", "hdf", "stats1", "stats2", "stats3", "stats4") cam = ADCpt(AravisCam, "cam1:") image = ADCpt(ImagePlugin_V34, "image1:") @@ -58,7 +58,7 @@ class AravisDetector(SingleImageModeTrigger, DetectorBase): stats4 = ADCpt(StatsPlugin_V34, "Stats4:", kind=Kind.normal) stats5 = ADCpt(StatsPlugin_V34, "Stats5:", kind=Kind.normal) hdf = ADCpt(HDF5FilePlugin, "HDF1:", kind=Kind.normal) - tiff = ADCpt(TIFFFilePlugin, "TIFF1:", kind=Kind.normal) + # tiff = ADCpt(TIFFFilePlugin, "TIFF1:", kind=Kind.normal) def load_cameras(config=None) -> Sequence[DetectorBase]: From 7b7f0f94b7ef1d8b66c5e992ddec60f086c57b12 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sat, 13 Jul 2024 13:42:35 -0500 Subject: [PATCH 10/26] Fixed tests that were broken by testing at the beamline. --- src/haven/instrument/area_detector.py | 11 ++++--- src/haven/tests/test_aps.py | 18 ----------- src/haven/tests/test_area_detector.py | 19 +++++------ src/haven/tests/test_camera.py | 1 - src/haven/tests/test_motor.py | 45 +++++++++++++-------------- 5 files changed, 38 insertions(+), 56 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index b08e1e2e..833d1ccf 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -1,7 +1,7 @@ import logging import time from enum import IntEnum -from collections import OrderedDict +from collections import OrderedDict, namedtuple from typing import Dict import pandas as pd @@ -98,8 +98,9 @@ class DetectorState(IntEnum): INITIALIZING = 8 DISCONNECTED = 9 ABORTED = 10 - - + + +fly_event = namedtuple("fly_event", ('timestamp', 'value')) class FlyerMixin(FlyerInterface, Device): @@ -108,8 +109,8 @@ class FlyerMixin(FlyerInterface, Device): def save_fly_datum(self, *, value, timestamp, obj, **kwargs): """Callback to save data from a signal during fly-scanning.""" - datum = (timestamp, value) - self._fly_data.setdefault(obj, []).append(datum) + datum = fly_event(timestamp=timestamp, value=value) + self._fly_data.setdefault(obj, []).append(datum) def kickoff(self) -> StatusBase: # Set up subscriptions for capturing data diff --git a/src/haven/tests/test_aps.py b/src/haven/tests/test_aps.py index 12c5eb82..cb27b255 100644 --- a/src/haven/tests/test_aps.py +++ b/src/haven/tests/test_aps.py @@ -14,24 +14,6 @@ def test_read_attrs(): assert attr in device.read_attrs -def test_config_attrs(): - device = aps.ApsMachine(name="Aps") - config_attrs = [ - "aps_cycle", - "machine_status", - "operating_mode", - "shutter_permit", - "fill_number", - "orbit_correction", - "global_feedback", - "global_feedback_h", - "global_feedback_v", - "operator_messages", - ] - for attr in config_attrs: - assert attr in device.configuration_attrs - - # ----------------------------------------------------------------------------- # :author: Mark Wolfman # :email: wolfman@anl.gov diff --git a/src/haven/tests/test_area_detector.py b/src/haven/tests/test_area_detector.py index e578c198..a98cc827 100644 --- a/src/haven/tests/test_area_detector.py +++ b/src/haven/tests/test_area_detector.py @@ -6,7 +6,7 @@ from ophyd import ADComponent as ADCpt from ophyd.areadetector.cam import AreaDetectorCam -from haven.instrument.area_detector import load_area_detectors, DetectorBase +from haven.instrument.area_detector import load_area_detectors, DetectorBase, DetectorState class Detector(DetectorBase): @@ -20,25 +20,26 @@ def detector(sim_registry): def test_flyscan_kickoff(detector): - detector.flyer_num_frames.set(10) + detector.flyer_num_points.set(10) status = detector.kickoff() - # detector.acquiring.set(1) - status.wait() + detector.cam.detector_state.sim_put(DetectorState.ACQUIRE) + status.wait(timeout=3) assert status.success assert status.done # Check that the device was properly configured for fly-scanning assert detector.cam.acquire.get() == 1 - assert detector._fly_data == [] + assert detector._fly_data == {} # Check that timestamps get recorded when new data are available - detector.cam.num_images_counter.sim_put(1) - assert detector._fly_data[0] == pytest.approx(time.time()) + detector.cam.array_counter.sim_put(1) + event = detector._fly_data[detector.cam.array_counter] + assert event[0].timestamp == pytest.approx(time.time()) def test_flyscan_complete(sim_ion_chamber): flyer = sim_ion_chamber # Run the complete method status = flyer.complete() - status.wait() + status.wait(timeout=3) # Check that the detector is stopped assert flyer.stop_all._readback == 1 @@ -53,7 +54,7 @@ def test_flyscan_collect(sim_ion_chamber): flyer.mca.spectrum._readback = sim_data sim_times = np.asarray([12.0e7, 4.0e7, 4.0e7, 4.0e7, 4.0e7, 4.0e7]) flyer.mca_times.spectrum._readback = sim_times - flyer.frequency.set(1e7).wait() + flyer.frequency.set(1e7).wait(timeout=3) # Ignore the first collected data point because it's during taxiing expected_data = sim_data[1:] # The real timestamps should be midway between PSO pulses diff --git a/src/haven/tests/test_camera.py b/src/haven/tests/test_camera.py index 15105bcb..e683a252 100644 --- a/src/haven/tests/test_camera.py +++ b/src/haven/tests/test_camera.py @@ -40,7 +40,6 @@ def test_default_time_signal(camera): def test_hdf5_write_path(camera): # The HDF5 file should get dynamically re-written based on config file assert camera.hdf.write_path_template == "/tmp/%Y/%m/camera" - assert camera.tiff.write_path_template == "/tmp/%Y/%m/camera" # ----------------------------------------------------------------------------- diff --git a/src/haven/tests/test_motor.py b/src/haven/tests/test_motor.py index f77a5cf0..436105a8 100644 --- a/src/haven/tests/test_motor.py +++ b/src/haven/tests/test_motor.py @@ -1,3 +1,5 @@ +from collections import OrderedDict + import pytest from ophyd.sim import instantiate_fake_device from ophyd.flyers import FlyerInterface @@ -87,13 +89,13 @@ def test_fly_params_forward(motor): """ # Set some example positions - motor.motor_egu.set("micron").wait() - motor.acceleration.set(0.5).wait() # sec - motor.start_position.set(10.).wait() # µm - motor.end_position.set(20.).wait() # µm - motor.encoder_resolution.set(0.001).wait() # µm - motor.num_points.set(101).wait() # µm - motor.dwell_time.set(1).wait() # sec + motor.motor_egu.set("micron").wait(timeout=3) + motor.acceleration.set(0.5).wait(timeout=3) # sec + motor.start_position.set(10.).wait(timeout=3) # µm + motor.end_position.set(20.).wait(timeout=3) # µm + motor.encoder_resolution.set(0.001).wait(timeout=3) # µm + motor.flyer_num_points.set(101).wait(timeout=3) # µm + motor.flyer_dwell_time.set(1).wait(timeout=3) # sec # Check that the fly-scan parameters were calculated correctly assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec @@ -113,12 +115,12 @@ def test_fly_params_reverse(motor): """ # Set some example positions - motor.motor_egu.set("micron").wait() - motor.acceleration.set(0.5).wait() # sec - motor.start_position.set(20.0).wait() # µm - motor.end_position.set(10.0).wait() # µm - motor.num_points.set(101).wait() # µm - motor.dwell_time.set(1).wait() # sec + motor.motor_egu.set("micron").wait(timeout=3) + motor.acceleration.set(0.5).wait(timeout=3) # sec + motor.start_position.set(20.0).wait(timeout=3) # µm + motor.end_position.set(10.0).wait(timeout=3) # µm + motor.flyer_num_points.set(101).wait(timeout=3) # µm + motor.flyer_dwell_time.set(1).wait(timeout=3) # sec # Check that the fly-scan parameters were calculated correctly assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec @@ -133,7 +135,7 @@ def test_fly_params_reverse(motor): def test_kickoff(motor): - motor.dwell_time.set(1.0).wait() + motor.flyer_dwell_time.set(1.0).wait(timeout=3) # Start flying status = motor.kickoff() # Check status behavior matches flyer interface @@ -145,7 +147,7 @@ def test_kickoff(motor): def test_complete(motor): # Set up fake flyer with mocked fly method assert motor.user_setpoint.get() == 0 - motor.taxi_end.set(10).wait() + motor.taxi_end.set(10).wait(timeout=3) # Complete flying status = motor.complete() # Check that the motor was moved @@ -188,15 +190,12 @@ def test_collect(motor): for datum, value, timestamp in zip( payload, motor.pixel_positions, expected_timestamps ): - assert datum == { - "data": { - "m1": value, - }, - "timestamps": { - "m1": timestamp, - }, - "time": timestamp, + assert datum['data'] == { + "m1": value, + "m1_user_setpoint": value, } + assert datum["timestamps"]['m1'] == pytest.approx(timestamp, abs=0.3) + assert datum["time"] == pytest.approx(timestamp, abs=0.3) def test_describe_collect(aerotech_flyer): From 5aa0ba0c1750f825f1791bb464fc9fed16a7e7fe Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sun, 14 Jul 2024 21:04:55 -0500 Subject: [PATCH 11/26] Refactored the motor flyer to use threads. --- src/haven/instrument/aerotech.py | 1 + src/haven/instrument/motor.py | 44 +++++- src/haven/instrument/motor_flyer.py | 232 ++++++++++++++++++++++++++++ src/haven/tests/test_motor.py | 5 +- src/haven/tests/test_motor_flyer.py | 171 ++++++++++++++++++++ 5 files changed, 445 insertions(+), 8 deletions(-) create mode 100644 src/haven/instrument/motor_flyer.py create mode 100644 src/haven/tests/test_motor_flyer.py diff --git a/src/haven/instrument/aerotech.py b/src/haven/instrument/aerotech.py index aabb1438..e667b6c2 100644 --- a/src/haven/instrument/aerotech.py +++ b/src/haven/instrument/aerotech.py @@ -154,6 +154,7 @@ def flight_check(*args, old_value, value, **kwargs) -> bool: status = SubscriptionStatus(self.ready_to_fly, flight_check) # Taxi the motor th = threading.Thread(target=self.taxi) + th.daemon = True th.start() # Record time of fly start of scan self.starttime = time.time() diff --git a/src/haven/instrument/motor.py b/src/haven/instrument/motor.py index a524be8e..395f663a 100644 --- a/src/haven/instrument/motor.py +++ b/src/haven/instrument/motor.py @@ -8,22 +8,24 @@ import numpy as np from apstools.utils.misc import safe_ophyd_name from ophyd import Component as Cpt -from ophyd import EpicsMotor, EpicsSignal, EpicsSignalRO, Signal, Kind +from ophyd import EpicsMotor, EpicsSignal, EpicsSignalRO, Signal, Kind, get_cl from ophyd.flyers import FlyerInterface +from ophyd.status import Status from .._iconfig import load_config from .device import make_device, resolve_device_names from .instrument_registry import InstrumentRegistry from .instrument_registry import registry as default_registry +from .motor_flyer import MotorFlyer log = logging.getLogger(__name__) -class HavenMotor(FlyerInterface, EpicsMotor): +class HavenMotor(MotorFlyer, EpicsMotor): """The default motor for haven movement. This motor also implements the flyer interface and so can be used - in a fly scan, though no hardware trigger is supported. + in a fly scan, though no hardware triggering is supported. Returns to the previous value when being unstaged. @@ -51,6 +53,8 @@ class HavenMotor(FlyerInterface, EpicsMotor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._kickoff_thread = None + self.cl = get_cl() # Set up auto-calculations for the flyer self.motor_egu.subscribe(self._update_fly_params) self.start_position.subscribe(self._update_fly_params) @@ -60,6 +64,7 @@ def __init__(self, *args, **kwargs): self.acceleration.subscribe(self._update_fly_params) def stage(self): + super().stage() # Override some additional staged signals self._original_vals.setdefault(self.user_setpoint, self.user_readback.get()) self._original_vals.setdefault(self.velocity, self.velocity.get()) @@ -76,9 +81,36 @@ def kickoff(self): Indicate when flying is ready. """ - self.move(self.taxi_start.get(), wait=True) - st = self.velocity.set(self.slew_speed.get()) - return st + self.log.debug(f"Kicking off {self}") + + def kickoff_thread(): + try: + print(self.taxi_start.get()) + self.move(self.taxi_start.get(), wait=True) + self.velocity.set(self.slew_speed.get()).wait() + except Exception as exc: + st.set_exception(exc) + else: + self.log.debug(f"{self} kickoff succeeded") + st.set_finished() + finally: + # keep a local reference to avoid any GC shenanigans + th = self._set_thread + # these two must be in this order to avoid a race condition + self._set_thread = None + del th + + if self._kickoff_thread is not None: + raise RuntimeError( + "Another set() call is still in progress " f"for {self.name}" + ) + + st = Status(self) + self._status = st + self._kickoff_thread = self.cl.thread_class(target=kickoff_thread) + self._kickoff_thread.daemon = True + self._kickoff_thread.start() + return self._status def complete(self): """Start the motor flying and wait for it to complete. diff --git a/src/haven/instrument/motor_flyer.py b/src/haven/instrument/motor_flyer.py new file mode 100644 index 00000000..5b973e2e --- /dev/null +++ b/src/haven/instrument/motor_flyer.py @@ -0,0 +1,232 @@ +from collections import OrderedDict +import logging +from typing import Generator, Dict + +from scipy.interpolate import CubicSpline +import numpy as np + +from ophyd import Component as Cpt, Kind, Signal, get_cl, Device +from ophyd.flyers import FlyerInterface +from ophyd.status import Status + + +log = logging.getLogger() + + +class MotorFlyer(FlyerInterface, Device): + # Desired fly parameters + start_position = Cpt(Signal, name="start_position", value=0, kind=Kind.config) + end_position = Cpt(Signal, name="end_position", value=1, kind=Kind.config) + # step_size = Cpt(Signal, name="step_size", value=1, kind=Kind.config) + flyer_num_points = Cpt(Signal, value=2, kind=Kind.config) + flyer_dwell_time = Cpt(Signal, value=1, kind=Kind.config) + + # Calculated fly parameters + slew_speed = Cpt(Signal, value=1, kind=Kind.config) + taxi_start = Cpt(Signal, kind=Kind.config) + taxi_end = Cpt(Signal, kind=Kind.config) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._kickoff_thread = None + self._complete_thread = None + self.cl = get_cl() + # Set up auto-calculations for the flyer + self.motor_egu.subscribe(self._update_fly_params) + self.start_position.subscribe(self._update_fly_params) + self.end_position.subscribe(self._update_fly_params) + self.flyer_num_points.subscribe(self._update_fly_params) + self.flyer_dwell_time.subscribe(self._update_fly_params) + self.acceleration.subscribe(self._update_fly_params) + + def kickoff(self): + """Start the motor as a flyer. + + The status object return is marked as done once flying + is ready. + + Returns + ------- + kickoff_status : StatusBase + Indicate when flying is ready. + + """ + self.log.debug(f"Kicking off {self}") + + def kickoff_thread(): + try: + self.move(self.taxi_start.get(), wait=True) + self.velocity.put(self.slew_speed.get()) + except Exception as exc: + st.set_exception(exc) + else: + self.log.debug(f"{self} kickoff succeeded") + st.set_finished() + finally: + # keep a local reference to avoid any GC shenanigans + th = self._set_thread + # these two must be in this order to avoid a race condition + self._kickoff_thread = None + del th + + if self._kickoff_thread is not None: + raise RuntimeError( + "Another kickoff() call is still in progress " f"for {self.name}" + ) + + st = Status(self) + self._status = st + self._kickoff_thread = self.cl.thread_class(target=kickoff_thread) + self._kickoff_thread.daemon = True + self._kickoff_thread.start() + return self._status + + def complete(self): + """Start the motor flying and wait for it to complete. + + Returns + ------- + complete_status : StatusBase + Indicate when flying has completed + + """ + self.log.debug(f"Comleting {self}") + + def complete_thread(): + try: + # Record real motor positions for later evaluation + self._fly_data = [] + cid = self.user_readback.subscribe(self.record_datum, run=False) + self.move(self.taxi_end.get(), wait=True) + self.user_readback.unsubscribe(cid) + except Exception as exc: + st.set_exception(exc) + else: + self.log.debug(f"{self} complete succeeded") + st.set_finished() + finally: + # keep a local reference to avoid any GC shenanigans + th = self._complete_thread + # these two must be in this order to avoid a race condition + self._complete_thread = None + del th + + if self._complete_thread is not None: + raise RuntimeError( + f"Another complete() call is still in progress for {self.name}" + ) + + st = Status(self) + self._status = st + self._complete_thread = self.cl.thread_class(target=complete_thread) + self._complete_thread.daemon = True + self._complete_thread.start() + return self._status + + def record_datum(self, *, old_value, value, timestamp, **kwargs): + """Record a fly-scan data point so we can report it later.""" + self._fly_data.append((timestamp, value)) + + def collect(self) -> Generator[Dict, None, None]: + """Retrieve data from the flyer as proto-events + + Yields + ------ + event_data : dict + Must have the keys {'time', 'timestamps', 'data'}. + + """ + times, positions = np.asarray(self._fly_data).transpose() + model = CubicSpline(positions, times, bc_type="clamped") + # Create the data objects + for position in self.pixel_positions: + timestamp = float(model(position)) + yield { + "time": timestamp, + "timestamps": { + self.user_readback.name: timestamp, + self.user_setpoint.name: timestamp, + }, + "data": { + self.user_readback.name: position, + self.user_setpoint.name: position, + }, + } + + def describe_collect(self): + """Describe details for the collect() method""" + desc = OrderedDict() + desc.update(self.describe()) + return {self.user_readback.name: desc} + + def _update_fly_params(self, *args, **kwargs): + """Calculate new fly-scan parameters based on signal values. + + Computes several parameters describing the fly scan motion. + These include the actual start position of the motor, the + actual distance between points, and the end position of the + motor. + + Several fields are set in the class: + + direction + 1 if we are moving positive in user coordinates, −1 if + negative + taxi_start + The starting point for motor movement during flying, accounts + for needed acceleration of the motor. + taxi_end + The target point for motor movement during flying, accounts + for needed acceleration of the motor. + pixel_positions + array of places where pixels are, should occur calculated from + encoder counts then translated to motor positions + + """ + # Grab any neccessary signals for calculation + start_position = self.start_position.get() + end_position = self.end_position.get() + dwell_time = self.flyer_dwell_time.get() + num_points = self.flyer_num_points.get() + accel_time = self.acceleration.get() + # Check for sane values + if dwell_time == 0: + log.warning( + f"{self} dwell_time is zero. Could not update fly scan parameters." + ) + return + if accel_time <= 0: + log.warning( + f"{self} acceleration is non-positive. Could not update fly scan" + " parameters." + ) + return + # Determine the desired direction of travel: + # +1 when moving in + encoder direction, -1 if else + direction = 1 if start_position < end_position else -1 + # Determine taxi distance to accelerate to req speed, v^2/(2*a) = d + # x1.5 for safety margin + step_size = abs((start_position - end_position) / (num_points-1)) + if step_size <= 0: + log.warning( + f"{self} step_size is non-positive. Could not update fly scan" + " parameters." + ) + return + slew_speed = step_size / dwell_time + motor_accel = slew_speed / accel_time + taxi_dist = slew_speed**2 / (2 * motor_accel) * 1.5 + step_size / 2 + taxi_start = start_position - (direction * taxi_dist) + taxi_end = end_position + (direction * taxi_dist) + # Tranforms from pulse positions to pixel centers + pixel_positions = np.linspace(start_position, end_position, num=num_points) + # Set all the calculated variables + [ + status.wait() + for status in [ + self.slew_speed.set(slew_speed), + self.taxi_start.set(taxi_start), + self.taxi_end.set(taxi_end), + ] + ] + self.pixel_positions = pixel_positions diff --git a/src/haven/tests/test_motor.py b/src/haven/tests/test_motor.py index 436105a8..bdff1441 100644 --- a/src/haven/tests/test_motor.py +++ b/src/haven/tests/test_motor.py @@ -135,13 +135,14 @@ def test_fly_params_reverse(motor): def test_kickoff(motor): - motor.flyer_dwell_time.set(1.0).wait(timeout=3) + motor.flyer_dwell_time.put(1.0) + motor.taxi_start.put(1.5) # Start flying status = motor.kickoff() # Check status behavior matches flyer interface assert isinstance(status, StatusBase) # Make sure the motor moved to its taxi position - assert motor.user_setpoint.get() == motor.taxi_start + assert motor.user_setpoint.get() == motor.taxi_start.get() def test_complete(motor): diff --git a/src/haven/tests/test_motor_flyer.py b/src/haven/tests/test_motor_flyer.py new file mode 100644 index 00000000..9bd9e968 --- /dev/null +++ b/src/haven/tests/test_motor_flyer.py @@ -0,0 +1,171 @@ +import pytest +from collections import OrderedDict + +import numpy as np +from ophyd import EpicsMotor +from ophyd.sim import instantiate_fake_device +from ophyd.flyers import FlyerInterface +from ophyd.status import StatusBase + +from haven.instrument.motor_flyer import MotorFlyer + + +@pytest.fixture() +def motor(sim_registry, mocker): + Motor = type("Motor", (MotorFlyer, EpicsMotor), {}) + m = instantiate_fake_device(Motor, name="m1") + mocker.patch.object(m, "move") + m.user_setpoint._use_limits = False + return m + + +def test_motor_flyer(motor): + """Check that the haven motor implements the flyer interface.""" + assert motor is not None + assert isinstance(motor, FlyerInterface) + + +def test_fly_params_forward(motor): + """Test that the fly-scan parameters are correct when going from + lower to higher positions. + + """ + # Set some example positions + motor.motor_egu.set("micron").wait(timeout=3) + motor.acceleration.set(0.5).wait(timeout=3) # sec + motor.start_position.set(10.).wait(timeout=3) # µm + motor.end_position.set(20.).wait(timeout=3) # µm + motor.flyer_num_points.set(101).wait(timeout=3) # µm + motor.flyer_dwell_time.set(1).wait(timeout=3) # sec + + # Check that the fly-scan parameters were calculated correctly + assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec + assert motor.taxi_start.get(use_monitor=False) == pytest.approx(9.9125) # µm + assert motor.taxi_end.get(use_monitor=False) == pytest.approx(20.0875) # µm + i = 10. + pixel = [] + while i <= 20.005: + pixel.append(i) + i = i + 0.1 + np.testing.assert_allclose(motor.pixel_positions, pixel) + + +def test_fly_params_reverse(motor): + """Test that the fly-scan parameters are correct when going from + higher to lower positions. + + """ + # Set some example positions + motor.motor_egu.set("micron").wait(timeout=3) + motor.acceleration.set(0.5).wait(timeout=3) # sec + motor.start_position.set(20.0).wait(timeout=3) # µm + motor.end_position.set(10.0).wait(timeout=3) # µm + motor.flyer_num_points.set(101).wait(timeout=3) # µm + motor.flyer_dwell_time.set(1).wait(timeout=3) # sec + + # Check that the fly-scan parameters were calculated correctly + assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec + assert motor.taxi_start.get(use_monitor=False) == pytest.approx(20.0875) # µm + assert motor.taxi_end.get(use_monitor=False) == pytest.approx(9.9125) # µm + i = 20.0 + pixel = [] + while i >= 9.995: + pixel.append(i) + i = i - 0.1 + np.testing.assert_allclose(motor.pixel_positions, pixel) + + +def test_kickoff(motor): + motor.flyer_dwell_time.put(1.0) + motor.taxi_start.put(1.5) + # Start flying + status = motor.kickoff() + # Check status behavior matches flyer interface + assert isinstance(status, StatusBase) + status.wait(timeout=1) + # Make sure the motor moved to its taxi position + motor.move.assert_called_once_with(1.5, wait=True) + + +def test_complete(motor): + # Set up fake flyer with mocked fly method + assert motor.user_setpoint.get() == 0 + motor.taxi_end.put(10) + # Complete flying + status = motor.complete() + # Check that the motor was moved + assert isinstance(status, StatusBase) + status.wait() + motor.move.assert_called_once_with(10, wait=True) + + +def test_collect(motor): + # Set up needed parameters + motor.pixel_positions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + # Set up some fake positions from camonitors + motor._fly_data = [ + # timestamp, position + (1.125, 0.5), + (2.125, 1.5), + (3.125, 2.5), + (4.125, 3.5), + (5.125, 4.5), + (6.125, 5.5), + (7.125, 6.5), + (8.125, 7.5), + (9.125, 8.5), + (10.125, 9.5), + (11.125, 10.5), + ] + expected_timestamps = [ + 1.625, + 2.625, + 3.625, + 4.625, + 5.625, + 6.625, + 7.625, + 8.625, + 9.625, + 10.625, + ] + payload = list(motor.collect()) + # Confirm data have the right structure + for datum, value, timestamp in zip( + payload, motor.pixel_positions, expected_timestamps + ): + assert datum['data'] == { + "m1": value, + "m1_user_setpoint": value, + } + assert datum["timestamps"]['m1'] == pytest.approx(timestamp, abs=0.3) + assert datum["time"] == pytest.approx(timestamp, abs=0.3) + + +def test_describe_collect(aerotech_flyer): + expected = { + "positions": OrderedDict( + [ + ( + "aerotech_horiz", + { + "source": "SIM:aerotech_horiz", + "dtype": "integer", + "shape": [], + "precision": 3, + }, + ), + ( + "aerotech_horiz_user_setpoint", + { + "source": "SIM:aerotech_horiz_user_setpoint", + "dtype": "integer", + "shape": [], + "precision": 3, + }, + ), + ] + ) + } + + assert aerotech_flyer.describe_collect() == expected From f7a23950bbf5886bf276c792f47615c9d4dfd903 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sun, 14 Jul 2024 21:34:18 -0500 Subject: [PATCH 12/26] Removed flyer tests from motor tests module. --- src/haven/tests/test_fly_plans.py | 24 +++-- src/haven/tests/test_motor.py | 148 +----------------------------- 2 files changed, 19 insertions(+), 153 deletions(-) diff --git a/src/haven/tests/test_fly_plans.py b/src/haven/tests/test_fly_plans.py index f07aa88a..5e464751 100644 --- a/src/haven/tests/test_fly_plans.py +++ b/src/haven/tests/test_fly_plans.py @@ -1,20 +1,28 @@ +import pytest from collections import OrderedDict from unittest.mock import MagicMock import numpy as np -from ophyd import sim +from ophyd import sim, EpicsMotor from haven.plans.fly import FlyerCollector, fly_scan, grid_fly_scan +from haven.instrument.motor_flyer import MotorFlyer -def test_set_fly_params(aerotech_flyer): +@pytest.fixture() +def flyer(sim_registry, mocker): + Motor = type("Motor", (MotorFlyer, EpicsMotor), {}) + m = sim.instantiate_fake_device(Motor, name="m1") + mocker.patch.object(m, "move") + m.user_setpoint._use_limits = False + return m + + +def test_set_fly_params(flyer): """Does the plan set the parameters of the flyer motor.""" - flyer = aerotech_flyer # step size == 10 plan = fly_scan(detectors=[], flyer=flyer, start=-20, stop=30, num=6) messages = list(plan) - for msg in messages: - print(msg.command) open_msg = messages[1] param_msgs = messages[2:8] fly_msgs = messages[9:-1] @@ -25,8 +33,10 @@ def test_set_fly_params(aerotech_flyer): assert param_msgs[3].command == "wait" assert param_msgs[4].command == "set" # Make sure the step size is calculated properly - new_step_size = param_msgs[4].args[0] - assert new_step_size == 10 + msg = param_msgs[4] + assert msg.obj is flyer.flyer_num_points + new_step_size = msg.args[0] + assert new_step_size == 6 def test_fly_scan_metadata(aerotech_flyer, sim_ion_chamber): diff --git a/src/haven/tests/test_motor.py b/src/haven/tests/test_motor.py index bdff1441..e3ebe41c 100644 --- a/src/haven/tests/test_motor.py +++ b/src/haven/tests/test_motor.py @@ -7,6 +7,7 @@ import numpy as np from haven.instrument.motor import HavenMotor, load_motors +from haven.instrument.motor_flyer import MotorFlyer @pytest.fixture() @@ -80,154 +81,9 @@ def test_motor_signals(): def test_motor_flyer(motor): """Check that the haven motor implements the flyer interface.""" assert motor is not None - assert isinstance(motor, FlyerInterface) + assert isinstance(motor, MotorFlyer) -def test_fly_params_forward(motor): - """Test that the fly-scan parameters are correct when going from - lower to higher positions. - - """ - # Set some example positions - motor.motor_egu.set("micron").wait(timeout=3) - motor.acceleration.set(0.5).wait(timeout=3) # sec - motor.start_position.set(10.).wait(timeout=3) # µm - motor.end_position.set(20.).wait(timeout=3) # µm - motor.encoder_resolution.set(0.001).wait(timeout=3) # µm - motor.flyer_num_points.set(101).wait(timeout=3) # µm - motor.flyer_dwell_time.set(1).wait(timeout=3) # sec - - # Check that the fly-scan parameters were calculated correctly - assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec - assert motor.taxi_start.get(use_monitor=False) == pytest.approx(9.9125) # µm - assert motor.taxi_end.get(use_monitor=False) == pytest.approx(20.0875) # µm - i = 10. - pixel = [] - while i <= 20.005: - pixel.append(i) - i = i + 0.1 - np.testing.assert_allclose(motor.pixel_positions, pixel) - - -def test_fly_params_reverse(motor): - """Test that the fly-scan parameters are correct when going from - higher to lower positions. - - """ - # Set some example positions - motor.motor_egu.set("micron").wait(timeout=3) - motor.acceleration.set(0.5).wait(timeout=3) # sec - motor.start_position.set(20.0).wait(timeout=3) # µm - motor.end_position.set(10.0).wait(timeout=3) # µm - motor.flyer_num_points.set(101).wait(timeout=3) # µm - motor.flyer_dwell_time.set(1).wait(timeout=3) # sec - - # Check that the fly-scan parameters were calculated correctly - assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec - assert motor.taxi_start.get(use_monitor=False) == pytest.approx(20.0875) # µm - assert motor.taxi_end.get(use_monitor=False) == pytest.approx(9.9125) # µm - i = 20.0 - pixel = [] - while i >= 9.995: - pixel.append(i) - i = i - 0.1 - np.testing.assert_allclose(motor.pixel_positions, pixel) - - -def test_kickoff(motor): - motor.flyer_dwell_time.put(1.0) - motor.taxi_start.put(1.5) - # Start flying - status = motor.kickoff() - # Check status behavior matches flyer interface - assert isinstance(status, StatusBase) - # Make sure the motor moved to its taxi position - assert motor.user_setpoint.get() == motor.taxi_start.get() - - -def test_complete(motor): - # Set up fake flyer with mocked fly method - assert motor.user_setpoint.get() == 0 - motor.taxi_end.set(10).wait(timeout=3) - # Complete flying - status = motor.complete() - # Check that the motor was moved - assert isinstance(status, StatusBase) - assert motor.user_setpoint.get() == 10 - - -def test_collect(motor): - # Set up needed parameters - motor.pixel_positions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - # Set up some fake positions from camonitors - motor._fly_data = [ - # timestamp, position - (1.125, 0.5), - (2.125, 1.5), - (3.125, 2.5), - (4.125, 3.5), - (5.125, 4.5), - (6.125, 5.5), - (7.125, 6.5), - (8.125, 7.5), - (9.125, 8.5), - (10.125, 9.5), - (11.125, 10.5), - ] - expected_timestamps = [ - 1.625, - 2.625, - 3.625, - 4.625, - 5.625, - 6.625, - 7.625, - 8.625, - 9.625, - 10.625, - ] - payload = list(motor.collect()) - # Confirm data have the right structure - for datum, value, timestamp in zip( - payload, motor.pixel_positions, expected_timestamps - ): - assert datum['data'] == { - "m1": value, - "m1_user_setpoint": value, - } - assert datum["timestamps"]['m1'] == pytest.approx(timestamp, abs=0.3) - assert datum["time"] == pytest.approx(timestamp, abs=0.3) - - -def test_describe_collect(aerotech_flyer): - expected = { - "positions": OrderedDict( - [ - ( - "aerotech_horiz", - { - "source": "SIM:aerotech_horiz", - "dtype": "integer", - "shape": [], - "precision": 3, - }, - ), - ( - "aerotech_horiz_user_setpoint", - { - "source": "SIM:aerotech_horiz_user_setpoint", - "dtype": "integer", - "shape": [], - "precision": 3, - }, - ), - ] - ) - } - - assert aerotech_flyer.describe_collect() == expected - - # ----------------------------------------------------------------------------- # :author: Mark Wolfman # :email: wolfman@anl.gov From 9b3d9a787d5a953ba24c8e63b83c7d7d79dfa1a1 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sun, 14 Jul 2024 23:06:01 -0500 Subject: [PATCH 13/26] Fixed the aerotech flyer support so it matches the regular motor flyer support. --- src/haven/instrument/aerotech.py | 43 ++++--- src/haven/instrument/motor.py | 190 ---------------------------- src/haven/instrument/motor_flyer.py | 34 ++--- src/haven/plans/fly.py | 4 +- src/haven/tests/test_aerotech.py | 164 ++++++++++++------------ src/haven/tests/test_fly_plans.py | 4 +- src/haven/tests/test_motor_flyer.py | 24 ++-- 7 files changed, 141 insertions(+), 322 deletions(-) diff --git a/src/haven/instrument/aerotech.py b/src/haven/instrument/aerotech.py index e667b6c2..02e55c29 100644 --- a/src/haven/instrument/aerotech.py +++ b/src/haven/instrument/aerotech.py @@ -208,8 +208,8 @@ def collect(self) -> Generator[Dict, None, None]: endtime = self.endtime # grab necessary for calculation accel_time = self.acceleration.get() - dwell_time = self.dwell_time.get() - step_size = self.step_size.get() + dwell_time = self.flyer_dwell_time.get() + step_size = self.flyer_step_size() slew_speed = step_size / dwell_time motor_accel = slew_speed / accel_time # Calculate the time it takes for taxi to reach first pixel @@ -240,9 +240,9 @@ def describe_collect(self): def fly(self): # Start the trajectory - destination = self.taxi_end.get() + destination = self.flyer_taxi_end.get() log.debug(f"Flying to {destination}.") - flight_status = self.move(destination, wait=True) + self.move(destination, wait=True) # Wait for the landing self.disable_pso() self.flying_complete.set(True).wait() @@ -259,11 +259,11 @@ def taxi(self): self.enable_pso() self.arm_pso() # Move the motor to the taxi position - taxi_start = self.taxi_start.get() + taxi_start = self.flyer_taxi_start.get() log.debug(f"Taxiing to {taxi_start}.") self.move(taxi_start, wait=True) # Set the speed on the motor - self.velocity.set(self.slew_speed.get()).wait() + self.velocity.set(self.flyer_slew_speed.get()).wait() # Set timing on the delay for triggering detectors, etc self.parent.delay.channel_C.delay.put(0) self.parent.delay.output_CD.polarity.put(self.parent.delay.polarities.NEGATIVE) @@ -311,6 +311,14 @@ def motor_egu_pint(self): egu = ureg(self.motor_egu.get()) return egu + def flyer_step_size(self): + """Calculate the size of each step in a fly scan.""" + start_position = self.flyer_start_position.get() + end_position = self.flyer_end_position.get() + num_points = self.flyer_num_points.get() + step_size = abs(start_position - end_position) / (num_points - 1) + return step_size + def _update_fly_params(self, *args, **kwargs): """Calculate new fly-scan parameters based on signal values. @@ -361,11 +369,10 @@ def _update_fly_params(self, *args, **kwargs): """ window_buffer = 5 # Grab any neccessary signals for calculation - egu = self.motor_egu.get() - start_position = self.start_position.get() - end_position = self.end_position.get() - dwell_time = self.dwell_time.get() - step_size = self.step_size.get() + start_position = self.flyer_start_position.get() + end_position = self.flyer_end_position.get() + dwell_time = self.flyer_dwell_time.get() + step_size = self.flyer_step_size() encoder_resolution = self.encoder_resolution.get() accel_time = self.acceleration.get() # Check for sane values @@ -391,7 +398,7 @@ def _update_fly_params(self, *args, **kwargs): direction = 1 if start_position < end_position else -1 overall_sense = direction * self.encoder_direction # Calculate the step size in encoder steps - encoder_step_size = int(step_size / encoder_resolution) + encoder_step_size = round(step_size / encoder_resolution) # PSO start/end should be located to where req. start/end are # in between steps. Also doubles as the location where slew # speed must be met. @@ -445,9 +452,9 @@ def is_valid_window(value): self.encoder_step_size.set(encoder_step_size), self.pso_start.set(pso_start), self.pso_end.set(pso_end), - self.slew_speed.set(slew_speed), - self.taxi_start.set(taxi_start), - self.taxi_end.set(taxi_end), + self.flyer_slew_speed.set(slew_speed), + self.flyer_taxi_start.set(taxi_start), + self.flyer_taxi_end.set(taxi_end), self.encoder_window_start.set(encoder_window_start), self.encoder_window_end.set(encoder_window_end), self.encoder_use_window.set(encoder_use_window), @@ -480,14 +487,14 @@ def check_flyscan_bounds(self): This checks to make sure no spurious pulses are expected from taxiing. """ - end_points = [(self.taxi_start, self.pso_start), (self.taxi_end, self.pso_end)] - step_size = self.step_size.get() + end_points = [(self.flyer_taxi_start, self.pso_start), (self.flyer_taxi_end, self.pso_end)] + step_size = self.flyer_step_size() for taxi, pso in end_points: # Make sure we're not going to have extra pulses taxi_distance = abs(taxi.get() - pso.get()) if taxi_distance > (1.1 * step_size): raise InvalidScanParameters( - f"Scan parameters for {taxi}, {pso}, {self.step_size} would produce" + f"Scan parameters for {taxi}, {pso}, {self.flyer_step_size} would produce" " extra pulses without a window." ) diff --git a/src/haven/instrument/motor.py b/src/haven/instrument/motor.py index 395f663a..fd4b4e80 100644 --- a/src/haven/instrument/motor.py +++ b/src/haven/instrument/motor.py @@ -39,29 +39,8 @@ class HavenMotor(MotorFlyer, EpicsMotor): motor_stop = Cpt(EpicsSignal, ".STOP", kind="omitted", tolerance=2) soft_limit_violation = Cpt(EpicsSignalRO, ".LVIO", kind="omitted") - # Desired fly parameters - start_position = Cpt(Signal, name="start_position", value=0, kind=Kind.config) - end_position = Cpt(Signal, name="end_position", value=1, kind=Kind.config) - # step_size = Cpt(Signal, name="step_size", value=1, kind=Kind.config) - flyer_num_points = Cpt(Signal, value=2, kind=Kind.config) - flyer_dwell_time = Cpt(Signal, value=1, kind=Kind.config) - - # Calculated fly parameters - slew_speed = Cpt(Signal, value=1, kind=Kind.config) - taxi_start = Cpt(Signal, kind=Kind.config) - taxi_end = Cpt(Signal, kind=Kind.config) - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._kickoff_thread = None - self.cl = get_cl() - # Set up auto-calculations for the flyer - self.motor_egu.subscribe(self._update_fly_params) - self.start_position.subscribe(self._update_fly_params) - self.end_position.subscribe(self._update_fly_params) - self.flyer_num_points.subscribe(self._update_fly_params) - self.flyer_dwell_time.subscribe(self._update_fly_params) - self.acceleration.subscribe(self._update_fly_params) def stage(self): super().stage() @@ -69,175 +48,6 @@ def stage(self): self._original_vals.setdefault(self.user_setpoint, self.user_readback.get()) self._original_vals.setdefault(self.velocity, self.velocity.get()) - def kickoff(self): - """Start the motor as a flyer. - - The status object return is marked as done once flying - is ready. - - Returns - ------- - kickoff_status : StatusBase - Indicate when flying is ready. - - """ - self.log.debug(f"Kicking off {self}") - - def kickoff_thread(): - try: - print(self.taxi_start.get()) - self.move(self.taxi_start.get(), wait=True) - self.velocity.set(self.slew_speed.get()).wait() - except Exception as exc: - st.set_exception(exc) - else: - self.log.debug(f"{self} kickoff succeeded") - st.set_finished() - finally: - # keep a local reference to avoid any GC shenanigans - th = self._set_thread - # these two must be in this order to avoid a race condition - self._set_thread = None - del th - - if self._kickoff_thread is not None: - raise RuntimeError( - "Another set() call is still in progress " f"for {self.name}" - ) - - st = Status(self) - self._status = st - self._kickoff_thread = self.cl.thread_class(target=kickoff_thread) - self._kickoff_thread.daemon = True - self._kickoff_thread.start() - return self._status - - def complete(self): - """Start the motor flying and wait for it to complete. - - Returns - ------- - complete_status : StatusBase - Indicate when flying has completed - - """ - # Record real motor positions for later evaluation - self._fly_data = [] - cid = self.user_readback.subscribe(self.record_datum, run=False) - st = self.move(self.taxi_end.get(), wait=True) - self.user_readback.unsubscribe(cid) - return st - - def record_datum(self, *, old_value, value, timestamp, **kwargs): - """Record a fly-scan data point so we can report it later.""" - self._fly_data.append((timestamp, value)) - - def collect(self) -> Generator[Dict, None, None]: - """Retrieve data from the flyer as proto-events - - Yields - ------ - event_data : dict - Must have the keys {'time', 'timestamps', 'data'}. - - """ - times, positions = np.asarray(self._fly_data).transpose() - model = CubicSpline(positions, times, bc_type="clamped") - # Create the data objects - for position in self.pixel_positions: - timestamp = float(model(position)) - yield { - "time": timestamp, - "timestamps": { - self.user_readback.name: timestamp, - self.user_setpoint.name: timestamp, - }, - "data": { - self.user_readback.name: position, - self.user_setpoint.name: position, - }, - } - - def describe_collect(self): - """Describe details for the collect() method""" - desc = OrderedDict() - desc.update(self.describe()) - return {self.user_readback.name: desc} - - def _update_fly_params(self, *args, **kwargs): - """Calculate new fly-scan parameters based on signal values. - - Computes several parameters describing the fly scan motion. - These include the actual start position of the motor, the - actual distance between points, and the end position of the - motor. - - Several fields are set in the class: - - direction - 1 if we are moving positive in user coordinates, −1 if - negative - taxi_start - The starting point for motor movement during flying, accounts - for needed acceleration of the motor. - taxi_end - The target point for motor movement during flying, accounts - for needed acceleration of the motor. - pixel_positions - array of places where pixels are, should occur calculated from - encoder counts then translated to motor positions - - """ - # Grab any neccessary signals for calculation - egu = self.motor_egu.get() - start_position = self.start_position.get() - end_position = self.end_position.get() - dwell_time = self.flyer_dwell_time.get() - num_points = self.flyer_num_points.get() - accel_time = self.acceleration.get() - # Check for sane values - if dwell_time == 0: - log.warning( - f"{self} dwell_time is zero. Could not update fly scan parameters." - ) - return - if accel_time <= 0: - log.warning( - f"{self} acceleration is non-positive. Could not update fly scan" - " parameters." - ) - return - # Determine the desired direction of travel: - # +1 when moving in + encoder direction, -1 if else - direction = 1 if start_position < end_position else -1 - # Determine taxi distance to accelerate to req speed, v^2/(2*a) = d - # x1.5 for safety margin - step_size = abs((start_position - end_position) / (num_points-1)) - if step_size <= 0: - log.warning( - f"{self} step_size is non-positive. Could not update fly scan" - " parameters." - ) - return - slew_speed = step_size / dwell_time - motor_accel = slew_speed / accel_time - taxi_dist = slew_speed**2 / (2 * motor_accel) * 1.5 + step_size / 2 - taxi_start = start_position - (direction * taxi_dist) - taxi_end = end_position + (direction * taxi_dist) - # Tranforms from pulse positions to pixel centers - pixel_positions = np.linspace(start_position, end_position, num=num_points) - # Set all the calculated variables - [ - status.wait() - for status in [ - self.slew_speed.set(slew_speed), - self.taxi_start.set(taxi_start), - self.taxi_end.set(taxi_end), - ] - ] - self.pixel_positions = pixel_positions - - def load_motors( config: Mapping = None, registry: InstrumentRegistry = default_registry diff --git a/src/haven/instrument/motor_flyer.py b/src/haven/instrument/motor_flyer.py index 5b973e2e..519db38f 100644 --- a/src/haven/instrument/motor_flyer.py +++ b/src/haven/instrument/motor_flyer.py @@ -15,16 +15,16 @@ class MotorFlyer(FlyerInterface, Device): # Desired fly parameters - start_position = Cpt(Signal, name="start_position", value=0, kind=Kind.config) - end_position = Cpt(Signal, name="end_position", value=1, kind=Kind.config) + flyer_start_position = Cpt(Signal, name="start_position", value=0, kind=Kind.config) + flyer_end_position = Cpt(Signal, name="end_position", value=1, kind=Kind.config) # step_size = Cpt(Signal, name="step_size", value=1, kind=Kind.config) flyer_num_points = Cpt(Signal, value=2, kind=Kind.config) flyer_dwell_time = Cpt(Signal, value=1, kind=Kind.config) # Calculated fly parameters - slew_speed = Cpt(Signal, value=1, kind=Kind.config) - taxi_start = Cpt(Signal, kind=Kind.config) - taxi_end = Cpt(Signal, kind=Kind.config) + flyer_slew_speed = Cpt(Signal, value=1, kind=Kind.config) + flyer_taxi_start = Cpt(Signal, kind=Kind.config) + flyer_taxi_end = Cpt(Signal, kind=Kind.config) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -33,11 +33,11 @@ def __init__(self, *args, **kwargs): self.cl = get_cl() # Set up auto-calculations for the flyer self.motor_egu.subscribe(self._update_fly_params) - self.start_position.subscribe(self._update_fly_params) - self.end_position.subscribe(self._update_fly_params) + self.acceleration.subscribe(self._update_fly_params) + self.flyer_start_position.subscribe(self._update_fly_params) + self.flyer_end_position.subscribe(self._update_fly_params) self.flyer_num_points.subscribe(self._update_fly_params) self.flyer_dwell_time.subscribe(self._update_fly_params) - self.acceleration.subscribe(self._update_fly_params) def kickoff(self): """Start the motor as a flyer. @@ -55,8 +55,8 @@ def kickoff(self): def kickoff_thread(): try: - self.move(self.taxi_start.get(), wait=True) - self.velocity.put(self.slew_speed.get()) + self.move(self.flyer_taxi_start.get(), wait=True) + self.velocity.put(self.flyer_slew_speed.get()) except Exception as exc: st.set_exception(exc) else: @@ -64,7 +64,7 @@ def kickoff_thread(): st.set_finished() finally: # keep a local reference to avoid any GC shenanigans - th = self._set_thread + th = self._kickoff_thread # these two must be in this order to avoid a race condition self._kickoff_thread = None del th @@ -97,7 +97,7 @@ def complete_thread(): # Record real motor positions for later evaluation self._fly_data = [] cid = self.user_readback.subscribe(self.record_datum, run=False) - self.move(self.taxi_end.get(), wait=True) + self.move(self.flyer_taxi_end.get(), wait=True) self.user_readback.unsubscribe(cid) except Exception as exc: st.set_exception(exc) @@ -184,8 +184,8 @@ def _update_fly_params(self, *args, **kwargs): """ # Grab any neccessary signals for calculation - start_position = self.start_position.get() - end_position = self.end_position.get() + start_position = self.flyer_start_position.get() + end_position = self.flyer_end_position.get() dwell_time = self.flyer_dwell_time.get() num_points = self.flyer_num_points.get() accel_time = self.acceleration.get() @@ -224,9 +224,9 @@ def _update_fly_params(self, *args, **kwargs): [ status.wait() for status in [ - self.slew_speed.set(slew_speed), - self.taxi_start.set(taxi_start), - self.taxi_end.set(taxi_end), + self.flyer_slew_speed.set(slew_speed), + self.flyer_taxi_start.set(taxi_start), + self.flyer_taxi_end.set(taxi_end), ] ] self.pixel_positions = pixel_positions diff --git a/src/haven/plans/fly.py b/src/haven/plans/fly.py index 45ec19a6..fb9c2f61 100644 --- a/src/haven/plans/fly.py +++ b/src/haven/plans/fly.py @@ -38,8 +38,8 @@ def fly_line_scan(detectors: list, flyer, start, stop, num, extra_signals=(), co """ # Calculate parameters for the fly-scan # step_size = abs(start - stop) / (num - 1) - yield from bps.mv(flyer.start_position, start) - yield from bps.mv(flyer.end_position, stop) + yield from bps.mv(flyer.flyer_start_position, start) + yield from bps.mv(flyer.flyer_end_position, stop) yield from bps.mv(flyer.flyer_num_points, num) # Perform the fly scan flyers = [flyer, *detectors] diff --git a/src/haven/tests/test_aerotech.py b/src/haven/tests/test_aerotech.py index 607ab14a..45f7473f 100644 --- a/src/haven/tests/test_aerotech.py +++ b/src/haven/tests/test_aerotech.py @@ -44,26 +44,26 @@ def test_aerotech_stage(sim_registry): def test_aerotech_fly_params_forward(aerotech_flyer): flyer = aerotech_flyer # Set some example positions - flyer.motor_egu.set("micron").wait() - flyer.acceleration.set(0.5).wait() # sec - flyer.encoder_resolution.set(0.001).wait() # µm - flyer.start_position.set(10.05).wait() # µm - flyer.end_position.set(19.95).wait() # µm - flyer.step_size.set(0.1).wait() # µm - flyer.dwell_time.set(1).wait() # sec + flyer.motor_egu.put("micron") + flyer.acceleration.put(0.5) # sec + flyer.encoder_resolution.put(0.001) # µm + flyer.flyer_start_position.put(10.0) # µm + flyer.flyer_end_position.put(20.0) # µm + flyer.flyer_num_points.put(101) # µm + flyer.flyer_dwell_time.put(1) # sec # Check that the fly-scan parameters were calculated correctly - assert flyer.pso_start.get(use_monitor=False) == 10.0 - assert flyer.pso_end.get(use_monitor=False) == 20.0 - assert flyer.slew_speed.get(use_monitor=False) == 0.1 # µm/sec - assert flyer.taxi_start.get(use_monitor=False) == 9.9 # µm - assert flyer.taxi_end.get(use_monitor=False) == 20.0375 # µm + assert flyer.pso_start.get(use_monitor=False) == 9.95 + assert flyer.pso_end.get(use_monitor=False) == 20.05 + assert flyer.flyer_slew_speed.get(use_monitor=False) == 0.1 # µm/sec + assert flyer.flyer_taxi_start.get(use_monitor=False) == 9.85 # µm + assert flyer.flyer_taxi_end.get(use_monitor=False) == pytest.approx(20.0875) # µm assert flyer.encoder_step_size.get(use_monitor=False) == 100 assert flyer.encoder_window_start.get(use_monitor=False) == -5 - assert flyer.encoder_window_end.get(use_monitor=False) == 10005 - i = 10.05 + assert flyer.encoder_window_end.get(use_monitor=False) == 10105 + i = 10.0 pixel = [] - while i <= 19.98: + while i <= 20.03: pixel.append(i) i = i + 0.1 np.testing.assert_allclose(flyer.pixel_positions, pixel) @@ -72,42 +72,42 @@ def test_aerotech_fly_params_forward(aerotech_flyer): def test_aerotech_fly_params_reverse(aerotech_flyer): flyer = aerotech_flyer # Set some example positions - flyer.motor_egu.set("micron").wait() - flyer.acceleration.set(0.5).wait() # sec - flyer.encoder_resolution.set(0.001).wait() # µm - flyer.start_position.set(19.95).wait() # µm - flyer.end_position.set(10.05).wait() # µm - flyer.step_size.set(0.1).wait() # µm - flyer.dwell_time.set(1).wait() # sec + flyer.motor_egu.put("micron") + flyer.acceleration.put(0.5) # sec + flyer.encoder_resolution.put(0.001) # µm + flyer.flyer_start_position.put(20.0) # µm + flyer.flyer_end_position.put(10.0) # µm + flyer.flyer_num_points.put(101) # µm + flyer.flyer_dwell_time.put(1) # sec # Check that the fly-scan parameters were calculated correctly - assert flyer.pso_start.get(use_monitor=False) == 20.0 - assert flyer.pso_end.get(use_monitor=False) == 10.0 - assert flyer.slew_speed.get(use_monitor=False) == 0.1 # µm/sec - assert flyer.taxi_start.get(use_monitor=False) == 20.1 # µm - assert flyer.taxi_end.get(use_monitor=False) == 9.9625 # µm + assert flyer.pso_start.get(use_monitor=False) == 20.05 + assert flyer.pso_end.get(use_monitor=False) == 9.95 + assert flyer.flyer_slew_speed.get(use_monitor=False) == 0.1 # µm/sec + assert flyer.flyer_taxi_start.get(use_monitor=False) == pytest.approx(20.15) # µm + assert flyer.flyer_taxi_end.get(use_monitor=False) == 9.9125 # µm assert flyer.encoder_step_size.get(use_monitor=False) == 100 assert flyer.encoder_window_start.get(use_monitor=False) == 5 - assert flyer.encoder_window_end.get(use_monitor=False) == -10005 + assert flyer.encoder_window_end.get(use_monitor=False) == -10105 def test_aerotech_fly_params_no_window(aerotech_flyer): """Test the fly scan params when the range is too large for the PSO window.""" flyer = aerotech_flyer # Set some example positions - flyer.motor_egu.set("micron").wait() - flyer.acceleration.set(0.5).wait() # sec - flyer.encoder_resolution.set(0.001).wait() # µm - flyer.start_position.set(0).wait() # µm - flyer.end_position.set(9000).wait() # µm - flyer.step_size.set(0.1).wait() # µm - flyer.dwell_time.set(1).wait() # sec + flyer.motor_egu.put("micron") + flyer.acceleration.put(0.5) # sec + flyer.encoder_resolution.put(0.001) # µm + flyer.flyer_start_position.put(0) # µm + flyer.flyer_end_position.put(9000) # µm + flyer.flyer_num_points.put(90001) # µm + flyer.flyer_dwell_time.put(1) # sec # Check that the fly-scan parameters were calculated correctly assert flyer.pso_start.get(use_monitor=False) == -0.05 assert flyer.pso_end.get(use_monitor=False) == 9000.05 - assert flyer.taxi_start.get(use_monitor=False) == pytest.approx(-0.15) # µm - assert flyer.taxi_end.get(use_monitor=False) == 9000.0875 # µm + assert flyer.flyer_taxi_start.get(use_monitor=False) == pytest.approx(-0.15) # µm + assert flyer.flyer_taxi_end.get(use_monitor=False) == 9000.0875 # µm assert flyer.encoder_step_size.get(use_monitor=False) == 100 assert flyer.encoder_window_start.get(use_monitor=False) == -5 assert flyer.encoder_window_end.get(use_monitor=False) == 9000105 @@ -118,13 +118,13 @@ def test_aerotech_predicted_positions(aerotech_flyer): """Check that the fly-scan positions are calculated properly.""" flyer = aerotech_flyer # Set some example positions - flyer.motor_egu.set("micron").wait() - flyer.acceleration.set(0.5).wait() # sec - flyer.encoder_resolution.set(0.001).wait() # µm - flyer.start_position.set(10.05).wait() # µm - flyer.end_position.set(19.95).wait() # µm - flyer.step_size.set(0.1).wait() # µm - flyer.dwell_time.set(1).wait() # sec + flyer.motor_egu.put("micron") + flyer.acceleration.put(0.5) # sec + flyer.encoder_resolution.put(0.001) # µm + flyer.flyer_start_position.put(10.05) # µm + flyer.flyer_end_position.put(19.95) # µm + flyer.flyer_num_points.put(100) # µm + flyer.flyer_dwell_time.put(1) # sec # Check that the fly-scan parameters were calculated correctly i = 10.05 @@ -143,10 +143,10 @@ def test_aerotech_predicted_positions(aerotech_flyer): def test_enable_pso(aerotech_flyer): flyer = aerotech_flyer # Set up scan parameters - flyer.encoder_step_size.set(50).wait() # In encoder counts - flyer.encoder_window_start.set(-5).wait() # In encoder counts - flyer.encoder_window_end.set(10000).wait() # In encoder counts - flyer.encoder_use_window.set(True).wait() + flyer.encoder_step_size.put(50) # In encoder counts + flyer.encoder_window_start.put(-5) # In encoder counts + flyer.encoder_window_end.put(10000) # In encoder counts + flyer.encoder_use_window.put(True) # Check that commands are sent to set up the controller for flying flyer.enable_pso() assert flyer.send_command.called @@ -166,9 +166,9 @@ def test_enable_pso(aerotech_flyer): def test_enable_pso_no_window(aerotech_flyer): flyer = aerotech_flyer # Set up scan parameters - flyer.encoder_step_size.set(50).wait() # In encoder counts - flyer.encoder_window_start.set(-5).wait() # In encoder counts - flyer.encoder_window_end.set(None).wait() # High end is outside the window range + flyer.encoder_step_size.put(50) # In encoder counts + flyer.encoder_window_start.put(-5) # In encoder counts + flyer.encoder_window_end.put(None) # High end is outside the window range # Check that commands are sent to set up the controller for flying flyer.enable_pso() assert flyer.send_command.called @@ -191,14 +191,14 @@ def test_pso_bad_window_forward(aerotech_flyer): I.e. when the taxi distance is larger than the encoder step size.""" flyer = aerotech_flyer # Set up scan parameters - flyer.encoder_resolution.set(1).wait() - flyer.encoder_step_size.set( + flyer.encoder_resolution.put(1) + flyer.encoder_step_size.put( 5 / flyer.encoder_resolution.get() - ).wait() # In encoder counts - flyer.encoder_window_start.set(-5).wait() # In encoder counts - flyer.encoder_window_end.set(None).wait() # High end is outside the window range - flyer.pso_end.set(100) - flyer.taxi_end.set(110) + ) # In encoder counts + flyer.encoder_window_start.put(-5) # In encoder counts + flyer.encoder_window_end.put(None) # High end is outside the window range + flyer.pso_end.put(100) + flyer.flyer_taxi_end.put(110) # Check that commands are sent to set up the controller for flying with pytest.raises(exceptions.InvalidScanParameters): flyer.enable_pso() @@ -210,15 +210,16 @@ def test_pso_bad_window_reverse(aerotech_flyer): I.e. when the taxi distance is larger than the encoder step size.""" flyer = aerotech_flyer # Set up scan parameters - flyer.encoder_resolution.set(1).wait() - flyer.step_size.set(5).wait() - flyer.encoder_step_size.set( - flyer.step_size.get() / flyer.encoder_resolution.get() - ).wait() # In encoder counts - flyer.encoder_window_start.set(114).wait() # In encoder counts - flyer.encoder_window_start.set(None).wait() # High end is outside the window range - flyer.pso_start.set(100) - flyer.taxi_start.set(94) + flyer.encoder_resolution.put(1) + flyer.flyer_end_position.put(5) + flyer.flyer_num_points.put(2) + flyer.encoder_step_size.put( + flyer.flyer_step_size() / flyer.encoder_resolution.get() + ) # In encoder counts + flyer.encoder_window_start.put(114) # In encoder counts + flyer.encoder_window_start.put(None) # High end is outside the window range + flyer.pso_start.put(100) + flyer.flyer_taxi_start.put(94) # Check that commands are sent to set up the controller for flying with pytest.raises(exceptions.InvalidScanParameters): flyer.enable_pso() @@ -236,7 +237,7 @@ def test_arm_pso(aerotech_flyer): def test_motor_units(aerotech_flyer): """Check that the motor and flyer handle enginering units properly.""" flyer = aerotech_flyer - flyer.motor_egu.set("micron").wait() + flyer.motor_egu.put("micron") unit = flyer.motor_egu_pint assert unit == ureg("1e-6 m") @@ -245,14 +246,14 @@ def test_kickoff(aerotech_flyer): # Set up fake flyer with mocked fly method flyer = aerotech_flyer flyer.taxi = mock.MagicMock() - flyer.dwell_time.set(1.0) + flyer.flyer_dwell_time.put(1.0) # Start flying status = flyer.kickoff() # Check status behavior matches flyer interface assert isinstance(status, StatusBase) assert not status.done # Start flying and see if the status is done - flyer.ready_to_fly.set(True).wait() + flyer.ready_to_fly.put(True) status.wait() assert status.done assert type(flyer.starttime) == float @@ -263,14 +264,14 @@ def test_complete(aerotech_flyer): flyer = aerotech_flyer flyer.move = mock.MagicMock() assert flyer.user_setpoint.get() == 0 - flyer.taxi_end.set(10).wait() + flyer.flyer_taxi_end.put(10) # Complete flying status = flyer.complete() # Check that the motor was moved assert flyer.move.called_with(9) # Check status behavior matches flyer interface assert isinstance(status, StatusBase) - status.wait() + status.wait(timeout=1) assert status.done @@ -280,9 +281,10 @@ def test_collect(aerotech_flyer): flyer.pixel_positions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] flyer.starttime = 0 flyer.endtime = flyer.starttime + 11.25 - motor_accel = flyer.acceleration.set(0.5).wait() # µm/s^2 - flyer.step_size.set(0.1).wait() # µm - flyer.dwell_time.set(1).wait() # sec + flyer.acceleration.put(0.5) # µm/s^2 + flyer.flyer_end_position.put(0.1) + flyer.flyer_num_points.put(2) # µm + flyer.flyer_dwell_time.put(1) # sec expected_timestamps = [ 1.125, 2.125, @@ -345,15 +347,15 @@ def test_describe_collect(aerotech_flyer): def test_fly_motor_positions(aerotech_flyer): flyer = aerotech_flyer # Arbitrary rest position - flyer.user_setpoint.set(255).wait() + flyer.user_setpoint.put(255) flyer.parent.delay.channel_C.delay.sim_put(1.5) flyer.parent.delay.output_CD.polarity.sim_put(1) # Set example fly scan parameters - flyer.taxi_start.set(5).wait() - flyer.start_position.set(10).wait() - flyer.pso_start.set(9.5).wait() - flyer.taxi_end.set(105).wait() - flyer.encoder_use_window.set(True).wait() + flyer.flyer_taxi_start.put(5) + flyer.flyer_start_position.put(10) + flyer.pso_start.put(9.5) + flyer.flyer_taxi_end.put(105) + flyer.encoder_use_window.put(True) # Mock the motor position so that it returns a status we control motor_status = StatusBase() motor_status.set_finished() diff --git a/src/haven/tests/test_fly_plans.py b/src/haven/tests/test_fly_plans.py index 5e464751..d8be0ae5 100644 --- a/src/haven/tests/test_fly_plans.py +++ b/src/haven/tests/test_fly_plans.py @@ -275,12 +275,12 @@ def test_fly_grid_scan(aerotech_flyer): flyer_start_positions = [ msg.args[0] for msg in messages - if (msg.command == "set" and msg.obj.name == f"{flyer.name}_start_position") + if (msg.command == "set" and msg.obj.name == f"{flyer.name}_flyer_start_position") ] flyer_end_positions = [ msg.args[0] for msg in messages - if (msg.command == "set" and msg.obj.name == f"{flyer.name}_end_position") + if (msg.command == "set" and msg.obj.name == f"{flyer.name}_flyer_end_position") ] assert stepper_positions == list(np.linspace(-100, 100, num=11)) assert flyer_start_positions == [-20, 30, -20, 30, -20, 30, -20, 30, -20, 30, -20] diff --git a/src/haven/tests/test_motor_flyer.py b/src/haven/tests/test_motor_flyer.py index 9bd9e968..c2c2b4f3 100644 --- a/src/haven/tests/test_motor_flyer.py +++ b/src/haven/tests/test_motor_flyer.py @@ -33,15 +33,15 @@ def test_fly_params_forward(motor): # Set some example positions motor.motor_egu.set("micron").wait(timeout=3) motor.acceleration.set(0.5).wait(timeout=3) # sec - motor.start_position.set(10.).wait(timeout=3) # µm - motor.end_position.set(20.).wait(timeout=3) # µm + motor.flyer_start_position.set(10.).wait(timeout=3) # µm + motor.flyer_end_position.set(20.).wait(timeout=3) # µm motor.flyer_num_points.set(101).wait(timeout=3) # µm motor.flyer_dwell_time.set(1).wait(timeout=3) # sec # Check that the fly-scan parameters were calculated correctly - assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec - assert motor.taxi_start.get(use_monitor=False) == pytest.approx(9.9125) # µm - assert motor.taxi_end.get(use_monitor=False) == pytest.approx(20.0875) # µm + assert motor.flyer_slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec + assert motor.flyer_taxi_start.get(use_monitor=False) == pytest.approx(9.9125) # µm + assert motor.flyer_taxi_end.get(use_monitor=False) == pytest.approx(20.0875) # µm i = 10. pixel = [] while i <= 20.005: @@ -58,15 +58,15 @@ def test_fly_params_reverse(motor): # Set some example positions motor.motor_egu.set("micron").wait(timeout=3) motor.acceleration.set(0.5).wait(timeout=3) # sec - motor.start_position.set(20.0).wait(timeout=3) # µm - motor.end_position.set(10.0).wait(timeout=3) # µm + motor.flyer_start_position.set(20.0).wait(timeout=3) # µm + motor.flyer_end_position.set(10.0).wait(timeout=3) # µm motor.flyer_num_points.set(101).wait(timeout=3) # µm motor.flyer_dwell_time.set(1).wait(timeout=3) # sec # Check that the fly-scan parameters were calculated correctly - assert motor.slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec - assert motor.taxi_start.get(use_monitor=False) == pytest.approx(20.0875) # µm - assert motor.taxi_end.get(use_monitor=False) == pytest.approx(9.9125) # µm + assert motor.flyer_slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec + assert motor.flyer_taxi_start.get(use_monitor=False) == pytest.approx(20.0875) # µm + assert motor.flyer_taxi_end.get(use_monitor=False) == pytest.approx(9.9125) # µm i = 20.0 pixel = [] while i >= 9.995: @@ -77,7 +77,7 @@ def test_fly_params_reverse(motor): def test_kickoff(motor): motor.flyer_dwell_time.put(1.0) - motor.taxi_start.put(1.5) + motor.flyer_taxi_start.put(1.5) # Start flying status = motor.kickoff() # Check status behavior matches flyer interface @@ -90,7 +90,7 @@ def test_kickoff(motor): def test_complete(motor): # Set up fake flyer with mocked fly method assert motor.user_setpoint.get() == 0 - motor.taxi_end.put(10) + motor.flyer_taxi_end.put(10) # Complete flying status = motor.complete() # Check that the motor was moved From bb64593da073ccf9bf7b792a02e3b31244ee5057 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sun, 14 Jul 2024 23:15:09 -0500 Subject: [PATCH 14/26] Black, isort, and flake8. --- src/haven/catalog.py | 4 +++- src/haven/instrument/aerotech.py | 10 +++++---- src/haven/instrument/aps.py | 3 ++- src/haven/instrument/area_detector.py | 32 ++++++++++++++++----------- src/haven/instrument/camera.py | 13 +++++++---- src/haven/instrument/motor.py | 10 +++------ src/haven/instrument/motor_flyer.py | 13 +++++------ src/haven/plans/fly.py | 4 +++- src/haven/run_engine.py | 1 - src/haven/tests/test_aerotech.py | 4 +--- src/haven/tests/test_area_detector.py | 10 ++++++--- src/haven/tests/test_camera.py | 4 +++- src/haven/tests/test_fly_plans.py | 11 +++++---- src/haven/tests/test_motor.py | 9 +------- src/haven/tests/test_motor_flyer.py | 14 ++++++------ 15 files changed, 77 insertions(+), 65 deletions(-) diff --git a/src/haven/catalog.py b/src/haven/catalog.py index 6e5af2f9..bccfa7c4 100644 --- a/src/haven/catalog.py +++ b/src/haven/catalog.py @@ -169,7 +169,9 @@ def write_safe(self): delete = with_thread_lock(Cache.delete) -def tiled_client(entry_node=None, uri=None, cache_filepath=None, structure_clients="dask"): +def tiled_client( + entry_node=None, uri=None, cache_filepath=None, structure_clients="dask" +): config = load_config() # Create a cache for saving local copies if cache_filepath is None: diff --git a/src/haven/instrument/aerotech.py b/src/haven/instrument/aerotech.py index 02e55c29..d095a77b 100644 --- a/src/haven/instrument/aerotech.py +++ b/src/haven/instrument/aerotech.py @@ -9,17 +9,16 @@ import pint from apstools.synApps.asyn import AsynRecord from ophyd import Component as Cpt -from ophyd import EpicsMotor, EpicsSignal from ophyd import FormattedComponent as FCpt -from ophyd import Kind, Signal, flyers +from ophyd import Kind, Signal from ophyd.status import SubscriptionStatus from .._iconfig import load_config from ..exceptions import InvalidScanParameters from .delay import DG645Delay from .device import make_device -from .stage import XYStage from .motor import HavenMotor +from .stage import XYStage log = logging.getLogger(__name__) @@ -487,7 +486,10 @@ def check_flyscan_bounds(self): This checks to make sure no spurious pulses are expected from taxiing. """ - end_points = [(self.flyer_taxi_start, self.pso_start), (self.flyer_taxi_end, self.pso_end)] + end_points = [ + (self.flyer_taxi_start, self.pso_start), + (self.flyer_taxi_end, self.pso_end), + ] step_size = self.flyer_step_size() for taxi, pso in end_points: # Make sure we're not going to have extra pulses diff --git a/src/haven/instrument/aps.py b/src/haven/instrument/aps.py index 2d180bd9..ca35a4a2 100644 --- a/src/haven/instrument/aps.py +++ b/src/haven/instrument/aps.py @@ -1,7 +1,8 @@ import logging from apstools.devices.aps_machine import ApsMachineParametersDevice -from ophyd import Component as Cpt, EpicsSignalRO +from ophyd import Component as Cpt +from ophyd import EpicsSignalRO from .._iconfig import load_config from .device import make_device diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 833d1ccf..dab5564a 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -1,28 +1,31 @@ import logging import time -from enum import IntEnum from collections import OrderedDict, namedtuple +from enum import IntEnum from typing import Dict +import warnings -import pandas as pd import numpy as np +import pandas as pd from apstools.devices import CamMixin_V34, SingleTrigger_V34 from ophyd import ADComponent as ADCpt from ophyd import Component as Cpt from ophyd import DetectorBase as OphydDetectorBase from ophyd import ( + Device, EigerDetectorCam, Kind, Lambda750kCam, OphydObject, + Signal, SimDetectorCam, SingleTrigger, - Signal, - Device, ) -from ophyd.status import StatusBase, SubscriptionStatus from ophyd.areadetector.base import EpicsSignalWithRBV as SignalWithRBV -from ophyd.areadetector.filestore_mixins import FileStoreHDF5IterativeWrite, FileStoreTIFFIterativeWrite +from ophyd.areadetector.filestore_mixins import ( + FileStoreHDF5IterativeWrite, + FileStoreTIFFIterativeWrite, +) from ophyd.areadetector.plugins import ( HDF5Plugin_V31, HDF5Plugin_V34, @@ -33,12 +36,12 @@ PvaPlugin_V34, ROIPlugin_V31, ROIPlugin_V34, - TIFFPlugin_V31, - TIFFPlugin_V34, ) from ophyd.areadetector.plugins import StatsPlugin_V31 as OphydStatsPlugin_V31 from ophyd.areadetector.plugins import StatsPlugin_V34 as OphydStatsPlugin_V34 +from ophyd.areadetector.plugins import TIFFPlugin_V31, TIFFPlugin_V34 from ophyd.flyers import FlyerInterface +from ophyd.status import StatusBase, SubscriptionStatus from .. import exceptions from .._iconfig import load_config @@ -71,6 +74,7 @@ class EraseState(IntEnum): DONE = 0 ERASE = 1 + class AcquireState(IntEnum): DONE = 0 ACQUIRE = 1 @@ -100,7 +104,7 @@ class DetectorState(IntEnum): ABORTED = 10 -fly_event = namedtuple("fly_event", ('timestamp', 'value')) +fly_event = namedtuple("fly_event", ("timestamp", "value")) class FlyerMixin(FlyerInterface, Device): @@ -304,7 +308,10 @@ class DynamicFileStore(Device): iconfig values. """ - def __init__(self, *args, write_path_template="/{root_path}/%Y/%m/{name}/", **kwargs): + + def __init__( + self, *args, write_path_template="/{root_path}/%Y/%m/{name}/", **kwargs + ): super().__init__(*args, write_path_template=write_path_template, **kwargs) # Format the file_write_template with per-device values config = load_config() @@ -330,8 +337,7 @@ def stage(self): super().stage() -class TIFFFilePlugin(DynamicFileStore, FileStoreTIFFIterativeWrite, TIFFPlugin_V34): - ... +class TIFFFilePlugin(DynamicFileStore, FileStoreTIFFIterativeWrite, TIFFPlugin_V34): ... class DetectorBase(FlyerMixin, OphydDetectorBase): @@ -343,7 +349,7 @@ def __init__(self, *args, description=None, **kwargs): @property def default_time_signal(self): - return self.cam.acquire_time + return self.cam.acquire_time class StatsMixin: diff --git a/src/haven/instrument/camera.py b/src/haven/instrument/camera.py index a7d569f1..a57ce538 100644 --- a/src/haven/instrument/camera.py +++ b/src/haven/instrument/camera.py @@ -4,12 +4,10 @@ from ophyd import ADComponent as ADCpt from ophyd import CamBase, EpicsSignal, Kind from ophyd.areadetector.plugins import ( - HDF5Plugin_V34, ImagePlugin_V34, OverlayPlugin_V34, PvaPlugin_V34, ROIPlugin_V34, - TIFFPlugin_V34, ) from .. import exceptions @@ -17,10 +15,10 @@ from .area_detector import ( # noqa: F401 AsyncCamMixin, DetectorBase, + HDF5FilePlugin, SimDetector, SingleImageModeTrigger, StatsPlugin_V34, - HDF5FilePlugin, TIFFFilePlugin, ) from .device import make_device @@ -41,7 +39,14 @@ class AravisDetector(SingleImageModeTrigger, DetectorBase): A gige-vision camera described by EPICS. """ - _default_configuration_attrs = ("cam", "hdf", "stats1", "stats2", "stats3", "stats4") + _default_configuration_attrs = ( + "cam", + "hdf", + "stats1", + "stats2", + "stats3", + "stats4", + ) _default_read_attrs = ("cam", "hdf", "stats1", "stats2", "stats3", "stats4") cam = ADCpt(AravisCam, "cam1:") diff --git a/src/haven/instrument/motor.py b/src/haven/instrument/motor.py index fd4b4e80..b205043c 100644 --- a/src/haven/instrument/motor.py +++ b/src/haven/instrument/motor.py @@ -1,16 +1,11 @@ import asyncio import logging import warnings -from collections import OrderedDict -from typing import Mapping, Sequence, Generator, Dict +from typing import Mapping, Sequence -from scipy.interpolate import CubicSpline -import numpy as np from apstools.utils.misc import safe_ophyd_name from ophyd import Component as Cpt -from ophyd import EpicsMotor, EpicsSignal, EpicsSignalRO, Signal, Kind, get_cl -from ophyd.flyers import FlyerInterface -from ophyd.status import Status +from ophyd import EpicsMotor, EpicsSignal, EpicsSignalRO, Kind from .._iconfig import load_config from .device import make_device, resolve_device_names @@ -30,6 +25,7 @@ class HavenMotor(MotorFlyer, EpicsMotor): Returns to the previous value when being unstaged. """ + # Extra motor record components encoder_resolution = Cpt(EpicsSignal, ".ERES", kind=Kind.config) description = Cpt(EpicsSignal, ".DESC", kind="omitted") diff --git a/src/haven/instrument/motor_flyer.py b/src/haven/instrument/motor_flyer.py index 519db38f..da6827d1 100644 --- a/src/haven/instrument/motor_flyer.py +++ b/src/haven/instrument/motor_flyer.py @@ -1,14 +1,13 @@ -from collections import OrderedDict import logging -from typing import Generator, Dict +from collections import OrderedDict +from typing import Dict, Generator -from scipy.interpolate import CubicSpline import numpy as np - -from ophyd import Component as Cpt, Kind, Signal, get_cl, Device +from ophyd import Component as Cpt +from ophyd import Device, Kind, Signal, get_cl from ophyd.flyers import FlyerInterface from ophyd.status import Status - +from scipy.interpolate import CubicSpline log = logging.getLogger() @@ -206,7 +205,7 @@ def _update_fly_params(self, *args, **kwargs): direction = 1 if start_position < end_position else -1 # Determine taxi distance to accelerate to req speed, v^2/(2*a) = d # x1.5 for safety margin - step_size = abs((start_position - end_position) / (num_points-1)) + step_size = abs((start_position - end_position) / (num_points - 1)) if step_size <= 0: log.warning( f"{self} step_size is non-positive. Could not update fly scan" diff --git a/src/haven/plans/fly.py b/src/haven/plans/fly.py index fb9c2f61..5e6f1bb7 100644 --- a/src/haven/plans/fly.py +++ b/src/haven/plans/fly.py @@ -13,7 +13,9 @@ __all__ = ["fly_scan", "grid_fly_scan"] -def fly_line_scan(detectors: list, flyer, start, stop, num, extra_signals=(), combine_streams=True): +def fly_line_scan( + detectors: list, flyer, start, stop, num, extra_signals=(), combine_streams=True +): """A plan stub for fly-scanning a single trajectory. Parameters diff --git a/src/haven/run_engine.py b/src/haven/run_engine.py index e56483af..06befa17 100644 --- a/src/haven/run_engine.py +++ b/src/haven/run_engine.py @@ -2,7 +2,6 @@ import databroker from bluesky import RunEngine as BlueskyRunEngine -from bluesky import suspenders from bluesky.callbacks.best_effort import BestEffortCallback from .exceptions import ComponentNotFound diff --git a/src/haven/tests/test_aerotech.py b/src/haven/tests/test_aerotech.py index 45f7473f..e34e304b 100644 --- a/src/haven/tests/test_aerotech.py +++ b/src/haven/tests/test_aerotech.py @@ -192,9 +192,7 @@ def test_pso_bad_window_forward(aerotech_flyer): flyer = aerotech_flyer # Set up scan parameters flyer.encoder_resolution.put(1) - flyer.encoder_step_size.put( - 5 / flyer.encoder_resolution.get() - ) # In encoder counts + flyer.encoder_step_size.put(5 / flyer.encoder_resolution.get()) # In encoder counts flyer.encoder_window_start.put(-5) # In encoder counts flyer.encoder_window_end.put(None) # High end is outside the window range flyer.pso_end.put(100) diff --git a/src/haven/tests/test_area_detector.py b/src/haven/tests/test_area_detector.py index a98cc827..c0d4cbcb 100644 --- a/src/haven/tests/test_area_detector.py +++ b/src/haven/tests/test_area_detector.py @@ -1,12 +1,16 @@ import time -import pytest import numpy as np -from ophyd.sim import instantiate_fake_device +import pytest from ophyd import ADComponent as ADCpt from ophyd.areadetector.cam import AreaDetectorCam +from ophyd.sim import instantiate_fake_device -from haven.instrument.area_detector import load_area_detectors, DetectorBase, DetectorState +from haven.instrument.area_detector import ( + DetectorBase, + DetectorState, + load_area_detectors, +) class Detector(DetectorBase): diff --git a/src/haven/tests/test_camera.py b/src/haven/tests/test_camera.py index e683a252..b9ef4a25 100644 --- a/src/haven/tests/test_camera.py +++ b/src/haven/tests/test_camera.py @@ -18,7 +18,9 @@ def test_load_cameras(sim_registry): @pytest.fixture() def camera(sim_registry): - camera = instantiate_fake_device(AravisDetector, prefix="255idgigeA:", name="camera") + camera = instantiate_fake_device( + AravisDetector, prefix="255idgigeA:", name="camera" + ) return camera diff --git a/src/haven/tests/test_fly_plans.py b/src/haven/tests/test_fly_plans.py index d8be0ae5..3128a77d 100644 --- a/src/haven/tests/test_fly_plans.py +++ b/src/haven/tests/test_fly_plans.py @@ -1,12 +1,12 @@ -import pytest from collections import OrderedDict from unittest.mock import MagicMock import numpy as np -from ophyd import sim, EpicsMotor +import pytest +from ophyd import EpicsMotor, sim -from haven.plans.fly import FlyerCollector, fly_scan, grid_fly_scan from haven.instrument.motor_flyer import MotorFlyer +from haven.plans.fly import FlyerCollector, fly_scan, grid_fly_scan @pytest.fixture() @@ -275,7 +275,10 @@ def test_fly_grid_scan(aerotech_flyer): flyer_start_positions = [ msg.args[0] for msg in messages - if (msg.command == "set" and msg.obj.name == f"{flyer.name}_flyer_start_position") + if ( + msg.command == "set" + and msg.obj.name == f"{flyer.name}_flyer_start_position" + ) ] flyer_end_positions = [ msg.args[0] diff --git a/src/haven/tests/test_motor.py b/src/haven/tests/test_motor.py index e3ebe41c..5a9e4ce2 100644 --- a/src/haven/tests/test_motor.py +++ b/src/haven/tests/test_motor.py @@ -1,10 +1,5 @@ -from collections import OrderedDict - import pytest from ophyd.sim import instantiate_fake_device -from ophyd.flyers import FlyerInterface -from ophyd import StatusBase -import numpy as np from haven.instrument.motor import HavenMotor, load_motors from haven.instrument.motor_flyer import MotorFlyer @@ -51,9 +46,7 @@ def test_skip_existing_motors(sim_registry, mocked_device_names): """ # Create an existing fake motor - m1 = HavenMotor( - "255idVME:m1", name="kb_mirrors_horiz_upstream", labels={"motors"} - ) + m1 = HavenMotor("255idVME:m1", name="kb_mirrors_horiz_upstream", labels={"motors"}) # Load the Ophyd motor definitions load_motors() # Were the motors imported correctly diff --git a/src/haven/tests/test_motor_flyer.py b/src/haven/tests/test_motor_flyer.py index c2c2b4f3..469c1798 100644 --- a/src/haven/tests/test_motor_flyer.py +++ b/src/haven/tests/test_motor_flyer.py @@ -1,10 +1,10 @@ -import pytest from collections import OrderedDict import numpy as np +import pytest from ophyd import EpicsMotor -from ophyd.sim import instantiate_fake_device from ophyd.flyers import FlyerInterface +from ophyd.sim import instantiate_fake_device from ophyd.status import StatusBase from haven.instrument.motor_flyer import MotorFlyer @@ -33,8 +33,8 @@ def test_fly_params_forward(motor): # Set some example positions motor.motor_egu.set("micron").wait(timeout=3) motor.acceleration.set(0.5).wait(timeout=3) # sec - motor.flyer_start_position.set(10.).wait(timeout=3) # µm - motor.flyer_end_position.set(20.).wait(timeout=3) # µm + motor.flyer_start_position.set(10.0).wait(timeout=3) # µm + motor.flyer_end_position.set(20.0).wait(timeout=3) # µm motor.flyer_num_points.set(101).wait(timeout=3) # µm motor.flyer_dwell_time.set(1).wait(timeout=3) # sec @@ -42,7 +42,7 @@ def test_fly_params_forward(motor): assert motor.flyer_slew_speed.get(use_monitor=False) == pytest.approx(0.1) # µm/sec assert motor.flyer_taxi_start.get(use_monitor=False) == pytest.approx(9.9125) # µm assert motor.flyer_taxi_end.get(use_monitor=False) == pytest.approx(20.0875) # µm - i = 10. + i = 10.0 pixel = [] while i <= 20.005: pixel.append(i) @@ -134,11 +134,11 @@ def test_collect(motor): for datum, value, timestamp in zip( payload, motor.pixel_positions, expected_timestamps ): - assert datum['data'] == { + assert datum["data"] == { "m1": value, "m1_user_setpoint": value, } - assert datum["timestamps"]['m1'] == pytest.approx(timestamp, abs=0.3) + assert datum["timestamps"]["m1"] == pytest.approx(timestamp, abs=0.3) assert datum["time"] == pytest.approx(timestamp, abs=0.3) From c3051dadd4d566ceea98234cfb0e58eccc906161 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Tue, 16 Jul 2024 13:55:34 -0500 Subject: [PATCH 15/26] The motor flyer just collects its data without interpolation. --- src/haven/instrument/motor_flyer.py | 11 ++++------- src/haven/tests/test_motor_flyer.py | 4 ++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/haven/instrument/motor_flyer.py b/src/haven/instrument/motor_flyer.py index da6827d1..6647c551 100644 --- a/src/haven/instrument/motor_flyer.py +++ b/src/haven/instrument/motor_flyer.py @@ -135,16 +135,13 @@ def collect(self) -> Generator[Dict, None, None]: Must have the keys {'time', 'timestamps', 'data'}. """ - times, positions = np.asarray(self._fly_data).transpose() - model = CubicSpline(positions, times, bc_type="clamped") # Create the data objects - for position in self.pixel_positions: - timestamp = float(model(position)) + for time, position in self._fly_data: yield { - "time": timestamp, + "time": time, "timestamps": { - self.user_readback.name: timestamp, - self.user_setpoint.name: timestamp, + self.user_readback.name: time, + self.user_setpoint.name: time, }, "data": { self.user_readback.name: position, diff --git a/src/haven/tests/test_motor_flyer.py b/src/haven/tests/test_motor_flyer.py index 469c1798..810e64df 100644 --- a/src/haven/tests/test_motor_flyer.py +++ b/src/haven/tests/test_motor_flyer.py @@ -131,8 +131,8 @@ def test_collect(motor): ] payload = list(motor.collect()) # Confirm data have the right structure - for datum, value, timestamp in zip( - payload, motor.pixel_positions, expected_timestamps + for datum, (timestamp, value) in zip( + payload, motor._fly_data ): assert datum["data"] == { "m1": value, From 4349e23f5714eae240afcc9a449a27b22dfa38d6 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Tue, 16 Jul 2024 21:29:27 -0500 Subject: [PATCH 16/26] Added fly-scanning docs for scanning mode and data streams. --- docs/topic_guides/fly_scanning.rst | 88 ++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/docs/topic_guides/fly_scanning.rst b/docs/topic_guides/fly_scanning.rst index b736ab10..bdbc10af 100644 --- a/docs/topic_guides/fly_scanning.rst +++ b/docs/topic_guides/fly_scanning.rst @@ -6,10 +6,10 @@ Fly Scanning :depth: 3 -Fly scanning is when detectors take measuments from a sample while in -motion. Creating a range of measurements based on user specified -points. This method is generally faster than traditional step -scanning. +Fly scanning is when detectors **take measuments while one or more +positioners are in motion**, creating a range of measurements based on +user specified points. This method is generally faster than +traditional step scanning. Flyscanning with Bluesky follows a general three method process @@ -24,6 +24,86 @@ to have the ``kickoff()``, ``complete()``, ``collect()``, and ``collect_describe()`` methods. Any calculation or configuration for fly scanning is done inside the Ophyd device. +Modes of Fly Scanning +===================== + +Fly scanning can be done in two modes: + +1. Free-running -- devices operate independently +2. Triggered -- devices are synchronized at the hardware level + +In **free-running** mode, the positioners and detectors operate +independently from one another. Typically the positioners are set to +cover a range at a given speed, while detectors repeatedly acquire +data. This approach can be applied to many types of devices, but the +points at which the detector is triggered are not predictable. While +the position at each detector reading will be known, the positions +will not be exactly those specified in the plan. This fly-scan mode is +**best suited for scans where measuring specific points is not +critical**, such as for alignment of optical components, +e.g. slits. Grid scans are not supported for *free-running* mode. + +In **triggered** mode, a positioner's hardware will produce a signal +that is used to directly trigger one or more detectors. Both the +positioner and detectors must have compatible triggering mechanisms, +and the physical connections must be made before-hand. *Triggered* +mode is **best suited for scans where the precise position of each +detector reading is critical**, such as for data +acquisition. N-dimensional grid scans can also be performed in +*triggered* mode. + +For devices that support both modes, a *flyer_mode* signal shall be +provided that directs the device to operate in one mode or +another. The *flyer_mode* argument to +:py:func:`haven.plans.fly.fly_scan` will set all flyers to the given +mode if not ``None``. + +Data Streams +============ + +In all cases, each flyable device used in a fly scan will produce its +own data stream with the name of the device. By using the +*combine_streams* parameter to the :py:func:`haven.plans.fly.fly_scan` +or :py:func:`haven.plans.fly.grid_fly_scan` plans, it may be possible +to align the streams into a single "primary" stream based on +time-stamps of the collected data. Positions for positioners will be +interpolated based on timestamps of the detector frames. Not every +combination of flyers is compatible with this strategy: consult the +following table to see if data streams can be combined (✓) or will +cause ambiguous associations (✘). + +.. list-table:: Streams that can be combined in *free-running* mode + :header-rows: 1 + + * - + - 1 detector + - 2+ detectors + * - **1 positioner** + - ✓ + - ✘ + * - **2+ positioners** + - ✓ + - ✘ + +.. list-table:: Streams that can be combined in *triggered* mode + :header-rows: 1 + + * - + - 1 detector + - 2+ detectors + * - **1 positioner** + - ✓ + - ✓ + * - **2+ positioners** + - ✘ + - ✘ + +.. note:: + + The fly-scanning machinery will still produce a "primary" data + stream of those situations marked above as ambiguous (✘), however + there is no guarantee that data are aligned properly. + Plans for Fly-Scanning ====================== From 7f769fd93540b65ad498099fe4376901bdf4fbf9 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Wed, 17 Jul 2024 12:07:09 -0500 Subject: [PATCH 17/26] Fly scan plan now can combine streams for free-running and triggered fly scans. --- src/haven/plans/fly.py | 121 ++++++++++++++++++++---------- src/haven/tests/test_fly_plans.py | 71 +++++++++++++----- 2 files changed, 132 insertions(+), 60 deletions(-) diff --git a/src/haven/plans/fly.py b/src/haven/plans/fly.py index 5e6f1bb7..d3770dad 100644 --- a/src/haven/plans/fly.py +++ b/src/haven/plans/fly.py @@ -13,21 +13,24 @@ __all__ = ["fly_scan", "grid_fly_scan"] -def fly_line_scan( - detectors: list, flyer, start, stop, num, extra_signals=(), combine_streams=True -): +def fly_line_scan(detectors: list, *args, num, extra_signals=(), combine_streams=True): """A plan stub for fly-scanning a single trajectory. Parameters ========== detectors List of 'readable' objects that support the flyer interface - flyer - The thing going to get moved. - start - The center of the first pixel in *flyer*. - stop - The center of the last measurement in *flyer*. + *args + For one dimension, motor, start, stop. In general: + + .. code-block:: python + + motor1, start1, stop1, + motor2, start2, stop2, + ..., + motorN, startN, stopN + + Motors can be any ‘flyable’ object. num Number of measurements to take. combine_streams @@ -40,11 +43,24 @@ def fly_line_scan( """ # Calculate parameters for the fly-scan # step_size = abs(start - stop) / (num - 1) - yield from bps.mv(flyer.flyer_start_position, start) - yield from bps.mv(flyer.flyer_end_position, stop) - yield from bps.mv(flyer.flyer_num_points, num) + motors = args[0::3] + starts = args[1::3] + stops = args[2::3] + mv_args = [] + for motor, start, stop in zip(motors, starts, stops): + mv_args.extend( + [ + motor.flyer_start_position, + start, + motor.flyer_end_position, + stop, + motor.flyer_num_points, + num, + ] + ) + yield from bps.mv(*mv_args) # Perform the fly scan - flyers = [flyer, *detectors] + flyers = [*motors, *detectors] for flyer_ in flyers: yield from bps.kickoff(flyer_, wait=True) for flyer_ in flyers: @@ -53,21 +69,21 @@ def fly_line_scan( if combine_streams: # Collect data together as a single "primary" data stream collector = FlyerCollector( - flyers=flyers, name="flyer_collector", extra_signals=extra_signals + positioners=motors, + detectors=detectors, + name="flyer_collector", + extra_signals=extra_signals, ) yield from bps.collect(collector) - else: - # Collect data into separate data streams - for flyer_ in flyers: - yield from bps.collect(flyer_) + # Collect data into separate data streams + for flyer_ in flyers: + yield from bps.collect(flyer_) # @baseline_decorator() def fly_scan( detectors: Sequence[FlyerInterface], - flyer: FlyerInterface, - start: float, - stop: float, + *args, num: int, md: Mapping = {}, ): @@ -77,12 +93,17 @@ def fly_scan( ---------- detectors List of 'readable' objects that support the flyer interface - flyer - The thing going to get moved. - start - The center of the first pixel in *flyer*. - stop - The center of the last measurement in *flyer*. + *args + For one dimension, motor, start, stop. In general: + + .. code-block:: python + + motor1, start1, stop1, + motor2, start2, stop2, + ..., + motorN, startN, stopN + + Motors can be any ‘flyable’ object. num Number of measurements to take. md @@ -95,23 +116,27 @@ def fly_scan( """ # Stage the devices - devices = [flyer, *detectors] + motors = args[0::3] + starts = args[1::3] + stops = args[2::3] + devices = [*motors, *detectors] + # Prepare metadata representation of the motor arguments + md_args = zip([repr(m) for m in motors], starts, stops) + md_args = tuple(obj for m, start, stop in md_args for obj in [m, start, stop]) # Prepare metadata md_ = { "plan_name": "fly_scan", - "motors": [flyer.name], + "motors": [motor.name for motor in motors], "detectors": [det.name for det in detectors], "plan_args": { "detectors": list(map(repr, detectors)), - "flyer": repr(flyer), - "start": start, - "stop": stop, + "*args": md_args, "num": num, }, } md_.update(md) # Execute the plan - line_scan = fly_line_scan(detectors, flyer, start, stop, num, combine_streams=False) + line_scan = fly_line_scan(detectors, *args, num=num, combine_streams=False) line_scan = bpp.run_wrapper(line_scan, md=md_) line_scan = bpp.stage_wrapper(line_scan, devices) yield from line_scan @@ -266,9 +291,9 @@ def __call__(self, detectors, step, pos_cache): # Launch the fly scan yield from fly_line_scan( detectors, - flyer=self.flyer, - start=start, - stop=stop, + self.flyer, + start, + stop, num=self.num, extra_signals=step.keys(), ) @@ -276,12 +301,21 @@ def __call__(self, detectors, step, pos_cache): class FlyerCollector(FlyerInterface, Device): stream_name: str - flyers: list + detectors: Sequence + positioners: Sequence def __init__( - self, flyers, stream_name: str = "primary", extra_signals=(), *args, **kwargs + self, + detectors, + positioners, + stream_name: str = "primary", + extra_signals=(), + *args, + **kwargs, ): - self.flyers = flyers + # self.flyers = flyers + self.detectors = detectors + self.positioners = positioners self.stream_name = stream_name self.extra_signals = extra_signals super().__init__(*args, **kwargs) @@ -293,7 +327,7 @@ def complete(self): return StatusBase(success=True) def collect(self): - collections = [iter(flyer.collect()) for flyer in self.flyers] + collections = [iter(flyer.collect()) for flyer in self.detectors] while True: event = { "data": {}, @@ -311,6 +345,11 @@ def collect(self): for ts in event["timestamps"].values(): timestamps.extend(np.asarray(ts).flatten()) event["time"] = np.median(timestamps) + # Add interpolated motor positions + for motor in self.positioners: + datum = motor.predict(event["time"]) + event["data"].update(datum["data"]) + event["timestamps"].update(datum["timestamps"]) # Add extra non-flying signals (not inc. in event time) for signal in self.extra_signals: for signal_name, reading in signal.read().items(): @@ -320,7 +359,7 @@ def collect(self): def describe_collect(self): desc = OrderedDict() - for flyer in self.flyers: + for flyer in [*self.positioners, *self.detectors]: for stream, this_desc in flyer.describe_collect().items(): desc.update(this_desc) # Add extra signals, e.g. slow motor during a grid fly scan diff --git a/src/haven/tests/test_fly_plans.py b/src/haven/tests/test_fly_plans.py index 3128a77d..6404b139 100644 --- a/src/haven/tests/test_fly_plans.py +++ b/src/haven/tests/test_fly_plans.py @@ -21,19 +21,20 @@ def flyer(sim_registry, mocker): def test_set_fly_params(flyer): """Does the plan set the parameters of the flyer motor.""" # step size == 10 - plan = fly_scan(detectors=[], flyer=flyer, start=-20, stop=30, num=6) + plan = fly_scan([], flyer, -20, 30, num=6) messages = list(plan) open_msg = messages[1] - param_msgs = messages[2:8] - fly_msgs = messages[9:-1] + param_msgs = messages[2:6] + fly_msgs = messages[6:-1] close_msg = messages[:-1] assert param_msgs[0].command == "set" - assert param_msgs[1].command == "wait" + # assert param_msgs[1].command == "wait" + assert param_msgs[1].command == "set" + # assert param_msgs[3].command == "wait" assert param_msgs[2].command == "set" assert param_msgs[3].command == "wait" - assert param_msgs[4].command == "set" # Make sure the step size is calculated properly - msg = param_msgs[4] + msg = param_msgs[2] assert msg.obj is flyer.flyer_num_points new_step_size = msg.args[0] assert new_step_size == 6 @@ -43,9 +44,7 @@ def test_fly_scan_metadata(aerotech_flyer, sim_ion_chamber): """Does the plan set the parameters of the flyer motor.""" flyer = aerotech_flyer md = {"spam": "eggs"} - plan = fly_scan( - detectors=[sim_ion_chamber], flyer=flyer, start=-20, stop=30, num=6, md=md - ) + plan = fly_scan([sim_ion_chamber], flyer, -20, 30, num=6, md=md) messages = list(plan) open_msg = messages[2] assert open_msg.command == "open_run" @@ -54,9 +53,7 @@ def test_fly_scan_metadata(aerotech_flyer, sim_ion_chamber): "plan_args": { "detectors": list([repr(sim_ion_chamber)]), "num": 6, - "flyer": repr(flyer), - "start": -20, - "stop": 30, + "*args": (repr(flyer), -20, 30), }, "plan_name": "fly_scan", "motors": ["aerotech_horiz"], @@ -143,7 +140,11 @@ def test_collector_describe(): ) flyers = [aerotech, I0] collector = FlyerCollector( - flyers, stream_name="primary", extra_signals=[motor], name="collector" + positioners=[aerotech], + detectors=[I0], + stream_name="primary", + extra_signals=[motor], + name="collector", ) desc = collector.describe_collect() assert "primary" in desc.keys() @@ -162,7 +163,8 @@ def test_collector_describe(): def test_collector_collect(): aerotech = MagicMock() - aerotech.collect.return_value = [ + aerotech.predict.side_effect = [ + # These are the target data, but we need more events to simulate a flying motor { "data": { "aerotech_horiz": -1000.0, @@ -183,6 +185,35 @@ def test_collector_collect(): "time": 1691957266.1137164, }, ] + aerotech.collect.return_value = [ + { + "data": { + "aerotech_horiz": -1100.0, + "aerotech_horiz_user_setpoint": -1100.0, + }, + "timestamps": { + "aerotech_horiz": 1691957265.354138, + "aerotech_horiz_user_setpoint": 1691957265.354138, + }, + "time": 1691957265.354138, + }, + { + "data": {"aerotech_horiz": -900.0, "aerotech_horiz_user_setpoint": -900.0}, + "timestamps": { + "aerotech_horiz": 1691957265.8605237, + "aerotech_horiz_user_setpoint": 1691957265.8605237, + }, + "time": 1691957265.8605237, + }, + { + "data": {"aerotech_horiz": -700.0, "aerotech_horiz_user_setpoint": -700.0}, + "timestamps": { + "aerotech_horiz": 1691957266.366909, + "aerotech_horiz_user_setpoint": 1691957266.366909, + }, + "time": 1691957266.366909, + }, + ] I0 = MagicMock() I0.collect.return_value = [ { @@ -206,7 +237,11 @@ def test_collector_collect(): ) collector = FlyerCollector( - flyers, stream_name="primary", name="flyer_collector", extra_signals=[motor] + detectors=[I0], + positioners=[aerotech], + stream_name="primary", + name="flyer_collector", + extra_signals=[motor], ) events = list(collector.collect()) expected_events = [ @@ -225,7 +260,7 @@ def test_collector_collect(): "motor": 1692072398.879956, "motor_setpoint": 1692072398.8799553, }, - "time": 1691957265.6073308, + "time": 1691957269.1575842, }, { "data": { @@ -242,7 +277,7 @@ def test_collector_collect(): "motor": 1692072398.879956, "motor_setpoint": 1692072398.8799553, }, - "time": 1691957266.1137164, + "time": 1691957269.0734286, }, ] assert len(events) == 2 @@ -257,8 +292,6 @@ def test_fly_grid_scan(aerotech_flyer): [], stepper, -100, 100, 11, flyer, -20, 30, 6, snake_axes=[flyer] ) messages = list(plan) - # for msg in messages: - # print(f"{msg.command:<10}\t{getattr(msg.obj, 'name', 'None'):<20}\t{msg.args}") assert messages[0].command == "stage" assert messages[1].command == "open_run" # Check that we move the stepper first From d9e2d205da3ca88e82429102513435ee5a02fa4d Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Wed, 17 Jul 2024 12:38:54 -0500 Subject: [PATCH 18/26] The motor flyer device now supports the FlyerCollector when running fly scans. --- src/haven/instrument/motor_flyer.py | 41 +++++++++++++++++++++++ src/haven/tests/test_motor_flyer.py | 50 ++++++++++++++++++++++------- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/src/haven/instrument/motor_flyer.py b/src/haven/instrument/motor_flyer.py index 6647c551..d949bf0a 100644 --- a/src/haven/instrument/motor_flyer.py +++ b/src/haven/instrument/motor_flyer.py @@ -29,6 +29,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._kickoff_thread = None self._complete_thread = None + self._fly_model = None self.cl = get_cl() # Set up auto-calculations for the flyer self.motor_egu.subscribe(self._update_fly_params) @@ -95,6 +96,7 @@ def complete_thread(): try: # Record real motor positions for later evaluation self._fly_data = [] + self._fly_model = None cid = self.user_readback.subscribe(self.record_datum, run=False) self.move(self.flyer_taxi_end.get(), wait=True) self.user_readback.unsubscribe(cid) @@ -149,6 +151,45 @@ def collect(self) -> Generator[Dict, None, None]: }, } + def predict(self, timestamp: float) -> Dict: + """Predict where the motor was at *timestamp* during most recent fly + scan. + + Parameters + ========== + timestamp + The unix timestamp to use for interpolating the measured data. + + Returns + ======= + datum + A data event for this timestamp similar to those provided by + the ``collect()`` method. + + """ + # Prepare an interpolation model for fly scan data + if self._fly_model is None: + times, positions = np.asarray(self._fly_data).transpose() + self._fly_model = CubicSpline(times, positions, bc_type="clamped") + model = self._fly_model + # Interpolate the data value based on timestamp + position = float(model(timestamp)) + setpoint = self.pixel_positions[ + np.argmin(np.abs(self.pixel_positions - position)) + ] + datum = { + "time": timestamp, + "timestamps": { + self.user_readback.name: timestamp, + self.user_setpoint.name: timestamp, + }, + "data": { + self.user_readback.name: position, + self.user_setpoint.name: setpoint, + }, + } + return datum + def describe_collect(self): """Describe details for the collect() method""" desc = OrderedDict() diff --git a/src/haven/tests/test_motor_flyer.py b/src/haven/tests/test_motor_flyer.py index 810e64df..5b2ee998 100644 --- a/src/haven/tests/test_motor_flyer.py +++ b/src/haven/tests/test_motor_flyer.py @@ -100,8 +100,6 @@ def test_complete(motor): def test_collect(motor): - # Set up needed parameters - motor.pixel_positions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] # Set up some fake positions from camonitors motor._fly_data = [ # timestamp, position @@ -117,6 +115,36 @@ def test_collect(motor): (10.125, 9.5), (11.125, 10.5), ] + payload = list(motor.collect()) + # Confirm data have the right structure + for datum, (timestamp, value) in zip(payload, motor._fly_data): + assert datum["data"] == { + "m1": value, + "m1_user_setpoint": value, + } + assert datum["timestamps"]["m1"] == pytest.approx(timestamp, abs=0.3) + assert datum["time"] == pytest.approx(timestamp, abs=0.3) + + +def test_predict(motor): + # Set up some fake positions from camonitors + motor._fly_data = [ + # timestamp, position + (1.125, 0.5), + (2.125, 1.5), + (3.125, 2.5), + (4.125, 3.5), + (5.125, 4.5), + (6.125, 5.5), + (7.125, 6.5), + (8.125, 7.5), + (9.125, 8.5), + (10.125, 9.5), + (11.125, 10.5), + ] + # Prepare expected timestamp and position data + expected_positions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + motor.pixel_positions = np.asarray(expected_positions) expected_timestamps = [ 1.625, 2.625, @@ -129,17 +157,15 @@ def test_collect(motor): 9.625, 10.625, ] - payload = list(motor.collect()) + ts = [d[0] for d in motor._fly_data] + vs = [d[1] for d in motor._fly_data] # Confirm data have the right structure - for datum, (timestamp, value) in zip( - payload, motor._fly_data - ): - assert datum["data"] == { - "m1": value, - "m1_user_setpoint": value, - } - assert datum["timestamps"]["m1"] == pytest.approx(timestamp, abs=0.3) - assert datum["time"] == pytest.approx(timestamp, abs=0.3) + for timestamp, expected_value in zip(expected_timestamps, expected_positions): + datum = motor.predict(timestamp) + assert datum["data"]["m1"] == pytest.approx(expected_value, abs=0.2) + assert datum["data"]["m1_user_setpoint"] == expected_value + assert datum["timestamps"]["m1"] == timestamp + assert datum["time"] == timestamp def test_describe_collect(aerotech_flyer): From a253c4c11d40d9568aa18226e7f5bff1ec3296bb Mon Sep 17 00:00:00 2001 From: 25-ID-D user Date: Fri, 19 Jul 2024 12:54:00 -0500 Subject: [PATCH 19/26] Fixed bugs in fly scanning based on beamline commissioning. --- src/haven/instrument/area_detector.py | 11 +++++++---- src/haven/instrument/motor_flyer.py | 12 ++++-------- src/haven/plans/fly.py | 6 +++++- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index dab5564a..4bc38079 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -41,7 +41,7 @@ from ophyd.areadetector.plugins import StatsPlugin_V34 as OphydStatsPlugin_V34 from ophyd.areadetector.plugins import TIFFPlugin_V31, TIFFPlugin_V34 from ophyd.flyers import FlyerInterface -from ophyd.status import StatusBase, SubscriptionStatus +from ophyd.status import StatusBase, SubscriptionStatus, Status from .. import exceptions from .._iconfig import load_config @@ -121,15 +121,14 @@ def kickoff(self) -> StatusBase: self._fly_data = {} for walk in self.walk_fly_signals(): sig = walk.item + # Run subs the first time to make sure all signals are present sig.subscribe(self.save_fly_datum, run=True) - # Set up the status for when the detector is ready to fly def check_acquiring(*, old_value, value, **kwargs): is_acquiring = value == DetectorState.ACQUIRE if is_acquiring: self.start_fly_timestamp = time.time() return is_acquiring - status = SubscriptionStatus(self.cam.detector_state, check_acquiring) # Set the right parameters self._original_vals.setdefault(self.cam.image_mode, self.cam.image_mode.get()) @@ -153,7 +152,8 @@ def complete(self) -> StatusBase: for walk in self.walk_fly_signals(): sig = walk.item sig.clear_sub(self.save_fly_datum) - return self.cam.acquire.set(AcquireState.DONE) + self.cam.acquire.set(AcquireState.DONE) + return Status(done=True, success=True, settle_time=0.5) def collect(self) -> dict: """Generate the data events that were collected during the fly scan.""" @@ -219,6 +219,9 @@ def get_image_num(ts): # change so no new camonitor reply was received data = data.ffill(axis=0) timestamps = timestamps.ffill(axis=1) + # Drop the first frame since it was just the result of all the subs + data.drop(data.index[0], inplace=True) + timestamps.drop(timestamps.index[0], inplace=True) return data, timestamps def walk_fly_signals(self, *, include_lazy=False): diff --git a/src/haven/instrument/motor_flyer.py b/src/haven/instrument/motor_flyer.py index d949bf0a..72837790 100644 --- a/src/haven/instrument/motor_flyer.py +++ b/src/haven/instrument/motor_flyer.py @@ -98,6 +98,7 @@ def complete_thread(): self._fly_data = [] self._fly_model = None cid = self.user_readback.subscribe(self.record_datum, run=False) + print(f"Moving to {self.flyer_taxi_end.get()}") self.move(self.flyer_taxi_end.get(), wait=True) self.user_readback.unsubscribe(cid) except Exception as exc: @@ -258,12 +259,7 @@ def _update_fly_params(self, *args, **kwargs): # Tranforms from pulse positions to pixel centers pixel_positions = np.linspace(start_position, end_position, num=num_points) # Set all the calculated variables - [ - status.wait() - for status in [ - self.flyer_slew_speed.set(slew_speed), - self.flyer_taxi_start.set(taxi_start), - self.flyer_taxi_end.set(taxi_end), - ] - ] + self.flyer_slew_speed.put(slew_speed) + self.flyer_taxi_start.put(taxi_start) + self.flyer_taxi_end.put(taxi_end) self.pixel_positions = pixel_positions diff --git a/src/haven/plans/fly.py b/src/haven/plans/fly.py index d3770dad..4c655284 100644 --- a/src/haven/plans/fly.py +++ b/src/haven/plans/fly.py @@ -85,6 +85,7 @@ def fly_scan( detectors: Sequence[FlyerInterface], *args, num: int, + combine_streams = False, md: Mapping = {}, ): """Do a fly scan with a 'flyer' motor and some 'flyer' detectors. @@ -106,6 +107,9 @@ def fly_scan( Motors can be any ‘flyable’ object. num Number of measurements to take. + combine_streams + If true, the separate data streams will be combined into one + "primary" data stream (experimental). md metadata @@ -136,7 +140,7 @@ def fly_scan( } md_.update(md) # Execute the plan - line_scan = fly_line_scan(detectors, *args, num=num, combine_streams=False) + line_scan = fly_line_scan(detectors, *args, num=num, combine_streams=combine_streams) line_scan = bpp.run_wrapper(line_scan, md=md_) line_scan = bpp.stage_wrapper(line_scan, devices) yield from line_scan From c13ef4c87b8a03dfc8d7b01d18dff8cf56a5919e Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sun, 21 Jul 2024 20:55:07 -0500 Subject: [PATCH 20/26] Added HDF5 file plugin to the xspress detector device. --- src/haven/catalog.py | 2 +- src/haven/instrument/area_detector.py | 2 +- src/haven/instrument/fluorescence_detector.py | 3 ++- src/haven/instrument/xspress.py | 4 +++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/haven/catalog.py b/src/haven/catalog.py index bccfa7c4..b3b28bef 100644 --- a/src/haven/catalog.py +++ b/src/haven/catalog.py @@ -175,7 +175,7 @@ def tiled_client( config = load_config() # Create a cache for saving local copies if cache_filepath is None: - cache_filepath = config["database"]["tiled"].get("cache_filepath", "") + cache_filepath = config["database"].get("tiled", {}).get("cache_filepath", "") cache_filepath = cache_filepath or None cache = ThreadSafeCache(filepath=cache_filepath) # Create the client diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 4bc38079..3bd9229e 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -321,7 +321,7 @@ def __init__( try: self.write_path_template = self.write_path_template.format( name=self.parent.name, - root_path=config["area_detector"].get("root_path", "/tmp"), + root_path=config.get("area_detector", {}).get("root_path", "tmp"), ) except KeyError: warnings.warn(f"Could not format write_path_template {write_path_template}") diff --git a/src/haven/instrument/fluorescence_detector.py b/src/haven/instrument/fluorescence_detector.py index cf84004c..9d4a83d6 100644 --- a/src/haven/instrument/fluorescence_detector.py +++ b/src/haven/instrument/fluorescence_detector.py @@ -154,7 +154,8 @@ def unstage(self): # Restore the original (pre-staged) name if self.name != self._original_name: for walk in self.walk_signals(): - walk.item.name = walk.item.name.replace(self.name, self._original_name) + if self._original_name is not None: + walk.item.name = walk.item.name.replace(self.name, self._original_name) self.name = self._original_name # Restore original signal kinds for fld, kind in self._original_kinds.items(): diff --git a/src/haven/instrument/xspress.py b/src/haven/instrument/xspress.py index 1a53ae18..a2b6f3de 100644 --- a/src/haven/instrument/xspress.py +++ b/src/haven/instrument/xspress.py @@ -10,7 +10,7 @@ from apstools.devices import CamMixin_V34, SingleTrigger_V34 from ophyd import ADComponent as ADCpt from ophyd import Component as Cpt -from ophyd import DetectorBase, Device +from ophyd import Device from ophyd import DynamicDeviceComponent as DDC from ophyd import EpicsSignal, EpicsSignalRO, Kind from ophyd.areadetector.base import EpicsSignalWithRBV as SignalWithRBV @@ -22,6 +22,7 @@ from .._iconfig import load_config from .device import RegexComponent as RECpt from .device import make_device +from .area_detector import HDF5FilePlugin, DetectorBase from .fluorescence_detector import ( MCASumMixin, ROIMixin, @@ -243,6 +244,7 @@ def put_acquire_frames( } cam = ADCpt(CamMixin_V34, "det1:") + hdf = ADCpt(HDF5FilePlugin, "HDF1:", kind=Kind.normal) # Core control interface signals detector_state = ADCpt(EpicsSignalRO, "det1:DetectorState_RBV", kind="omitted") acquire = ADCpt(SignalWithRBV, "det1:Acquire", kind="omitted") From 846469dba375d39c73a713438112e4d246a707a7 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Mon, 22 Jul 2024 15:55:37 -0500 Subject: [PATCH 21/26] Added dtype_str to the HDF5 plugin descriptor. --- src/haven/instrument/area_detector.py | 29 +++++++++++++++++++++++++-- src/haven/tests/test_area_detector.py | 19 ++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 3bd9229e..97db389e 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -1,6 +1,6 @@ import logging import time -from collections import OrderedDict, namedtuple +from collections import OrderedDict, namedtuple, Mapping from enum import IntEnum from typing import Dict import warnings @@ -311,7 +311,6 @@ class DynamicFileStore(Device): iconfig values. """ - def __init__( self, *args, write_path_template="/{root_path}/%Y/%m/{name}/", **kwargs ): @@ -326,6 +325,32 @@ def __init__( except KeyError: warnings.warn(f"Could not format write_path_template {write_path_template}") + def _add_dtype_str(self, desc: Mapping) -> Mapping: + """Add the specific image data type into the metadata. + + This method modifies the dictionary in place. + + Parameters + ========== + desc: + The input description, most likely coming from self.describe(). + + Returns + ======= + desc + The same dictionary, with an added ``dtype_str`` key. + + """ + key = f"{self.parent.name}_image" + if key in desc: + dtype = self.data_type.get(as_string=True) + dtype_str = np.dtype(dtype.lower()).str + desc[key].setdefault("dtype_str", dtype_str) + return desc + + def describe(self): + return self._add_dtype_str(super().describe()) + class HDF5FilePlugin(DynamicFileStore, FileStoreHDF5IterativeWrite, HDF5Plugin_V34): """ diff --git a/src/haven/tests/test_area_detector.py b/src/haven/tests/test_area_detector.py index c0d4cbcb..57325057 100644 --- a/src/haven/tests/test_area_detector.py +++ b/src/haven/tests/test_area_detector.py @@ -1,4 +1,5 @@ import time +from collections import OrderedDict import numpy as np import pytest @@ -9,12 +10,14 @@ from haven.instrument.area_detector import ( DetectorBase, DetectorState, + HDF5FilePlugin, load_area_detectors, ) class Detector(DetectorBase): cam = ADCpt(AreaDetectorCam, "cam1:") + hdf = ADCpt(HDF5FilePlugin, "HDF1:", write_path_template="/tmp/") @pytest.fixture() @@ -80,6 +83,22 @@ def test_load_area_detectors(sim_registry): dets = sim_registry.findall(label="area_detectors") +def test_hdf_dtype(detector): + """Check that the right ``dtype_str`` is added to the image data to + make tiled happy. + """ + # Set up fake image metadata + detector.hdf.data_type.sim_put("UInt8") + original_desc = OrderedDict([('FakeDetector_image', + {'shape': (1, 1024, 1280), + 'source': 'PV:25idcARV4:', + 'dtype': 'array', + 'external': 'FILESTORE:'})]) + # Update and check the description + new_desc = detector.hdf._add_dtype_str(original_desc) + assert new_desc["FakeDetector_image"]['dtype_str'] == "|u1" + + # ----------------------------------------------------------------------------- # :author: Mark Wolfman # :email: wolfman@anl.gov From d34b706840d418959c644f0afec5a991fd08df3d Mon Sep 17 00:00:00 2001 From: yannachen Date: Tue, 23 Jul 2024 12:32:30 -0500 Subject: [PATCH 22/26] Cleaned up AD flyer describe_collect and made stages use HavenMotor subclasses. --- src/haven/instrument/area_detector.py | 6 +++--- src/haven/instrument/stage.py | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 97db389e..11e98c9d 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -173,9 +173,9 @@ def collect(self) -> dict: def describe_collect(self) -> Dict[str, Dict]: """Describe details for the flyer collect() method""" desc = OrderedDict() - for walk in self.walk_fly_signals(): - desc.update(walk.item.describe()) - return {self.name: desc} + # for walk in self.walk_fly_signals(): + # desc.update(walk.item.describe()) + return {self.name: self.describe()} def fly_data(self): """Compile the fly-scan data into a pandas dataframe.""" diff --git a/src/haven/instrument/stage.py b/src/haven/instrument/stage.py index 754d8185..28a3f5c5 100644 --- a/src/haven/instrument/stage.py +++ b/src/haven/instrument/stage.py @@ -5,6 +5,7 @@ from .._iconfig import load_config from .device import make_device +from .motor import HavenMotor __all__ = ["XYStage", "load_stages"] @@ -27,8 +28,8 @@ class XYStage(Device): The suffix to the PV for the horizontal motor. """ - vert = FCpt(EpicsMotor, "{prefix}{pv_vert}", labels={"motors"}) - horiz = FCpt(EpicsMotor, "{prefix}{pv_horiz}", labels={"motors"}) + vert = FCpt(HavenMotor, "{prefix}{pv_vert}", labels={"motors"}) + horiz = FCpt(HavenMotor, "{prefix}{pv_horiz}", labels={"motors"}) def __init__( self, From 80f9309402ac41fde16616cb9641fa7e7ceece26 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Fri, 26 Jul 2024 13:52:54 -0500 Subject: [PATCH 23/26] Fixed a test for xspress detectors. --- src/haven/instrument/area_detector.py | 4 ++-- src/haven/instrument/fluorescence_detector.py | 2 +- src/haven/tests/test_fluorescence_detectors.py | 8 +++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 11e98c9d..057296da 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -1,8 +1,8 @@ import logging import time -from collections import OrderedDict, namedtuple, Mapping +from collections import OrderedDict, namedtuple from enum import IntEnum -from typing import Dict +from typing import Dict, Mapping import warnings import numpy as np diff --git a/src/haven/instrument/fluorescence_detector.py b/src/haven/instrument/fluorescence_detector.py index 9d4a83d6..2735947f 100644 --- a/src/haven/instrument/fluorescence_detector.py +++ b/src/haven/instrument/fluorescence_detector.py @@ -148,7 +148,7 @@ def stage(self): } for fld in self._dynamic_hint_fields: getattr(self, fld).kind = new_kind - super().stage() + return super().stage() def unstage(self): # Restore the original (pre-staged) name diff --git a/src/haven/tests/test_fluorescence_detectors.py b/src/haven/tests/test_fluorescence_detectors.py index 9a00b302..9c5c7e2c 100644 --- a/src/haven/tests/test_fluorescence_detectors.py +++ b/src/haven/tests/test_fluorescence_detectors.py @@ -296,9 +296,10 @@ def test_stage_hints(vortex): # Ensure the hints aren't applied yet assert roi0.count.name not in vortex.hints["fields"] assert roi1.count.name not in vortex.hints["fields"] - # Stage the detector + # Stage the detector ROIs try: - vortex.stage() + roi0.stage() + roi1.stage() except Exception: raise else: @@ -306,7 +307,8 @@ def test_stage_hints(vortex): assert roi0.count.name in vortex.hints["fields"] assert roi1.count.name not in vortex.hints["fields"] finally: - vortex.unstage() + roi0.unstage() + roi1.unstage() # Name gets reset when unstaged assert roi0.count.name not in vortex.hints["fields"] assert roi1.count.name not in vortex.hints["fields"] From eed0855a596ead99271956ff5721e3506581583b Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Fri, 26 Jul 2024 13:54:04 -0500 Subject: [PATCH 24/26] Black, isort, and flake8. --- src/haven/instrument/area_detector.py | 7 +++++-- src/haven/instrument/fluorescence_detector.py | 4 +++- src/haven/instrument/stage.py | 2 +- src/haven/instrument/xspress.py | 2 +- src/haven/plans/fly.py | 6 ++++-- src/haven/tests/test_area_detector.py | 20 +++++++++++++------ 6 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/haven/instrument/area_detector.py b/src/haven/instrument/area_detector.py index 057296da..cb5e88f6 100644 --- a/src/haven/instrument/area_detector.py +++ b/src/haven/instrument/area_detector.py @@ -1,9 +1,9 @@ import logging import time +import warnings from collections import OrderedDict, namedtuple from enum import IntEnum from typing import Dict, Mapping -import warnings import numpy as np import pandas as pd @@ -41,7 +41,7 @@ from ophyd.areadetector.plugins import StatsPlugin_V34 as OphydStatsPlugin_V34 from ophyd.areadetector.plugins import TIFFPlugin_V31, TIFFPlugin_V34 from ophyd.flyers import FlyerInterface -from ophyd.status import StatusBase, SubscriptionStatus, Status +from ophyd.status import Status, StatusBase, SubscriptionStatus from .. import exceptions from .._iconfig import load_config @@ -123,12 +123,14 @@ def kickoff(self) -> StatusBase: sig = walk.item # Run subs the first time to make sure all signals are present sig.subscribe(self.save_fly_datum, run=True) + # Set up the status for when the detector is ready to fly def check_acquiring(*, old_value, value, **kwargs): is_acquiring = value == DetectorState.ACQUIRE if is_acquiring: self.start_fly_timestamp = time.time() return is_acquiring + status = SubscriptionStatus(self.cam.detector_state, check_acquiring) # Set the right parameters self._original_vals.setdefault(self.cam.image_mode, self.cam.image_mode.get()) @@ -311,6 +313,7 @@ class DynamicFileStore(Device): iconfig values. """ + def __init__( self, *args, write_path_template="/{root_path}/%Y/%m/{name}/", **kwargs ): diff --git a/src/haven/instrument/fluorescence_detector.py b/src/haven/instrument/fluorescence_detector.py index 2735947f..08998c72 100644 --- a/src/haven/instrument/fluorescence_detector.py +++ b/src/haven/instrument/fluorescence_detector.py @@ -155,7 +155,9 @@ def unstage(self): if self.name != self._original_name: for walk in self.walk_signals(): if self._original_name is not None: - walk.item.name = walk.item.name.replace(self.name, self._original_name) + walk.item.name = walk.item.name.replace( + self.name, self._original_name + ) self.name = self._original_name # Restore original signal kinds for fld, kind in self._original_kinds.items(): diff --git a/src/haven/instrument/stage.py b/src/haven/instrument/stage.py index 28a3f5c5..53e9d183 100644 --- a/src/haven/instrument/stage.py +++ b/src/haven/instrument/stage.py @@ -1,6 +1,6 @@ import logging -from ophyd import Device, EpicsMotor +from ophyd import Device from ophyd import FormattedComponent as FCpt from .._iconfig import load_config diff --git a/src/haven/instrument/xspress.py b/src/haven/instrument/xspress.py index a2b6f3de..f2a5335e 100644 --- a/src/haven/instrument/xspress.py +++ b/src/haven/instrument/xspress.py @@ -20,9 +20,9 @@ from pcdsdevices.type_hints import OphydDataType, SignalToValue from .._iconfig import load_config +from .area_detector import DetectorBase, HDF5FilePlugin from .device import RegexComponent as RECpt from .device import make_device -from .area_detector import HDF5FilePlugin, DetectorBase from .fluorescence_detector import ( MCASumMixin, ROIMixin, diff --git a/src/haven/plans/fly.py b/src/haven/plans/fly.py index 4c655284..5bc18c39 100644 --- a/src/haven/plans/fly.py +++ b/src/haven/plans/fly.py @@ -85,7 +85,7 @@ def fly_scan( detectors: Sequence[FlyerInterface], *args, num: int, - combine_streams = False, + combine_streams=False, md: Mapping = {}, ): """Do a fly scan with a 'flyer' motor and some 'flyer' detectors. @@ -140,7 +140,9 @@ def fly_scan( } md_.update(md) # Execute the plan - line_scan = fly_line_scan(detectors, *args, num=num, combine_streams=combine_streams) + line_scan = fly_line_scan( + detectors, *args, num=num, combine_streams=combine_streams + ) line_scan = bpp.run_wrapper(line_scan, md=md_) line_scan = bpp.stage_wrapper(line_scan, devices) yield from line_scan diff --git a/src/haven/tests/test_area_detector.py b/src/haven/tests/test_area_detector.py index 57325057..7e18aa42 100644 --- a/src/haven/tests/test_area_detector.py +++ b/src/haven/tests/test_area_detector.py @@ -89,14 +89,22 @@ def test_hdf_dtype(detector): """ # Set up fake image metadata detector.hdf.data_type.sim_put("UInt8") - original_desc = OrderedDict([('FakeDetector_image', - {'shape': (1, 1024, 1280), - 'source': 'PV:25idcARV4:', - 'dtype': 'array', - 'external': 'FILESTORE:'})]) + original_desc = OrderedDict( + [ + ( + "FakeDetector_image", + { + "shape": (1, 1024, 1280), + "source": "PV:25idcARV4:", + "dtype": "array", + "external": "FILESTORE:", + }, + ) + ] + ) # Update and check the description new_desc = detector.hdf._add_dtype_str(original_desc) - assert new_desc["FakeDetector_image"]['dtype_str'] == "|u1" + assert new_desc["FakeDetector_image"]["dtype_str"] == "|u1" # ----------------------------------------------------------------------------- From 82360068c694c1f5c88a9bcd387b4c8174b46e21 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Fri, 26 Jul 2024 14:12:02 -0500 Subject: [PATCH 25/26] Fixed a firefly test to be compatible with apstools==1.6.20. --- src/firefly/tests/test_energy_display.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/firefly/tests/test_energy_display.py b/src/firefly/tests/test_energy_display.py index f38e13a9..362ae7b8 100644 --- a/src/firefly/tests/test_energy_display.py +++ b/src/firefly/tests/test_energy_display.py @@ -1,7 +1,7 @@ from unittest import mock import pytest -from apstools.devices.aps_undulator import ApsUndulator +from apstools.devices.aps_undulator import PlanarUndulator from bluesky_queueserver_api import BPlan from ophyd.sim import make_fake_device from qtpy import QtCore @@ -13,7 +13,7 @@ FakeEnergyPositioner = make_fake_device( haven.instrument.energy_positioner.EnergyPositioner ) -FakeUndulator = make_fake_device(ApsUndulator) +FakeUndulator = make_fake_device(PlanarUndulator) @pytest.fixture() From 869cf4cc27a0f0482b7ba6da3bc3cf3f06939887 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Fri, 26 Jul 2024 14:21:46 -0500 Subject: [PATCH 26/26] Updated a broken firefly test. --- src/firefly/tests/test_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firefly/tests/test_controller.py b/src/firefly/tests/test_controller.py index 9e25a9e3..040ea66b 100644 --- a/src/firefly/tests/test_controller.py +++ b/src/firefly/tests/test_controller.py @@ -56,7 +56,7 @@ def test_queue_actions_enabled(controller, qtbot): assert not actions["start"].isEnabled() assert not actions["pause"].isEnabled() assert not actions["pause_now"].isEnabled() - assert actions["stop_queue"].isEnabled() + assert not actions["stop_queue"].isEnabled() assert actions["stop_runengine"].isEnabled() assert actions["resume"].isEnabled() assert actions["abort"].isEnabled()