From 39c4bc51b5cfd526006907164ed8a26264924a0f Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Wed, 18 Sep 2024 14:39:03 +0100 Subject: [PATCH 1/2] Add configurable SimpleDae class --- doc/simpledae/controllers.md | 46 ++++++ doc/simpledae/reducers.md | 36 +++++ doc/simpledae/simpledae.md | 51 +++++++ doc/simpledae/waiters.md | 41 +++++ pyproject.toml | 9 +- src/ibex_bluesky_core/demo_plan.py | 56 +++++-- src/ibex_bluesky_core/devices/dae/dae.py | 12 +- .../devices/dae/dae_controls.py | 2 +- .../devices/dae/dae_spectra.py | 47 ++++++ .../devices/simpledae/__init__.py | 88 +++++++++++ .../devices/simpledae/controllers.py | 137 +++++++++++++++++ .../devices/simpledae/reducers.py | 140 ++++++++++++++++++ .../devices/simpledae/strategies.py | 59 ++++++++ .../devices/simpledae/waiters.py | 93 ++++++++++++ tests/devices/simpledae/conftest.py | 17 +++ tests/devices/simpledae/test_controllers.py | 105 +++++++++++++ tests/devices/simpledae/test_reducers.py | 131 ++++++++++++++++ tests/devices/simpledae/test_simpledae.py | 103 +++++++++++++ tests/devices/simpledae/test_waiters.py | 89 +++++++++++ tests/devices/test_dae.py | 71 ++++++++- 20 files changed, 1310 insertions(+), 23 deletions(-) create mode 100644 doc/simpledae/controllers.md create mode 100644 doc/simpledae/reducers.md create mode 100644 doc/simpledae/simpledae.md create mode 100644 doc/simpledae/waiters.md create mode 100644 src/ibex_bluesky_core/devices/simpledae/__init__.py create mode 100644 src/ibex_bluesky_core/devices/simpledae/controllers.py create mode 100644 src/ibex_bluesky_core/devices/simpledae/reducers.py create mode 100644 src/ibex_bluesky_core/devices/simpledae/strategies.py create mode 100644 src/ibex_bluesky_core/devices/simpledae/waiters.py create mode 100644 tests/devices/simpledae/conftest.py create mode 100644 tests/devices/simpledae/test_controllers.py create mode 100644 tests/devices/simpledae/test_reducers.py create mode 100644 tests/devices/simpledae/test_simpledae.py create mode 100644 tests/devices/simpledae/test_waiters.py diff --git a/doc/simpledae/controllers.md b/doc/simpledae/controllers.md new file mode 100644 index 0000000..f4f723b --- /dev/null +++ b/doc/simpledae/controllers.md @@ -0,0 +1,46 @@ +# SimpleDae controllers + +The `Controller` class is responsible for starting and stopping acquisitions, in a generic +way. + +# Predefined controllers + +Some controllers have been predefined in the +`ibex_bluesky_core.devices.simpledae.controllers` module. + +## RunPerPointController + +This controller starts and stops a new DAE run for each scan point. It can be configured to +either end runs or abort them on completion. + +This controller causes the following signals to be published by `SimpleDae`: + +- `controller.run_number` - The run number into which data was collected. Only published + if runs are being saved. + +## PeriodPerPointController + +This controller begins a single DAE run at the start of a scan, and then counts into a new +DAE period for each individual scan point. + +The DAE must be configured with enough periods in advance. This is possible to do from a +plan as follows: + +```python +import bluesky.plan_stubs as bps +import bluesky.plans as bp +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.block import BlockRw + + +def plan(): + dae: SimpleDae = ... + block: BlockRw = ... + num_points = 20 + yield from bps.mv(dae.number_of_periods, num_points) + yield from bp.scan([dae], block, 0, 10, num=num_points) +``` + +The controller causes the following signals to be published by `SimpleDae`: + +- `simpledae.period_num` - the period number into which this scan point was counted. \ No newline at end of file diff --git a/doc/simpledae/reducers.md b/doc/simpledae/reducers.md new file mode 100644 index 0000000..393574f --- /dev/null +++ b/doc/simpledae/reducers.md @@ -0,0 +1,36 @@ +# Reducers + +A `Reducer` for a `SimpleDae` is responsible for publishing any data derived from the raw +DAE signals. For example, normalizing intensities are implemented as a reducer. + +A reducer may produce any number of reduced signals. + +## GoodFramesNormalizer + +This normalizer sums a set of user-defined detector spectra, and then divides by the number +of good frames. + +Published signals: +- `simpledae.good_frames` - the number of good frames reported by the DAE +- `reducer.det_counts` - summed detector counts for all of the user-provided spectra +- `reducer.intensity` - normalized intensity (`det_counts / good_frames`) + +## PeriodGoodFramesNormalizer + +Equivalent to the `GoodFramesNormalizer` above, but uses good frames only from the current +period. This should be used if a controller which counts into multiple periods is being used. + +Published signals: +- `simpledae.period.good_frames` - the number of good frames reported by the DAE +- `reducer.det_counts` - summed detector counts for all of the user-provided spectra +- `reducer.intensity` - normalized intensity (`det_counts / good_frames`) + +## DetectorMonitorNormalizer + +This normalizer sums a set of user-defined detector spectra, and then divides by the sum +of a set of user-defined monitor spectra. + +Published signals: +- `reducer.det_counts` - summed detector counts for the user-provided detector spectra +- `reducer.mon_counts` - summed monitor counts for the user-provided monitor spectra +- `reducer.intensity` - normalized intensity (`det_counts / mon_counts`) diff --git a/doc/simpledae/simpledae.md b/doc/simpledae/simpledae.md new file mode 100644 index 0000000..0c80876 --- /dev/null +++ b/doc/simpledae/simpledae.md @@ -0,0 +1,51 @@ +# Simple Dae + +The `SimpleDae` class is designed to be a configurable DAE object, which will cover the +majority of DAE use-cases within bluesky. + +This class uses several objects to configure its behaviour: +- The `Controller` is responsible for beginning and ending acquisitions. +- The `Waiter` is responsible for waiting for an acquisition to be "complete". +- The `Reducer` is responsible for publishing data from an acquisition that has + just been completed. + +This means that `SimpleDae` is generic enough to cope with most typical DAE use-casess, for +example running using either one DAE run per scan point, or one DAE period per scan point. + +For complex use-cases, particularly those where the DAE may need to start and stop multiple +acquisitions per scan point (e.g. polarization measurements), `SimpleDae` is unlikely to be +suitable; instead the `Dae` class should be subclassed directly to allow for finer control. + +## Mapping to bluesky device model + +### Start of scan (`stage`) + +`SimpleDae` will call `controller.setup()` to allow any pre-scan setup to be done. + +For example, this is where the period-per-point controller object will begin a DAE run. + +### Each scan point (`trigger`) + +`SimpleDae` will call: +- `controller.start_counting()` to begin counting for a single scan point. +- `waiter.wait()` to wait for that acquisition to complete +- `controller.stop_counting()` to finish counting for a single scan point. +- `reducer.reduce_data()` to do any necessary post-processing on + the raw DAE data (e.g. normalization) + +### Each scan point (`read`) + +Any signals marked as "interesting" by the controller, reducer or waiter will be published +in the top-level documents published when `read()`ing the `SimpleDae` object. + +These may correspond to EPICS signals directly from the DAE (e.g. good frames), or may be +soft signals derived at runtime (e.g. normalized intensity). + +This means that the `SimpleDae` object is suitable for use as a detector in most bluesky +plans, and will make an appropriate set of data available in the emitted documents. + +### End of scan (`unstage`) + +`SimpleDae` will call `controller.teardown()` to allow any post-scan teardown to be done. + +For example, this is where the period-per-point controller object will end a DAE run. \ No newline at end of file diff --git a/doc/simpledae/waiters.md b/doc/simpledae/waiters.md new file mode 100644 index 0000000..949a170 --- /dev/null +++ b/doc/simpledae/waiters.md @@ -0,0 +1,41 @@ +# Waiters + +A `waiter` defines an arbitrary strategy for how long to count at each point. + +Some waiters may be very simple, such as waiting for a fixed amount of time or for a number +of good frames or microamp-hours. However, it is also possible to define much more +sophisticated waiters, for example waiting until sufficient statistics have been collected. + +## GoodUahWaiter + +Waits for a user-specified number of microamp-hours. + +Published signals: +- `simpledae.good_uah` - actual good uAh for this run. + +## GoodFramesWaiter + +Waits for a user-specified number of good frames (in total for the entire run) + +Published signals: +- `simpledae.good_frames` - actual good frames for this run. + +## GoodFramesWaiter + +Waits for a user-specified number of good frames (in the current period) + +Published signals: +- `simpledae.period.good_frames` - actual period good frames for this run. + +## MEventsWaiter + +Waits for a user-specified number of millions of events + +Published signals: +- `simpledae.m_events` - actual period good frames for this run. + +## TimeWaiter + +Waits for a user-specified time duration, irrespective of DAE state. + +Does not publish any additional signals. diff --git a/pyproject.toml b/pyproject.toml index fd8d9c5..85c7d1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,8 @@ dependencies = [ "bluesky", "ophyd-async[ca]", "matplotlib", - "numpy" + "numpy", + "scipp", ] [project.optional-dependencies] @@ -76,6 +77,12 @@ omit = [ [tool.coverage.report] fail_under = 100 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if typing.TYPE_CHECKING:", + "@abstractmethod", +] [tool.coverage.html] directory = "coverage_html_report" diff --git a/src/ibex_bluesky_core/demo_plan.py b/src/ibex_bluesky_core/demo_plan.py index fabdac9..ca950ea 100644 --- a/src/ibex_bluesky_core/demo_plan.py +++ b/src/ibex_bluesky_core/demo_plan.py @@ -3,17 +3,25 @@ from typing import Generator import bluesky.plan_stubs as bps +import bluesky.plans as bp import matplotlib import matplotlib.pyplot as plt from bluesky.callbacks import LiveTable -from bluesky.preprocessors import run_decorator, subs_decorator +from bluesky.preprocessors import subs_decorator from bluesky.utils import Msg from ophyd_async.plan_stubs import ensure_connected from ibex_bluesky_core.callbacks.plotting import LivePlot from ibex_bluesky_core.devices import get_pv_prefix from ibex_bluesky_core.devices.block import block_rw_rbv -from ibex_bluesky_core.devices.dae.dae import Dae +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.simpledae.controllers import ( + RunPerPointController, +) +from ibex_bluesky_core.devices.simpledae.reducers import ( + GoodFramesNormalizer, +) +from ibex_bluesky_core.devices.simpledae.waiters import GoodFramesWaiter from ibex_bluesky_core.run_engine import get_run_engine __all__ = ["demo_plan"] @@ -23,27 +31,45 @@ def demo_plan() -> Generator[Msg, None, None]: """Demonstration plan which moves a block and reads the DAE.""" prefix = get_pv_prefix() block = block_rw_rbv(float, "mot") - dae = Dae(prefix) + + controller = RunPerPointController(save_run=True) + waiter = GoodFramesWaiter(500) + reducer = GoodFramesNormalizer( + prefix=prefix, + detector_spectra=[i for i in range(1, 100)], + ) + + dae = SimpleDae( + prefix=prefix, + controller=controller, + waiter=waiter, + reducer=reducer, + ) + + # Demo giving some signals more user-friendly names + controller.run_number.set_name("run number") + reducer.intensity.set_name("normalized counts") yield from ensure_connected(block, dae, force_reconnect=True) @subs_decorator( [ - LivePlot(y="DAE-good_uah", x=block.name, marker="x", linestyle="none"), - LiveTable([block.name, "DAE-good_uah"]), + LivePlot(y=reducer.intensity.name, x=block.name, marker="x", linestyle="none"), + LiveTable( + [ + block.name, + controller.run_number.name, + reducer.intensity.name, + reducer.det_counts.name, + dae.good_frames.name, + ] + ), ] ) - @run_decorator(md={}) def _inner() -> Generator[Msg, None, None]: - # Acquisition showing arbitrary DAE control to support complex use-cases. - yield from bps.abs_set(block, 2.0, wait=True) - yield from bps.trigger(dae.controls.begin_run, wait=True) - yield from bps.sleep(5) # ... some complicated logic ... - yield from bps.trigger(dae.controls.end_run, wait=True) - yield from bps.create() # Create a bundle of readings - yield from bps.read(block) - yield from bps.read(dae.good_uah) - yield from bps.save() + num_points = 20 + yield from bps.mv(dae.number_of_periods, num_points) + yield from bp.scan([dae], block, 0, 10, num=num_points) yield from _inner() diff --git a/src/ibex_bluesky_core/devices/dae/dae.py b/src/ibex_bluesky_core/devices/dae/dae.py index 06f0f48..0fa9efc 100644 --- a/src/ibex_bluesky_core/devices/dae/dae.py +++ b/src/ibex_bluesky_core/devices/dae/dae.py @@ -56,8 +56,16 @@ def __init__(self, prefix: str, name: str = "DAE") -> None: self.good_frames: SignalR[int] = epics_signal_r(int, f"{dae_prefix}GOODFRAMES") self.raw_frames: SignalR[int] = epics_signal_r(int, f"{dae_prefix}RAWFRAMES") self.total_counts: SignalR[int] = epics_signal_r(int, f"{dae_prefix}TOTALCOUNTS") - self.run_number: SignalR[int] = epics_signal_r(int, f"{dae_prefix}IRUNNUMBER") - self.run_number_str: SignalR[str] = epics_signal_r(str, f"{dae_prefix}RUNNUMBER") + + # Beware that this increments just after a run is ended. So it is generally not correct to + # read this just after a DAE run has been ended(). + self.current_or_next_run_number: SignalR[int] = epics_signal_r( + int, f"{dae_prefix}IRUNNUMBER" + ) + self.current_or_next_run_number_str: SignalR[str] = epics_signal_r( + str, f"{dae_prefix}RUNNUMBER" + ) + self.cycle_number: SignalR[str] = epics_signal_r(str, f"{dae_prefix}ISISCYCLE") self.inst_name: SignalR[str] = epics_signal_r(str, f"{dae_prefix}INSTNAME") self.run_start_time: SignalR[str] = epics_signal_r(str, f"{dae_prefix}STARTTIME") diff --git a/src/ibex_bluesky_core/devices/dae/dae_controls.py b/src/ibex_bluesky_core/devices/dae/dae_controls.py index 5e32f64..80d216b 100644 --- a/src/ibex_bluesky_core/devices/dae/dae_controls.py +++ b/src/ibex_bluesky_core/devices/dae/dae_controls.py @@ -43,4 +43,4 @@ def __init__(self, dae_prefix: str, name: str = "") -> None: @AsyncStatus.wrap async def set(self, value: BeginRunExBits) -> None: """Start a run with the specified bits - See BeginRunExBits.""" - await self._raw_begin_run_ex.set(value, wait=True) + await self._raw_begin_run_ex.set(value, wait=True, timeout=None) diff --git a/src/ibex_bluesky_core/devices/dae/dae_spectra.py b/src/ibex_bluesky_core/devices/dae/dae_spectra.py index e839b08..0c90469 100644 --- a/src/ibex_bluesky_core/devices/dae/dae_spectra.py +++ b/src/ibex_bluesky_core/devices/dae/dae_spectra.py @@ -2,6 +2,8 @@ import asyncio +import scipp as sc +from event_model.documents.event_descriptor import DataKey from numpy import float32 from numpy.typing import NDArray from ophyd_async.core import SignalR, StandardReadable @@ -22,6 +24,15 @@ def __init__(self, dae_prefix: str, *, spectra: int, period: int, name: str = "" int, f"{dae_prefix}SPEC:{period}:{spectra}:X.NORD" ) + # x-axis; time-of-flight. + # These are bin-edge coordinates, with a size one more than the corresponding data. + self.tof_edges: SignalR[NDArray[float32]] = epics_signal_r( + NDArray[float32], f"{dae_prefix}SPEC:{period}:{spectra}:XE" + ) + self.tof_edges_size: SignalR[int] = epics_signal_r( + int, f"{dae_prefix}SPEC:{period}:{spectra}:XE.NORD" + ) + # y-axis; counts / tof # This is the number of counts in a ToF bin, normalized by the width of # that ToF bin. @@ -57,6 +68,10 @@ async def read_tof(self) -> NDArray[float32]: """Read a correctly-sized time-of-flight (x) array representing bin centres.""" return await self._read_sized(self.tof, self.tof_size) + async def read_tof_edges(self) -> NDArray[float32]: + """Read a correctly-sized time-of-flight (x) array representing bin edges.""" + return await self._read_sized(self.tof_edges, self.tof_edges_size) + async def read_counts(self) -> NDArray[float32]: """Read a correctly-sized array of counts.""" return await self._read_sized(self.counts, self.counts_size) @@ -64,3 +79,35 @@ async def read_counts(self) -> NDArray[float32]: async def read_counts_per_time(self) -> NDArray[float32]: """Read a correctly-sized array of counts divided by bin width.""" return await self._read_sized(self.counts_per_time, self.counts_per_time_size) + + async def read_spectrum_dataarray(self) -> sc.DataArray: + """Get a scipp DataArray containing the current data from this spectrum. + + Variances are set to the counts - i.e. the standard deviation is sqrt(N), which is typical + for counts data. + + Data is returned along dimension "tof", which has bin-edge coordinates and units set from + the units of the underlying PVs. + """ + tof_edges, tof_edges_descriptor, counts = await asyncio.gather( + self.read_tof_edges(), + self.tof_edges.describe(), + self.read_counts(), + ) + + if tof_edges.size != counts.size + 1: + raise ValueError( + "Time-of-flight edges must have size one more than the data. " + "You may be trying to read too many time channels. " + f"Edges size was {tof_edges.size}, counts size was {counts.size}." + ) + + datakey: DataKey = tof_edges_descriptor[self.tof_edges.name] + unit = datakey.get("units", None) + if unit is None: + raise ValueError("Could not determine engineering units of tof edges.") + + return sc.DataArray( + data=sc.Variable(dims=["tof"], values=counts, variances=counts, unit=sc.units.counts), + coords={"tof": sc.array(dims=["tof"], values=tof_edges, unit=sc.Unit(unit))}, + ) diff --git a/src/ibex_bluesky_core/devices/simpledae/__init__.py b/src/ibex_bluesky_core/devices/simpledae/__init__.py new file mode 100644 index 0000000..a6b16fc --- /dev/null +++ b/src/ibex_bluesky_core/devices/simpledae/__init__.py @@ -0,0 +1,88 @@ +"""A simple interface to the DAE for bluesky.""" + +import typing + +from bluesky.protocols import Triggerable +from ophyd_async.core import ( + AsyncStageable, + AsyncStatus, +) + +from ibex_bluesky_core.devices.dae.dae import Dae + +if typing.TYPE_CHECKING: + from ibex_bluesky_core.devices.simpledae.controllers import Controller + from ibex_bluesky_core.devices.simpledae.reducers import Reducer + from ibex_bluesky_core.devices.simpledae.waiters import Waiter + + +class SimpleDae(Dae, Triggerable, AsyncStageable): + """Configurable DAE with pluggable strategies for data collection, waiting, and reduction. + + This class should cover many simple DAE use-cases, but for complex use-cases a custom Dae + subclass may still be required to give maximum flexibility. + """ + + def __init__( + self, + *, + prefix: str, + name: str = "DAE", + controller: "Controller", + waiter: "Waiter", + reducer: "Reducer", + ) -> None: + """Initialize a simple DAE interface. + + Args: + prefix: the PV prefix of the instrument being controlled. + name: A friendly name for this DAE object. + controller: A DAE control strategy, defines how the DAE begins and ends data acquisition + Pre-defined strategies in the ibex_bluesky_core.devices.controllers module + waiter: A waiting strategy, defines how the DAE waits for an acquisition to be complete + Pre-defined strategies in the ibex_bluesky_core.devices.waiters module + reducer: A data reduction strategy, defines the post-processing on raw DAE data, for + example normalization or unit conversion. + Pre-defined strategies in the ibex_bluesky_core.devices.reducers module + + """ + self.prefix = prefix + self.controller: "Controller" = controller + self.waiter: "Waiter" = waiter + self.reducer: "Reducer" = reducer + + # controller, waiter and reducer may be Devices (but don't necessarily have to be), + # so can define their own signals. Do __init__ after defining those, so that the signals + # are connected/named and usable. + super().__init__(prefix=prefix, name=name) + + # Ask each defined strategy what it's interesting signals are, and ensure those signals are + # published when the top-level SimpleDae object is read. + extra_readables = set() + for strategy in [self.controller, self.waiter, self.reducer]: + for sig in strategy.additional_readable_signals(self): + extra_readables.add(sig) + + self.add_readables(devices=list(extra_readables)) + + @AsyncStatus.wrap + async def stage(self) -> None: + """Pre-scan setup. Delegate to the controller.""" + await self.controller.setup(self) + + @AsyncStatus.wrap + async def trigger(self) -> None: + """Take a single measurement and prepare it for subsequent reading. + + This waits for the acquisition and any defined reduction to be complete, such that + after this coroutine completes all relevant data is available via read() + """ + await self.controller.start_counting(self) + await self.waiter.wait(self) + await self.controller.stop_counting(self) + await self.reducer.reduce_data(self) + + @AsyncStatus.wrap + async def unstage(self) -> None: + """Post-scan teardown, delegate to the controller.""" + await self.controller.teardown(self) diff --git a/src/ibex_bluesky_core/devices/simpledae/controllers.py b/src/ibex_bluesky_core/devices/simpledae/controllers.py new file mode 100644 index 0000000..fc847f6 --- /dev/null +++ b/src/ibex_bluesky_core/devices/simpledae/controllers.py @@ -0,0 +1,137 @@ +"""DAE control strategies.""" + +import typing + +from ophyd_async.core import ( + Device, + StandardReadable, + soft_signal_r_and_setter, + wait_for_value, +) + +from ibex_bluesky_core.devices.dae.dae import RunstateEnum +from ibex_bluesky_core.devices.dae.dae_controls import BeginRunExBits +from ibex_bluesky_core.devices.simpledae.strategies import Controller + +if typing.TYPE_CHECKING: + from ibex_bluesky_core.devices.simpledae import SimpleDae + + +async def _end_or_abort_run(dae: "SimpleDae", save: bool) -> None: + if save: + await dae.controls.end_run.trigger(wait=True, timeout=None) + else: + await dae.controls.abort_run.trigger(wait=True, timeout=None) + + +class PeriodPerPointController(Controller): + """Controller for a SimpleDae which counts using a period per point. + + A single run is opened during stage(), and then each new point will count into a new + DAE period (starting from 1). The run will be either ended or aborted in unstage, depending + on the value of the save_run parameter. + """ + + def __init__(self, save_run: bool) -> None: + """Period-per-point DAE controller. + + Args: + save_run: True to terminate runs using end(), saving the data. False to terminate runs + using abort(), discarding the data. + + """ + self._save_run = save_run + self._current_period = 0 + + async def setup(self, dae: "SimpleDae") -> None: + """Pre-scan setup (begin a new run in paused mode).""" + self._current_period = 0 + await dae.controls.begin_run_ex.set(BeginRunExBits.BEGIN_PAUSED) + await wait_for_value(dae.run_state, RunstateEnum.PAUSED, timeout=10) + + async def start_counting(self, dae: "SimpleDae") -> None: + """Start counting a scan point. + + Increments the period by 1, then unpauses the run. + """ + self._current_period += 1 + await dae.period_num.set(self._current_period, wait=True, timeout=None) + + # Error if the period change didn't work (e.g. we have exceeded max periods) + await wait_for_value( + dae.period_num, + self._current_period, + timeout=10, + ) + + # Ensure frame counters have reset to zero for the new period. + await wait_for_value(dae.period.good_frames, 0, timeout=10) + await wait_for_value(dae.period.raw_frames, 0, timeout=10) + + await dae.controls.resume_run.trigger(wait=True, timeout=None) + await wait_for_value( + dae.run_state, + lambda v: v in [RunstateEnum.RUNNING, RunstateEnum.WAITING, RunstateEnum.VETOING], + timeout=10, + ) + + async def stop_counting(self, dae: "SimpleDae") -> None: + """Stop counting a scan point, by pausing the run.""" + await dae.controls.pause_run.trigger(wait=True, timeout=None) + await wait_for_value(dae.run_state, RunstateEnum.PAUSED, timeout=10) + + async def teardown(self, dae: "SimpleDae") -> None: + """Finish taking data, ending or aborting the run.""" + await _end_or_abort_run(dae, self._save_run) + + def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]: + """period_num is always an interesting signal if using this controller.""" + return [dae.period_num] + + +class RunPerPointController(Controller, StandardReadable): + """Controller for a SimpleDae which counts using a DAE run per point. + + The runs can be either ended or aborted once counting is finished. + + """ + + def __init__(self, save_run: bool) -> None: + """Init. + + Args: + save_run: whether to end the run (True) or abort the run (False) on completion. + + """ + self._save_run = save_run + + # This run number is the run that the DAE *actually* counted into, as opposed to reading + # dae.run_number, which increments immediately after end and so reflects the next run + # number. + self.run_number, self._run_number_setter = soft_signal_r_and_setter(int, 0) + super().__init__() + + async def start_counting(self, dae: "SimpleDae") -> None: + """Start counting a scan point, by starting a DAE run.""" + await dae.controls.begin_run.trigger(wait=True, timeout=None) + await wait_for_value( + dae.run_state, + lambda v: v in [RunstateEnum.RUNNING, RunstateEnum.WAITING, RunstateEnum.VETOING], + timeout=10, + ) + + # Take care to read this after we've started a run, but before ending it, so that it + # accurately reflects the run number we're actually counting into. + run_number = await dae.current_or_next_run_number.get_value() + self._run_number_setter(run_number) + + async def stop_counting(self, dae: "SimpleDae") -> None: + """Stop counting a scan point, by ending or aborting the run.""" + await _end_or_abort_run(dae, self._save_run) + + def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]: + """Run number is an interesting signal only if saving runs.""" + if self._save_run: + return [self.run_number] + else: + return [] diff --git a/src/ibex_bluesky_core/devices/simpledae/reducers.py b/src/ibex_bluesky_core/devices/simpledae/reducers.py new file mode 100644 index 0000000..067b32b --- /dev/null +++ b/src/ibex_bluesky_core/devices/simpledae/reducers.py @@ -0,0 +1,140 @@ +"""DAE data reduction strategies.""" + +import asyncio +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Collection, Sequence + +import scipp as sc +from ophyd_async.core import ( + Device, + DeviceVector, + SignalR, + StandardReadable, + soft_signal_r_and_setter, +) + +from ibex_bluesky_core.devices.dae.dae_spectra import DaeSpectra +from ibex_bluesky_core.devices.simpledae.strategies import Reducer + +if TYPE_CHECKING: + from ibex_bluesky_core.devices.simpledae import SimpleDae + + +async def sum_spectra(spectra: Collection[DaeSpectra]) -> sc.Variable | sc.DataArray: + """Read and sum a number of spectra from the DAE. + + Returns a scipp scalar, which has .value and .variance properties for accessing the sum + and variance respectively of the summed counts. + """ + summed_counts = sc.scalar(value=0, unit=sc.units.counts, dtype="float64") + for spec in asyncio.as_completed([s.read_spectrum_dataarray() for s in spectra]): + summed_counts += (await spec).sum() + return summed_counts + + +class ScalarNormalizer(Reducer, StandardReadable, metaclass=ABCMeta): + """Sum a set of user-specified spectra, then normalize by a scalar signal.""" + + def __init__(self, prefix: str, detector_spectra: Sequence[int]) -> None: + """Init. + + Args: + prefix: the PV prefix of the instrument to get spectra from (e.g. IN:DEMO:) + detector_spectra: a sequence of spectra numbers (detectors) to sum. + + """ + self.detectors = DeviceVector( + { + i: DaeSpectra(dae_prefix=prefix + "DAE:", spectra=i, period=0) + for i in detector_spectra + } + ) + + self.det_counts, self._det_counts_setter = soft_signal_r_and_setter(float, 0.0) + self.intensity, self._intensity_setter = soft_signal_r_and_setter(float, 0.0, precision=6) + + super().__init__(name="") + + @abstractmethod + def denominator(self, dae: "SimpleDae") -> SignalR[int] | SignalR[float]: + """Get the normalization denominator, which is assumed to be a scalar signal.""" + + async def reduce_data(self, dae: "SimpleDae") -> None: + """Apply the normalization.""" + summed_counts, denominator = await asyncio.gather( + sum_spectra(self.detectors.values()), self.denominator(dae).get_value() + ) + + self._det_counts_setter(float(summed_counts.value)) + self._intensity_setter(float(summed_counts.value) / denominator) + + def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]: + """Publish interesting signals derived or used by this reducer.""" + return [ + self.det_counts, + self.intensity, + self.denominator(dae), + ] + + +class PeriodGoodFramesNormalizer(ScalarNormalizer): + """Sum a set of user-specified spectra, then normalize by period good frames.""" + + def denominator(self, dae: "SimpleDae") -> SignalR[int]: + """Get normalization denominator (period good frames).""" + return dae.period.good_frames + + +class GoodFramesNormalizer(ScalarNormalizer): + """Sum a set of user-specified spectra, then normalize by total good frames.""" + + def denominator(self, dae: "SimpleDae") -> SignalR[int]: + """Get normalization denominator (total good frames).""" + return dae.good_frames + + +class MonitorNormalizer(Reducer, StandardReadable): + """Normalize a set of user-specified detector spectra by user-specified monitor spectra.""" + + def __init__( + self, prefix: str, detector_spectra: Sequence[int], monitor_spectra: Sequence[int] + ) -> None: + """Init. + + Args: + prefix: the PV prefix of the instrument to get spectra from (e.g. IN:DEMO:) + detector_spectra: a sequence of spectra numbers (detectors) to sum. + monitor_spectra: a sequence of spectra number (monitors) to sum and normalize by. + + """ + dae_prefix = prefix + "DAE:" + self.detectors = DeviceVector( + {i: DaeSpectra(dae_prefix=dae_prefix, spectra=i, period=0) for i in detector_spectra} + ) + self.monitors = DeviceVector( + {i: DaeSpectra(dae_prefix=dae_prefix, spectra=i, period=0) for i in monitor_spectra} + ) + + self.det_counts, self._det_counts_setter = soft_signal_r_and_setter(float, 0.0) + self.mon_counts, self._mon_counts_setter = soft_signal_r_and_setter(float, 0.0) + self.intensity, self._intensity_setter = soft_signal_r_and_setter(float, 0.0, precision=6) + + super().__init__(name="") + + async def reduce_data(self, dae: "SimpleDae") -> None: + """Apply the normalization.""" + detector_counts, monitor_counts = await asyncio.gather( + sum_spectra(self.detectors.values()), sum_spectra(self.monitors.values()) + ) + + self._det_counts_setter(float(detector_counts.value)) + self._mon_counts_setter(float(monitor_counts.value)) + self._intensity_setter(float((detector_counts / monitor_counts).value)) + + def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]: + """Publish interesting signals derived or used by this reducer.""" + return [ + self.det_counts, + self.mon_counts, + self.intensity, + ] diff --git a/src/ibex_bluesky_core/devices/simpledae/strategies.py b/src/ibex_bluesky_core/devices/simpledae/strategies.py new file mode 100644 index 0000000..7021612 --- /dev/null +++ b/src/ibex_bluesky_core/devices/simpledae/strategies.py @@ -0,0 +1,59 @@ +"""Base classes for DAE strategies.""" + +from typing import TYPE_CHECKING + +from ophyd_async.core import ( + Device, +) + +if TYPE_CHECKING: + from ibex_bluesky_core.devices.simpledae import SimpleDae + + +class ProvidesExtraReadables: + """Strategies may specify interesting DAE signals using this method. + + Those signals will then be added to read() and describe() on the top-level SimpleDae object. + """ + + def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]: + """Define signals that this strategy considers important. + + These will be added to the dae's default-read signals and made available by read() on the + DAE object. + """ + return [] + + +class Controller(ProvidesExtraReadables): + """Controller specifies how DAE runs should be started & stopped.""" + + async def start_counting(self, dae: "SimpleDae") -> None: + """Start counting for a single scan point.""" + + async def stop_counting(self, dae: "SimpleDae") -> None: + """Stop counting for a single scan point.""" + + async def setup(self, dae: "SimpleDae") -> None: + """Pre-scan setup.""" + + async def teardown(self, dae: "SimpleDae") -> None: + """Post-scan teardown.""" + + +class Waiter(ProvidesExtraReadables): + """Waiter specifies how the dae will wait for a scan point to complete counting.""" + + async def wait(self, dae: "SimpleDae") -> None: + """Wait for the acquisition to complete.""" + + +class Reducer(ProvidesExtraReadables): + """Reducer specifies any post-processing which needs to be done after a scan point completes.""" + + async def reduce_data(self, dae: "SimpleDae") -> None: + """Triggers a reduction of DAE data after a scan point has been measured. + + Data that should be published by this reducer should be added as soft signals, in + a class which both implements this protocol and derives from StandardReadable. + """ diff --git a/src/ibex_bluesky_core/devices/simpledae/waiters.py b/src/ibex_bluesky_core/devices/simpledae/waiters.py new file mode 100644 index 0000000..14b3ade --- /dev/null +++ b/src/ibex_bluesky_core/devices/simpledae/waiters.py @@ -0,0 +1,93 @@ +"""DAE waiting strategies.""" + +import asyncio +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Generic, TypeVar + +from ophyd_async.core import ( + Device, + SignalR, + wait_for_value, +) + +from ibex_bluesky_core.devices.simpledae.strategies import Waiter + +if TYPE_CHECKING: + from ibex_bluesky_core.devices.simpledae import SimpleDae + + +T = TypeVar("T", int, float) + + +class _SimpleWaiter(Waiter, Generic[T], metaclass=ABCMeta): + """Wait for a single DAE variable to be greater or equal to a specified numeric value.""" + + def __init__(self, value: T) -> None: + """Wait for a value to be at least equal to the specified value. + + Args: + value: the value to wait for + + """ + self._value: T = value + + async def wait(self, dae: "SimpleDae") -> None: + """Wait for signal to reach the user-specified value.""" + await wait_for_value(self.get_signal(dae), lambda v: v >= self._value, timeout=None) + + def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]: + """Publish the signal we're waiting on as an interesting signal.""" + return [self.get_signal(dae)] + + @abstractmethod + def get_signal(self, dae: "SimpleDae") -> SignalR[T]: + pass + + +class PeriodGoodFramesWaiter(_SimpleWaiter[int]): + """Wait for period good frames to reach a user-specified value.""" + + def get_signal(self, dae: "SimpleDae") -> SignalR[int]: + """Wait for period good frames.""" + return dae.period.good_frames + + +class GoodFramesWaiter(_SimpleWaiter[int]): + """Wait for good frames to reach a user-specified value.""" + + def get_signal(self, dae: "SimpleDae") -> SignalR[int]: + """Wait for good frames.""" + return dae.good_frames + + +class GoodUahWaiter(_SimpleWaiter[float]): + """Wait for good microamp-hours to reach a user-specified value.""" + + def get_signal(self, dae: "SimpleDae") -> SignalR[float]: + """Wait for good uah.""" + return dae.good_uah + + +class MEventsWaiter(_SimpleWaiter[float]): + """Wait for a user-specified number of millions of events.""" + + def get_signal(self, dae: "SimpleDae") -> SignalR[float]: + """Wait for mevents.""" + return dae.m_events + + +class TimeWaiter(Waiter): + """Wait for a user-specified time duration.""" + + def __init__(self, *, seconds: float) -> None: + """Init. + + Args: + seconds: number of seconds to wait for. + + """ + self._secs = seconds + + async def wait(self, dae: "SimpleDae") -> None: + """Wait for the specified time duration.""" + await asyncio.sleep(self._secs) diff --git a/tests/devices/simpledae/conftest.py b/tests/devices/simpledae/conftest.py new file mode 100644 index 0000000..92495f0 --- /dev/null +++ b/tests/devices/simpledae/conftest.py @@ -0,0 +1,17 @@ +import pytest + +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.simpledae.strategies import Controller, Reducer, Waiter + + +@pytest.fixture +async def simpledae() -> SimpleDae: + dae = SimpleDae( + prefix="unittest:mock:", + name="dae", + controller=Controller(), + waiter=Waiter(), + reducer=Reducer(), + ) + await dae.connect(mock=True) + return dae diff --git a/tests/devices/simpledae/test_controllers.py b/tests/devices/simpledae/test_controllers.py new file mode 100644 index 0000000..ba8f4e5 --- /dev/null +++ b/tests/devices/simpledae/test_controllers.py @@ -0,0 +1,105 @@ +import pytest +from ophyd_async.core import get_mock_put, set_mock_value + +from ibex_bluesky_core.devices.dae.dae import RunstateEnum +from ibex_bluesky_core.devices.dae.dae_controls import BeginRunExBits +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.simpledae.controllers import ( + PeriodPerPointController, + RunPerPointController, +) + + +@pytest.fixture +def period_per_point_controller() -> PeriodPerPointController: + return PeriodPerPointController(save_run=True) + + +@pytest.fixture +def aborting_period_per_point_controller() -> PeriodPerPointController: + return PeriodPerPointController(save_run=False) + + +@pytest.fixture +def run_per_point_controller() -> RunPerPointController: + return RunPerPointController(save_run=True) + + +@pytest.fixture +def aborting_run_per_point_controller() -> RunPerPointController: + return RunPerPointController(save_run=False) + + +async def test_period_per_point_controller_publishes_current_period( + simpledae: SimpleDae, period_per_point_controller: PeriodPerPointController +): + assert period_per_point_controller.additional_readable_signals(simpledae) == [ + simpledae.period_num + ] + + +async def test_period_per_point_controller_begins_run_in_setup_and_ends_in_teardown( + simpledae: SimpleDae, period_per_point_controller: PeriodPerPointController +): + set_mock_value(simpledae.run_state, RunstateEnum.PAUSED) + await period_per_point_controller.setup(simpledae) + get_mock_put(simpledae.controls.begin_run_ex._raw_begin_run_ex).assert_called_once_with( + BeginRunExBits.BEGIN_PAUSED, wait=True, timeout=None + ) + set_mock_value(simpledae.run_state, RunstateEnum.SETUP) + await period_per_point_controller.teardown(simpledae) + get_mock_put(simpledae.controls.end_run).assert_called_once_with(None, wait=True, timeout=None) + + +async def test_aborting_period_per_point_controller_aborts_in_teardown( + simpledae: SimpleDae, aborting_period_per_point_controller: PeriodPerPointController +): + set_mock_value(simpledae.run_state, RunstateEnum.SETUP) + await aborting_period_per_point_controller.teardown(simpledae) + get_mock_put(simpledae.controls.abort_run).assert_called_once_with( + None, wait=True, timeout=None + ) + + +async def test_period_per_point_controller_changes_periods_and_counts( + simpledae: SimpleDae, period_per_point_controller: PeriodPerPointController +): + set_mock_value(simpledae.run_state, RunstateEnum.RUNNING) + await period_per_point_controller.start_counting(simpledae) + get_mock_put(simpledae.controls.resume_run).assert_called_once_with( + None, wait=True, timeout=None + ) + get_mock_put(simpledae.period_num).assert_called_once_with(1, wait=True, timeout=None) + + set_mock_value(simpledae.run_state, RunstateEnum.PAUSED) + await period_per_point_controller.stop_counting(simpledae) + get_mock_put(simpledae.controls.pause_run).assert_called_once_with( + None, wait=True, timeout=None + ) + + +async def test_run_per_point_controller_starts_and_ends_runs( + simpledae: SimpleDae, run_per_point_controller: RunPerPointController +): + set_mock_value(simpledae.run_state, RunstateEnum.RUNNING) + await run_per_point_controller.start_counting(simpledae) + get_mock_put(simpledae.controls.begin_run).assert_called_once_with( + None, wait=True, timeout=None + ) + + await run_per_point_controller.stop_counting(simpledae) + get_mock_put(simpledae.controls.end_run).assert_called_once_with(None, wait=True, timeout=None) + + +async def test_run_per_point_controller_publishes_run( + simpledae: SimpleDae, run_per_point_controller: RunPerPointController +): + assert run_per_point_controller.additional_readable_signals(simpledae) == [ + run_per_point_controller.run_number + ] + + +async def test_aborting_run_per_point_controller_doesnt_publish_run( + simpledae: SimpleDae, aborting_run_per_point_controller: RunPerPointController +): + assert aborting_run_per_point_controller.additional_readable_signals(simpledae) == [] diff --git a/tests/devices/simpledae/test_reducers.py b/tests/devices/simpledae/test_reducers.py new file mode 100644 index 0000000..a497f28 --- /dev/null +++ b/tests/devices/simpledae/test_reducers.py @@ -0,0 +1,131 @@ +from unittest.mock import AsyncMock + +import pytest +import scipp as sc +from ophyd_async.core import set_mock_value + +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.simpledae.reducers import ( + GoodFramesNormalizer, + MonitorNormalizer, + PeriodGoodFramesNormalizer, +) + + +@pytest.fixture +async def period_good_frames_reducer() -> PeriodGoodFramesNormalizer: + reducer = PeriodGoodFramesNormalizer(prefix="", detector_spectra=[1, 2]) + await reducer.connect(mock=True) + return reducer + + +@pytest.fixture +async def good_frames_reducer() -> GoodFramesNormalizer: + reducer = GoodFramesNormalizer(prefix="", detector_spectra=[1, 2]) + await reducer.connect(mock=True) + return reducer + + +@pytest.fixture +async def monitor_normalizer() -> MonitorNormalizer: + reducer = MonitorNormalizer(prefix="", detector_spectra=[1], monitor_spectra=[2]) + await reducer.connect(mock=True) + return reducer + + +class FakePeriod: + def __init__(self): + self.good_frames = object() + + +class FakeDae: + def __init__(self): + self.good_uah = object() + self.good_frames = object() + self.period = FakePeriod() + + +async def test_period_good_frames_normalizer_publishes_period_good_frames( + period_good_frames_reducer: PeriodGoodFramesNormalizer, +): + fake_dae: SimpleDae = FakeDae() # type: ignore + readables = period_good_frames_reducer.additional_readable_signals(fake_dae) + assert fake_dae.good_uah not in readables + assert fake_dae.period.good_frames in readables + + assert period_good_frames_reducer.denominator(fake_dae) == fake_dae.period.good_frames + + +async def test_good_frames_normalizer_publishes_good_frames( + good_frames_reducer: GoodFramesNormalizer, +): + fake_dae: SimpleDae = FakeDae() # type: ignore + readables = good_frames_reducer.additional_readable_signals(fake_dae) + assert fake_dae.good_uah not in readables + assert fake_dae.good_frames in readables + + assert good_frames_reducer.denominator(fake_dae) == fake_dae.good_frames + + +async def test_period_good_frames_normalizer( + simpledae: SimpleDae, + period_good_frames_reducer: PeriodGoodFramesNormalizer, +): + set_mock_value(simpledae.period.good_frames, 123) + + period_good_frames_reducer.detectors[1].read_spectrum_dataarray = AsyncMock( + return_value=sc.DataArray( + data=sc.Variable(dims=["tof"], values=[1000.0, 2000.0, 3000.0], unit=sc.units.counts), + coords={"tof": sc.array(dims=["tof"], values=[0, 1, 2, 3])}, + ) + ) + period_good_frames_reducer.detectors[2].read_spectrum_dataarray = AsyncMock( + return_value=sc.DataArray( + data=sc.Variable(dims=["tof"], values=[4000.0, 5000.0, 6000.0], unit=sc.units.counts), + coords={"tof": sc.array(dims=["tof"], values=[0, 1, 2, 3])}, + ) + ) + + await period_good_frames_reducer.reduce_data(simpledae) + + det_counts = await period_good_frames_reducer.det_counts.get_value() + intensity = await period_good_frames_reducer.intensity.get_value() + + assert det_counts == 21000 + # (21000 det counts) / (123 good frames) + assert intensity == pytest.approx(170.731707317) + + +async def test_monitor_normalizer(simpledae: SimpleDae, monitor_normalizer: MonitorNormalizer): + monitor_normalizer.detectors[1].read_spectrum_dataarray = AsyncMock( + return_value=sc.DataArray( + data=sc.Variable(dims=["tof"], values=[1000.0, 2000.0, 3000.0], unit=sc.units.counts), + coords={"tof": sc.array(dims=["tof"], values=[0, 1, 2, 3])}, + ) + ) + monitor_normalizer.monitors[2].read_spectrum_dataarray = AsyncMock( + return_value=sc.DataArray( + data=sc.Variable(dims=["tof"], values=[4000.0, 5000.0, 6000.0], unit=sc.units.counts), + coords={"tof": sc.array(dims=["tof"], values=[0, 1, 2, 3])}, + ) + ) + + await monitor_normalizer.reduce_data(simpledae) + + det_counts = await monitor_normalizer.det_counts.get_value() + mon_counts = await monitor_normalizer.mon_counts.get_value() + intensity = await monitor_normalizer.intensity.get_value() + + assert det_counts == 6000 + assert mon_counts == 15000 + assert intensity == pytest.approx(6000 / 15000) + + +async def test_monitor_normalizer_publishes_raw_and_normalized_counts( + simpledae: SimpleDae, + monitor_normalizer: MonitorNormalizer, +): + readables = monitor_normalizer.additional_readable_signals(simpledae) + assert monitor_normalizer.intensity in readables + assert monitor_normalizer.det_counts in readables + assert monitor_normalizer.mon_counts in readables diff --git a/tests/devices/simpledae/test_simpledae.py b/tests/devices/simpledae/test_simpledae.py new file mode 100644 index 0000000..c5b5a74 --- /dev/null +++ b/tests/devices/simpledae/test_simpledae.py @@ -0,0 +1,103 @@ +from unittest.mock import MagicMock + +import pytest +from ophyd_async.core import Device, StandardReadable, soft_signal_rw + +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.simpledae.strategies import Controller, Reducer, Waiter + + +@pytest.fixture +def mock_controller() -> Controller: + return MagicMock(spec=Controller) + + +@pytest.fixture +def mock_waiter() -> Waiter: + return MagicMock(spec=Waiter) + + +@pytest.fixture +def mock_reducer() -> Reducer: + return MagicMock(spec=Reducer) + + +@pytest.fixture +async def simpledae( + mock_controller: Controller, mock_waiter: Waiter, mock_reducer: Reducer +) -> SimpleDae: + simpledae = SimpleDae( + prefix="unittest:mock:", + name="simpledae", + controller=mock_controller, + waiter=mock_waiter, + reducer=mock_reducer, + ) + await simpledae.connect(mock=True) + return simpledae + + +async def test_simpledae_calls_controller_on_stage_and_unstage( + simpledae: SimpleDae, mock_controller: MagicMock +): + await simpledae.stage() + mock_controller.setup.assert_called_once_with(simpledae) + await simpledae.unstage() + mock_controller.teardown.assert_called_once_with(simpledae) + + +async def test_simpledae_calls_controller_on_trigger( + simpledae: SimpleDae, mock_controller: MagicMock +): + await simpledae.trigger() + mock_controller.start_counting.assert_called_once_with(simpledae) + mock_controller.stop_counting.assert_called_once_with(simpledae) + + +async def test_simpledae_calls_waiter_on_trigger(simpledae: SimpleDae, mock_waiter: MagicMock): + await simpledae.trigger() + mock_waiter.wait.assert_called_once_with(simpledae) + + +async def test_simpledae_calls_reducer_on_trigger(simpledae: SimpleDae, mock_reducer: MagicMock): + await simpledae.trigger() + mock_reducer.reduce_data.assert_called_once_with(simpledae) + + +async def test_simpledae_publishes_interesting_signals_in_read(): + class TestReducer(Reducer, StandardReadable): + def __init__(self): + self.soft_signal = soft_signal_rw(float, 0.0) + super().__init__(name="reducer") + + def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]: + # Signal explicitly published by this reducer rather than the DAE itself + return [self.soft_signal] + + class TestController(Controller): + def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]: + return [dae.good_uah] + + class TestWaiter(Waiter): + def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]: + # Same signal as controller, should only be added once. + return [dae.good_uah] + + reducer = TestReducer() + + dae = SimpleDae( + prefix="", + name="dae", + controller=TestController(), + waiter=TestWaiter(), + reducer=reducer, + ) + await dae.connect(mock=True) + reading = await dae.read() + + assert reducer.soft_signal.name in reading + assert dae.good_uah.name in reading + + # Check that non-interesting signals are *not* read by default + assert dae.good_frames not in reading + assert len(reading) == 2 diff --git a/tests/devices/simpledae/test_waiters.py b/tests/devices/simpledae/test_waiters.py new file mode 100644 index 0000000..12d4cc6 --- /dev/null +++ b/tests/devices/simpledae/test_waiters.py @@ -0,0 +1,89 @@ +import asyncio + +import pytest +from ophyd_async.core import set_mock_value + +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.simpledae.waiters import ( + GoodFramesWaiter, + GoodUahWaiter, + MEventsWaiter, + PeriodGoodFramesWaiter, + TimeWaiter, +) + +SHORT_TIMEOUT = 0.01 + + +async def test_good_uah_waiter(simpledae: "SimpleDae"): + waiter = GoodUahWaiter(5000) + + set_mock_value(simpledae.good_uah, 4999.9) + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(waiter.wait(simpledae), timeout=SHORT_TIMEOUT) + + set_mock_value(simpledae.good_uah, 5000.1) + + # Check this returns - will raise a timeout error if not. + await asyncio.wait_for(waiter.wait(simpledae), timeout=SHORT_TIMEOUT) + + assert waiter.additional_readable_signals(simpledae) == [simpledae.good_uah] + assert waiter.get_signal(simpledae) == simpledae.good_uah + + +async def test_period_good_frames_waiter(simpledae: "SimpleDae"): + waiter = PeriodGoodFramesWaiter(5000) + + set_mock_value(simpledae.period.good_frames, 4999) + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(waiter.wait(simpledae), timeout=SHORT_TIMEOUT) + + set_mock_value(simpledae.period.good_frames, 5000) + + # Check this returns - will raise a timeout error if not. + await asyncio.wait_for(waiter.wait(simpledae), timeout=SHORT_TIMEOUT) + + assert waiter.additional_readable_signals(simpledae) == [simpledae.period.good_frames] + assert waiter.get_signal(simpledae) == simpledae.period.good_frames + + +async def test_good_frames_waiter(simpledae: "SimpleDae"): + waiter = GoodFramesWaiter(5000) + + set_mock_value(simpledae.good_frames, 4999) + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(waiter.wait(simpledae), timeout=SHORT_TIMEOUT) + + set_mock_value(simpledae.good_frames, 5000) + + # Check this returns - will raise a timeout error if not. + await asyncio.wait_for(waiter.wait(simpledae), timeout=SHORT_TIMEOUT) + + assert waiter.additional_readable_signals(simpledae) == [simpledae.good_frames] + assert waiter.get_signal(simpledae) == simpledae.good_frames + + +async def test_mevents_waiter(simpledae: "SimpleDae"): + waiter = MEventsWaiter(5000) + + set_mock_value(simpledae.m_events, 4999) + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(waiter.wait(simpledae), timeout=SHORT_TIMEOUT) + + set_mock_value(simpledae.m_events, 5000) + + # Check this returns - will raise a timeout error if not. + await asyncio.wait_for(waiter.wait(simpledae), timeout=SHORT_TIMEOUT) + + assert waiter.additional_readable_signals(simpledae) == [simpledae.m_events] + assert waiter.get_signal(simpledae) == simpledae.m_events + + +async def test_time_waiter(simpledae: "SimpleDae"): + waiter = TimeWaiter(seconds=0.01) + await waiter.wait(simpledae) + assert waiter.additional_readable_signals(simpledae) == [] diff --git a/tests/devices/test_dae.py b/tests/devices/test_dae.py index a56f3ad..6ef4f17 100644 --- a/tests/devices/test_dae.py +++ b/tests/devices/test_dae.py @@ -1,10 +1,13 @@ # pyright: reportMissingParameterType=false from enum import Enum +from unittest.mock import AsyncMock from xml.etree import ElementTree as ET import bluesky.plan_stubs as bps import numpy as np import pytest +import scipp as sc +import scipp.testing from bluesky.run_engine import RunEngine from ophyd_async.core import get_mock_put, set_mock_value @@ -55,6 +58,13 @@ async def dae() -> Dae: return dae +@pytest.fixture +async def spectrum() -> DaeSpectra: + spectrum = DaeSpectra(dae_prefix="UNITTEST:MOCK:", spectra=1, period=1) + await spectrum.connect(mock=True) + return spectrum + + def test_dae_naming(dae: Dae): assert dae.name == "DAE" assert dae.good_uah.name == "DAE-good_uah" @@ -945,17 +955,70 @@ def test_empty_dae_settings_dataclass_does_not_change_any_settings(dae: Dae, RE: assert after.wiring_filepath.endswith("NIMROD84modules+9monitors+LAB5Oct2012Wiring.dat") -async def test_read_spectra_correctly_sizes_arrays(): - spectrum = DaeSpectra(dae_prefix="unittest", spectra=1, period=1) - await spectrum.connect(mock=True) - +async def test_read_spectra_correctly_sizes_arrays(spectrum: DaeSpectra): set_mock_value(spectrum.tof, np.zeros(dtype=np.float32, shape=(1000,))) set_mock_value(spectrum.tof_size, 100) set_mock_value(spectrum.counts, np.zeros(dtype=np.float32, shape=(2000,))) set_mock_value(spectrum.counts_size, 200) set_mock_value(spectrum.counts_per_time, np.zeros(dtype=np.float32, shape=(3000,))) set_mock_value(spectrum.counts_per_time_size, 300) + set_mock_value(spectrum.tof_edges, np.zeros(dtype=np.float32, shape=(4000,))) + set_mock_value(spectrum.tof_edges_size, 400) assert (await spectrum.read_tof()).shape == (100,) assert (await spectrum.read_counts()).shape == (200,) assert (await spectrum.read_counts_per_time()).shape == (300,) + assert (await spectrum.read_tof_edges()).shape == (400,) + + +async def test_read_spectrum_dataarray(spectrum: DaeSpectra): + set_mock_value(spectrum.counts, np.array([1000, 2000, 3000], dtype=np.float32)) + set_mock_value(spectrum.counts_size, 3) + set_mock_value(spectrum.tof_edges, np.array([0, 1, 2, 3], dtype=np.float32)) + set_mock_value(spectrum.tof_edges_size, 4) + + spectrum.tof_edges.describe = AsyncMock(return_value={spectrum.tof_edges.name: {"units": "us"}}) + da = await spectrum.read_spectrum_dataarray() + + scipp.testing.assert_identical( + da, + sc.DataArray( + data=sc.Variable( + dims=["tof"], + values=[1000, 2000, 3000], + variances=[1000, 2000, 3000], + unit=sc.units.counts, + dtype="float32", + ), + coords={ + "tof": sc.Variable( + dims=["tof"], values=[0, 1, 2, 3], dtype="float32", unit=sc.units.us + ) + }, + ), + ) + + +async def test_if_tof_edges_doesnt_have_enough_points_then_read_spec_dataarray_gives_error( + spectrum: DaeSpectra, +): + set_mock_value(spectrum.counts, np.array([0])) + set_mock_value(spectrum.counts_size, 1) + set_mock_value(spectrum.tof_edges, np.array([0])) + set_mock_value(spectrum.tof_edges_size, 1) + + with pytest.raises(ValueError, match="Time-of-flight edges must have size"): + await spectrum.read_spectrum_dataarray() + + +async def test_if_tof_edges_has_no_units_then_read_spec_dataarray_gives_error( + spectrum: DaeSpectra, +): + set_mock_value(spectrum.counts, np.array([0])) + set_mock_value(spectrum.counts_size, 1) + set_mock_value(spectrum.tof_edges, np.array([0, 0])) + set_mock_value(spectrum.tof_edges_size, 2) + spectrum.tof_edges.describe = AsyncMock(return_value={spectrum.tof_edges.name: {"units": None}}) + + with pytest.raises(ValueError, match="Could not determine engineering units"): + await spectrum.read_spectrum_dataarray() From 091cdbd7c2ca667d347eb1f967a816596aae2d95 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Tue, 1 Oct 2024 10:14:34 +0100 Subject: [PATCH 2/2] Sort docs for new format --- doc/devices/dae.md | 265 +++++++++++++++++++++++++++++++++-- doc/simpledae/controllers.md | 46 ------ doc/simpledae/reducers.md | 36 ----- doc/simpledae/simpledae.md | 51 ------- doc/simpledae/waiters.md | 41 ------ 5 files changed, 254 insertions(+), 185 deletions(-) delete mode 100644 doc/simpledae/controllers.md delete mode 100644 doc/simpledae/reducers.md delete mode 100644 doc/simpledae/simpledae.md delete mode 100644 doc/simpledae/waiters.md diff --git a/doc/devices/dae.md b/doc/devices/dae.md index c562621..bff0aa9 100644 --- a/doc/devices/dae.md +++ b/doc/devices/dae.md @@ -1,9 +1,252 @@ -# DAE +# DAE (Data Acquisition Electronics) -## DaeBase (base class) +The `SimpleDae` class is designed to be a configurable DAE object, which will cover the +majority of DAE use-cases within bluesky. + +This class uses several objects to configure its behaviour: +- The `Controller` is responsible for beginning and ending acquisitions. +- The `Waiter` is responsible for waiting for an acquisition to be "complete". +- The `Reducer` is responsible for publishing data from an acquisition that has + just been completed. + +This means that `SimpleDae` is generic enough to cope with most typical DAE use-casess, for +example running using either one DAE run per scan point, or one DAE period per scan point. + +For complex use-cases, particularly those where the DAE may need to start and stop multiple +acquisitions per scan point (e.g. polarization measurements), `SimpleDae` is unlikely to be +suitable; instead the `Dae` class should be subclassed directly to allow for finer control. + +## Example configurations + +### Run-per-point + +```python +from ibex_bluesky_core.devices import get_pv_prefix +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.simpledae.controllers import RunPerPointController +from ibex_bluesky_core.devices.simpledae.waiters import GoodFramesWaiter +from ibex_bluesky_core.devices.simpledae.reducers import GoodFramesNormalizer + + +prefix = get_pv_prefix() +# One DAE run for each scan point, save the runs after each point. +controller = RunPerPointController(save_run=True) +# Wait for 500 good frames on each run +waiter = GoodFramesWaiter(500) +# Sum spectra 1..99 inclusive, then normalize by total good frames +reducer = GoodFramesNormalizer( + prefix=prefix, + detector_spectra=[i for i in range(1, 100)], +) + +dae = SimpleDae( + prefix=prefix, + controller=controller, + waiter=waiter, + reducer=reducer, +) + +# Can give signals user-friendly names if desired +controller.run_number.set_name("run number") +reducer.intensity.set_name("normalized counts") +``` + +### Period-per-point + +```python +from ibex_bluesky_core.devices import get_pv_prefix +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.simpledae.controllers import PeriodPerPointController +from ibex_bluesky_core.devices.simpledae.waiters import PeriodGoodFramesWaiter +from ibex_bluesky_core.devices.simpledae.reducers import PeriodGoodFramesNormalizer + + +prefix = get_pv_prefix() +# One DAE period for each scan point, save the runs after the scan. +controller = PeriodPerPointController(save_run=True) +# Wait for 500 period good frames on each point +waiter = PeriodGoodFramesWaiter(500) +# Sum spectra 1..99 inclusive, then normalize by period good frames +reducer = PeriodGoodFramesNormalizer( + prefix=prefix, + detector_spectra=[i for i in range(1, 100)], +) + +dae = SimpleDae( + prefix=prefix, + controller=controller, + waiter=waiter, + reducer=reducer, +) +``` + +```{note} +You will also need to set up the DAE in advance with enough periods. This can be done from a +plan using `yield from bps.mv(dae.number_of_periods, num_points)` before starting the scan. +``` + +## Mapping to bluesky device model + +### Start of scan (`stage`) + +`SimpleDae` will call `controller.setup()` to allow any pre-scan setup to be done. + +For example, this is where the period-per-point controller object will begin a DAE run. + +### Each scan point (`trigger`) + +`SimpleDae` will call: +- `controller.start_counting()` to begin counting for a single scan point. +- `waiter.wait()` to wait for that acquisition to complete +- `controller.stop_counting()` to finish counting for a single scan point. +- `reducer.reduce_data()` to do any necessary post-processing on + the raw DAE data (e.g. normalization) + +### Each scan point (`read`) + +Any signals marked as "interesting" by the controller, reducer or waiter will be published +in the top-level documents published when `read()`ing the `SimpleDae` object. + +These may correspond to EPICS signals directly from the DAE (e.g. good frames), or may be +soft signals derived at runtime (e.g. normalized intensity). + +This means that the `SimpleDae` object is suitable for use as a detector in most bluesky +plans, and will make an appropriate set of data available in the emitted documents. + +### End of scan (`unstage`) + +`SimpleDae` will call `controller.teardown()` to allow any post-scan teardown to be done. + +For example, this is where the period-per-point controller object will end a DAE run. + +## Controllers + +The `Controller` class is responsible for starting and stopping acquisitions, in a generic +way. + +### RunPerPointController + +This controller starts and stops a new DAE run for each scan point. It can be configured to +either end runs or abort them on completion. + +This controller causes the following signals to be published by `SimpleDae`: + +- `controller.run_number` - The run number into which data was collected. Only published + if runs are being saved. + +### PeriodPerPointController + +This controller begins a single DAE run at the start of a scan, and then counts into a new +DAE period for each individual scan point. + +The DAE must be configured with enough periods in advance. This is possible to do from a +plan as follows: + +```python +import bluesky.plan_stubs as bps +import bluesky.plans as bp +from ibex_bluesky_core.devices.simpledae import SimpleDae +from ibex_bluesky_core.devices.block import BlockRw + + +def plan(): + dae: SimpleDae = ... + block: BlockRw = ... + num_points = 20 + yield from bps.mv(dae.number_of_periods, num_points) + yield from bp.scan([dae], block, 0, 10, num=num_points) +``` + +The controller causes the following signals to be published by `SimpleDae`: + +- `simpledae.period_num` - the period number into which this scan point was counted. + +## Reducers + +A `Reducer` for a `SimpleDae` is responsible for publishing any data derived from the raw +DAE signals. For example, normalizing intensities are implemented as a reducer. + +A reducer may produce any number of reduced signals. + +### GoodFramesNormalizer + +This normalizer sums a set of user-defined detector spectra, and then divides by the number +of good frames. + +Published signals: +- `simpledae.good_frames` - the number of good frames reported by the DAE +- `reducer.det_counts` - summed detector counts for all of the user-provided spectra +- `reducer.intensity` - normalized intensity (`det_counts / good_frames`) + +### PeriodGoodFramesNormalizer + +Equivalent to the `GoodFramesNormalizer` above, but uses good frames only from the current +period. This should be used if a controller which counts into multiple periods is being used. + +Published signals: +- `simpledae.period.good_frames` - the number of good frames reported by the DAE +- `reducer.det_counts` - summed detector counts for all of the user-provided spectra +- `reducer.intensity` - normalized intensity (`det_counts / good_frames`) + +### DetectorMonitorNormalizer + +This normalizer sums a set of user-defined detector spectra, and then divides by the sum +of a set of user-defined monitor spectra. + +Published signals: +- `reducer.det_counts` - summed detector counts for the user-provided detector spectra +- `reducer.mon_counts` - summed monitor counts for the user-provided monitor spectra +- `reducer.intensity` - normalized intensity (`det_counts / mon_counts`) + +## Waiters + +A `waiter` defines an arbitrary strategy for how long to count at each point. + +Some waiters may be very simple, such as waiting for a fixed amount of time or for a number +of good frames or microamp-hours. However, it is also possible to define much more +sophisticated waiters, for example waiting until sufficient statistics have been collected. + +### GoodUahWaiter + +Waits for a user-specified number of microamp-hours. + +Published signals: +- `simpledae.good_uah` - actual good uAh for this run. + +### GoodFramesWaiter + +Waits for a user-specified number of good frames (in total for the entire run) + +Published signals: +- `simpledae.good_frames` - actual good frames for this run. + +### GoodFramesWaiter + +Waits for a user-specified number of good frames (in the current period) + +Published signals: +- `simpledae.period.good_frames` - actual period good frames for this run. + +### MEventsWaiter + +Waits for a user-specified number of millions of events + +Published signals: +- `simpledae.m_events` - actual period good frames for this run. + +### TimeWaiter + +Waits for a user-specified time duration, irrespective of DAE state. + +Does not publish any additional signals. + +--- + +## `Dae` (base class, advanced) `Dae` is the principal class in ibex_bluesky_core which exposes configuration settings -and controls from the ISIS data acquisition electronics (DAE). +and controls from the ISIS data acquisition electronics (DAE). `SimpleDae` derives from +DAE, so all of the signals available on `Dae` are also available on `SimpleDae`. ```{note} The `Dae` class is not intended to be used directly in scans - it is a low-level class @@ -16,11 +259,11 @@ and controls from the ISIS data acquisition electronics (DAE). that it will usually be better to implement functionality at the device level rather than the plan level. - For other use-cases, a user-facing DAE class is likely to be more appropriate to use - as a detector in a scan - this class cannot be used by itself. + For other use-cases, a user-facing DAE class such as `SimpleDae` is likely to be more + appropriate to use as a detector in a scan - this class cannot be used by itself. ``` -## Top-level signals +### Top-level signals Some DAE parameters, particularly metadata parameters, are exposed as simple signals, for example `dae.title` or `dae.good_uah`. @@ -36,13 +279,13 @@ def plan(dae: Dae): yield from bps.mv(dae.title, "new title") ``` -## Period-specific signals +### Period-specific signals For signals which apply to the current period, see `dae.period`, which contains signals such as `dae.period.good_uah` (the number of good uamp-hours collected in the current period). -## Controlling the DAE directly +### Controlling the DAE directly It is possible to control the DAE directly using the signals provided by `dae.controls`. @@ -51,7 +294,7 @@ used by plans directly. For example, beginning a run is possible via `dae.controls.begin_run.trigger()`. -### Advanced options +### Additional begin_run flags Options on `begin` (for example, beginning a run in paused mode) can be specified using the `dae.controls.begin_run_ex` signal. @@ -60,7 +303,7 @@ Unlike the standard `begin_run` signal, this needs to be `set()` rather than sim `trigger()`ed, the value on set is a combination of flags from `BeginRunExBits`. -## DAE Settings +### DAE Settings Many signals on the DAE are only available as composite signals - this includes most DAE configuration parameters which are available under the "experiment setup" tab in IBEX, for @@ -95,7 +338,7 @@ def plan(dae: Dae): ``` -## DAE Spectra +### DAE Spectra Raw spectra are provided by the `DaeSpectra` class. Not all spectra are automatically available on the base DAE object - user classes will define the specific set of spectra which they are diff --git a/doc/simpledae/controllers.md b/doc/simpledae/controllers.md deleted file mode 100644 index f4f723b..0000000 --- a/doc/simpledae/controllers.md +++ /dev/null @@ -1,46 +0,0 @@ -# SimpleDae controllers - -The `Controller` class is responsible for starting and stopping acquisitions, in a generic -way. - -# Predefined controllers - -Some controllers have been predefined in the -`ibex_bluesky_core.devices.simpledae.controllers` module. - -## RunPerPointController - -This controller starts and stops a new DAE run for each scan point. It can be configured to -either end runs or abort them on completion. - -This controller causes the following signals to be published by `SimpleDae`: - -- `controller.run_number` - The run number into which data was collected. Only published - if runs are being saved. - -## PeriodPerPointController - -This controller begins a single DAE run at the start of a scan, and then counts into a new -DAE period for each individual scan point. - -The DAE must be configured with enough periods in advance. This is possible to do from a -plan as follows: - -```python -import bluesky.plan_stubs as bps -import bluesky.plans as bp -from ibex_bluesky_core.devices.simpledae import SimpleDae -from ibex_bluesky_core.devices.block import BlockRw - - -def plan(): - dae: SimpleDae = ... - block: BlockRw = ... - num_points = 20 - yield from bps.mv(dae.number_of_periods, num_points) - yield from bp.scan([dae], block, 0, 10, num=num_points) -``` - -The controller causes the following signals to be published by `SimpleDae`: - -- `simpledae.period_num` - the period number into which this scan point was counted. \ No newline at end of file diff --git a/doc/simpledae/reducers.md b/doc/simpledae/reducers.md deleted file mode 100644 index 393574f..0000000 --- a/doc/simpledae/reducers.md +++ /dev/null @@ -1,36 +0,0 @@ -# Reducers - -A `Reducer` for a `SimpleDae` is responsible for publishing any data derived from the raw -DAE signals. For example, normalizing intensities are implemented as a reducer. - -A reducer may produce any number of reduced signals. - -## GoodFramesNormalizer - -This normalizer sums a set of user-defined detector spectra, and then divides by the number -of good frames. - -Published signals: -- `simpledae.good_frames` - the number of good frames reported by the DAE -- `reducer.det_counts` - summed detector counts for all of the user-provided spectra -- `reducer.intensity` - normalized intensity (`det_counts / good_frames`) - -## PeriodGoodFramesNormalizer - -Equivalent to the `GoodFramesNormalizer` above, but uses good frames only from the current -period. This should be used if a controller which counts into multiple periods is being used. - -Published signals: -- `simpledae.period.good_frames` - the number of good frames reported by the DAE -- `reducer.det_counts` - summed detector counts for all of the user-provided spectra -- `reducer.intensity` - normalized intensity (`det_counts / good_frames`) - -## DetectorMonitorNormalizer - -This normalizer sums a set of user-defined detector spectra, and then divides by the sum -of a set of user-defined monitor spectra. - -Published signals: -- `reducer.det_counts` - summed detector counts for the user-provided detector spectra -- `reducer.mon_counts` - summed monitor counts for the user-provided monitor spectra -- `reducer.intensity` - normalized intensity (`det_counts / mon_counts`) diff --git a/doc/simpledae/simpledae.md b/doc/simpledae/simpledae.md deleted file mode 100644 index 0c80876..0000000 --- a/doc/simpledae/simpledae.md +++ /dev/null @@ -1,51 +0,0 @@ -# Simple Dae - -The `SimpleDae` class is designed to be a configurable DAE object, which will cover the -majority of DAE use-cases within bluesky. - -This class uses several objects to configure its behaviour: -- The `Controller` is responsible for beginning and ending acquisitions. -- The `Waiter` is responsible for waiting for an acquisition to be "complete". -- The `Reducer` is responsible for publishing data from an acquisition that has - just been completed. - -This means that `SimpleDae` is generic enough to cope with most typical DAE use-casess, for -example running using either one DAE run per scan point, or one DAE period per scan point. - -For complex use-cases, particularly those where the DAE may need to start and stop multiple -acquisitions per scan point (e.g. polarization measurements), `SimpleDae` is unlikely to be -suitable; instead the `Dae` class should be subclassed directly to allow for finer control. - -## Mapping to bluesky device model - -### Start of scan (`stage`) - -`SimpleDae` will call `controller.setup()` to allow any pre-scan setup to be done. - -For example, this is where the period-per-point controller object will begin a DAE run. - -### Each scan point (`trigger`) - -`SimpleDae` will call: -- `controller.start_counting()` to begin counting for a single scan point. -- `waiter.wait()` to wait for that acquisition to complete -- `controller.stop_counting()` to finish counting for a single scan point. -- `reducer.reduce_data()` to do any necessary post-processing on - the raw DAE data (e.g. normalization) - -### Each scan point (`read`) - -Any signals marked as "interesting" by the controller, reducer or waiter will be published -in the top-level documents published when `read()`ing the `SimpleDae` object. - -These may correspond to EPICS signals directly from the DAE (e.g. good frames), or may be -soft signals derived at runtime (e.g. normalized intensity). - -This means that the `SimpleDae` object is suitable for use as a detector in most bluesky -plans, and will make an appropriate set of data available in the emitted documents. - -### End of scan (`unstage`) - -`SimpleDae` will call `controller.teardown()` to allow any post-scan teardown to be done. - -For example, this is where the period-per-point controller object will end a DAE run. \ No newline at end of file diff --git a/doc/simpledae/waiters.md b/doc/simpledae/waiters.md deleted file mode 100644 index 949a170..0000000 --- a/doc/simpledae/waiters.md +++ /dev/null @@ -1,41 +0,0 @@ -# Waiters - -A `waiter` defines an arbitrary strategy for how long to count at each point. - -Some waiters may be very simple, such as waiting for a fixed amount of time or for a number -of good frames or microamp-hours. However, it is also possible to define much more -sophisticated waiters, for example waiting until sufficient statistics have been collected. - -## GoodUahWaiter - -Waits for a user-specified number of microamp-hours. - -Published signals: -- `simpledae.good_uah` - actual good uAh for this run. - -## GoodFramesWaiter - -Waits for a user-specified number of good frames (in total for the entire run) - -Published signals: -- `simpledae.good_frames` - actual good frames for this run. - -## GoodFramesWaiter - -Waits for a user-specified number of good frames (in the current period) - -Published signals: -- `simpledae.period.good_frames` - actual period good frames for this run. - -## MEventsWaiter - -Waits for a user-specified number of millions of events - -Published signals: -- `simpledae.m_events` - actual period good frames for this run. - -## TimeWaiter - -Waits for a user-specified time duration, irrespective of DAE state. - -Does not publish any additional signals.