Skip to content

Commit

Permalink
Merge pull request #244 from spc-group/glide_scan
Browse files Browse the repository at this point in the history
Ability to fly-scan motors and detectors without hardware connections.
  • Loading branch information
canismarko authored Jul 26, 2024
2 parents a0f74db + 869cf4c commit 1ca2542
Show file tree
Hide file tree
Showing 26 changed files with 1,340 additions and 293 deletions.
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

0 comments on commit 1ca2542

Please sign in to comment.