Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Glide scan #244

Merged
merged 27 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4ba62de
Added fly-scan methods to the HavenMotor class.
canismarko Jul 9, 2024
8b120ad
Fixed the motor fly scanning at the beamline.
yannachen Jul 9, 2024
079a37a
Added fly-scanning support to area detector base (probably broken, co…
canismarko Jul 10, 2024
2426b65
Tested the flyable area detector at the beamline.
yannachen Jul 10, 2024
06efc34
Re-wrote the fly scan plan so it can be compatible with slew scanning.
yannachen Jul 10, 2024
70ffeaa
Camera now uses ophyd FileStore subclasses to set up AD plugins.
canismarko Jul 10, 2024
74eef51
Upgrade the APS device to the latest apstools version.
yannachen Jul 13, 2024
fa9abcf
Dask is optional for tiled client and no longer uses local caching.
yannachen Jul 13, 2024
a1063b6
Cleaned up support for tiff AD file plugin.
yannachen Jul 13, 2024
7b7f0f9
Fixed tests that were broken by testing at the beamline.
canismarko Jul 13, 2024
5aa0ba0
Refactored the motor flyer to use threads.
canismarko Jul 15, 2024
f7a2395
Removed flyer tests from motor tests module.
canismarko Jul 15, 2024
9b3d9a7
Fixed the aerotech flyer support so it matches the regular motor flye…
canismarko Jul 15, 2024
bb64593
Black, isort, and flake8.
canismarko Jul 15, 2024
c3051da
The motor flyer just collects its data without interpolation.
canismarko Jul 16, 2024
4349e23
Added fly-scanning docs for scanning mode and data streams.
canismarko Jul 17, 2024
7f769fd
Fly scan plan now can combine streams for free-running and triggered …
canismarko Jul 17, 2024
d9e2d20
The motor flyer device now supports the FlyerCollector when running f…
canismarko Jul 17, 2024
a253c4c
Fixed bugs in fly scanning based on beamline commissioning.
Jul 19, 2024
c13ef4c
Added HDF5 file plugin to the xspress detector device.
canismarko Jul 22, 2024
846469d
Added dtype_str to the HDF5 plugin descriptor.
canismarko Jul 22, 2024
d34b706
Cleaned up AD flyer describe_collect and made stages use HavenMotor s…
yannachen Jul 23, 2024
80f9309
Fixed a test for xspress detectors.
canismarko Jul 26, 2024
eed0855
Black, isort, and flake8.
canismarko Jul 26, 2024
83caf9c
Merge branch 'main' into glide_scan
canismarko Jul 26, 2024
8236006
Fixed a firefly test to be compatible with apstools==1.6.20.
canismarko Jul 26, 2024
869cf4c
Updated a broken firefly test.
canismarko Jul 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 84 additions & 4 deletions docs/topic_guides/fly_scanning.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
======================

Expand Down
5 changes: 2 additions & 3 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/firefly/tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions src/firefly/tests/test_energy_display.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/haven/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/haven/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,17 +169,19 @@ 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:
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
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]
Expand Down
3 changes: 3 additions & 0 deletions src/haven/iconfig_testing.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
82 changes: 38 additions & 44 deletions src/haven/instrument/aerotech.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@
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 .motor import HavenMotor
from .stage import XYStage

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
Expand Down Expand Up @@ -110,19 +110,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)
Expand All @@ -137,17 +125,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
Expand All @@ -170,6 +153,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()
Expand Down Expand Up @@ -223,8 +207,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
Expand Down Expand Up @@ -255,9 +239,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()
Expand All @@ -274,11 +258,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)
Expand Down Expand Up @@ -326,6 +310,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.

Expand Down Expand Up @@ -376,11 +368,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
Expand All @@ -401,12 +392,12 @@ 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
# 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.
Expand Down Expand Up @@ -460,9 +451,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),
Expand Down Expand Up @@ -495,14 +486,17 @@ 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."
)

Expand Down
Loading