Skip to content

Commit 39c4bc5

Browse files
committed
Add configurable SimpleDae class
1 parent 9201b04 commit 39c4bc5

File tree

20 files changed

+1310
-23
lines changed

20 files changed

+1310
-23
lines changed

doc/simpledae/controllers.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# SimpleDae controllers
2+
3+
The `Controller` class is responsible for starting and stopping acquisitions, in a generic
4+
way.
5+
6+
# Predefined controllers
7+
8+
Some controllers have been predefined in the
9+
`ibex_bluesky_core.devices.simpledae.controllers` module.
10+
11+
## RunPerPointController
12+
13+
This controller starts and stops a new DAE run for each scan point. It can be configured to
14+
either end runs or abort them on completion.
15+
16+
This controller causes the following signals to be published by `SimpleDae`:
17+
18+
- `controller.run_number` - The run number into which data was collected. Only published
19+
if runs are being saved.
20+
21+
## PeriodPerPointController
22+
23+
This controller begins a single DAE run at the start of a scan, and then counts into a new
24+
DAE period for each individual scan point.
25+
26+
The DAE must be configured with enough periods in advance. This is possible to do from a
27+
plan as follows:
28+
29+
```python
30+
import bluesky.plan_stubs as bps
31+
import bluesky.plans as bp
32+
from ibex_bluesky_core.devices.simpledae import SimpleDae
33+
from ibex_bluesky_core.devices.block import BlockRw
34+
35+
36+
def plan():
37+
dae: SimpleDae = ...
38+
block: BlockRw = ...
39+
num_points = 20
40+
yield from bps.mv(dae.number_of_periods, num_points)
41+
yield from bp.scan([dae], block, 0, 10, num=num_points)
42+
```
43+
44+
The controller causes the following signals to be published by `SimpleDae`:
45+
46+
- `simpledae.period_num` - the period number into which this scan point was counted.

doc/simpledae/reducers.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Reducers
2+
3+
A `Reducer` for a `SimpleDae` is responsible for publishing any data derived from the raw
4+
DAE signals. For example, normalizing intensities are implemented as a reducer.
5+
6+
A reducer may produce any number of reduced signals.
7+
8+
## GoodFramesNormalizer
9+
10+
This normalizer sums a set of user-defined detector spectra, and then divides by the number
11+
of good frames.
12+
13+
Published signals:
14+
- `simpledae.good_frames` - the number of good frames reported by the DAE
15+
- `reducer.det_counts` - summed detector counts for all of the user-provided spectra
16+
- `reducer.intensity` - normalized intensity (`det_counts / good_frames`)
17+
18+
## PeriodGoodFramesNormalizer
19+
20+
Equivalent to the `GoodFramesNormalizer` above, but uses good frames only from the current
21+
period. This should be used if a controller which counts into multiple periods is being used.
22+
23+
Published signals:
24+
- `simpledae.period.good_frames` - the number of good frames reported by the DAE
25+
- `reducer.det_counts` - summed detector counts for all of the user-provided spectra
26+
- `reducer.intensity` - normalized intensity (`det_counts / good_frames`)
27+
28+
## DetectorMonitorNormalizer
29+
30+
This normalizer sums a set of user-defined detector spectra, and then divides by the sum
31+
of a set of user-defined monitor spectra.
32+
33+
Published signals:
34+
- `reducer.det_counts` - summed detector counts for the user-provided detector spectra
35+
- `reducer.mon_counts` - summed monitor counts for the user-provided monitor spectra
36+
- `reducer.intensity` - normalized intensity (`det_counts / mon_counts`)

doc/simpledae/simpledae.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Simple Dae
2+
3+
The `SimpleDae` class is designed to be a configurable DAE object, which will cover the
4+
majority of DAE use-cases within bluesky.
5+
6+
This class uses several objects to configure its behaviour:
7+
- The `Controller` is responsible for beginning and ending acquisitions.
8+
- The `Waiter` is responsible for waiting for an acquisition to be "complete".
9+
- The `Reducer` is responsible for publishing data from an acquisition that has
10+
just been completed.
11+
12+
This means that `SimpleDae` is generic enough to cope with most typical DAE use-casess, for
13+
example running using either one DAE run per scan point, or one DAE period per scan point.
14+
15+
For complex use-cases, particularly those where the DAE may need to start and stop multiple
16+
acquisitions per scan point (e.g. polarization measurements), `SimpleDae` is unlikely to be
17+
suitable; instead the `Dae` class should be subclassed directly to allow for finer control.
18+
19+
## Mapping to bluesky device model
20+
21+
### Start of scan (`stage`)
22+
23+
`SimpleDae` will call `controller.setup()` to allow any pre-scan setup to be done.
24+
25+
For example, this is where the period-per-point controller object will begin a DAE run.
26+
27+
### Each scan point (`trigger`)
28+
29+
`SimpleDae` will call:
30+
- `controller.start_counting()` to begin counting for a single scan point.
31+
- `waiter.wait()` to wait for that acquisition to complete
32+
- `controller.stop_counting()` to finish counting for a single scan point.
33+
- `reducer.reduce_data()` to do any necessary post-processing on
34+
the raw DAE data (e.g. normalization)
35+
36+
### Each scan point (`read`)
37+
38+
Any signals marked as "interesting" by the controller, reducer or waiter will be published
39+
in the top-level documents published when `read()`ing the `SimpleDae` object.
40+
41+
These may correspond to EPICS signals directly from the DAE (e.g. good frames), or may be
42+
soft signals derived at runtime (e.g. normalized intensity).
43+
44+
This means that the `SimpleDae` object is suitable for use as a detector in most bluesky
45+
plans, and will make an appropriate set of data available in the emitted documents.
46+
47+
### End of scan (`unstage`)
48+
49+
`SimpleDae` will call `controller.teardown()` to allow any post-scan teardown to be done.
50+
51+
For example, this is where the period-per-point controller object will end a DAE run.

doc/simpledae/waiters.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Waiters
2+
3+
A `waiter` defines an arbitrary strategy for how long to count at each point.
4+
5+
Some waiters may be very simple, such as waiting for a fixed amount of time or for a number
6+
of good frames or microamp-hours. However, it is also possible to define much more
7+
sophisticated waiters, for example waiting until sufficient statistics have been collected.
8+
9+
## GoodUahWaiter
10+
11+
Waits for a user-specified number of microamp-hours.
12+
13+
Published signals:
14+
- `simpledae.good_uah` - actual good uAh for this run.
15+
16+
## GoodFramesWaiter
17+
18+
Waits for a user-specified number of good frames (in total for the entire run)
19+
20+
Published signals:
21+
- `simpledae.good_frames` - actual good frames for this run.
22+
23+
## GoodFramesWaiter
24+
25+
Waits for a user-specified number of good frames (in the current period)
26+
27+
Published signals:
28+
- `simpledae.period.good_frames` - actual period good frames for this run.
29+
30+
## MEventsWaiter
31+
32+
Waits for a user-specified number of millions of events
33+
34+
Published signals:
35+
- `simpledae.m_events` - actual period good frames for this run.
36+
37+
## TimeWaiter
38+
39+
Waits for a user-specified time duration, irrespective of DAE state.
40+
41+
Does not publish any additional signals.

pyproject.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ dependencies = [
4444
"bluesky",
4545
"ophyd-async[ca]",
4646
"matplotlib",
47-
"numpy"
47+
"numpy",
48+
"scipp",
4849
]
4950

5051
[project.optional-dependencies]
@@ -76,6 +77,12 @@ omit = [
7677

7778
[tool.coverage.report]
7879
fail_under = 100
80+
exclude_lines = [
81+
"pragma: no cover",
82+
"if TYPE_CHECKING:",
83+
"if typing.TYPE_CHECKING:",
84+
"@abstractmethod",
85+
]
7986

8087
[tool.coverage.html]
8188
directory = "coverage_html_report"

src/ibex_bluesky_core/demo_plan.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,25 @@
33
from typing import Generator
44

55
import bluesky.plan_stubs as bps
6+
import bluesky.plans as bp
67
import matplotlib
78
import matplotlib.pyplot as plt
89
from bluesky.callbacks import LiveTable
9-
from bluesky.preprocessors import run_decorator, subs_decorator
10+
from bluesky.preprocessors import subs_decorator
1011
from bluesky.utils import Msg
1112
from ophyd_async.plan_stubs import ensure_connected
1213

1314
from ibex_bluesky_core.callbacks.plotting import LivePlot
1415
from ibex_bluesky_core.devices import get_pv_prefix
1516
from ibex_bluesky_core.devices.block import block_rw_rbv
16-
from ibex_bluesky_core.devices.dae.dae import Dae
17+
from ibex_bluesky_core.devices.simpledae import SimpleDae
18+
from ibex_bluesky_core.devices.simpledae.controllers import (
19+
RunPerPointController,
20+
)
21+
from ibex_bluesky_core.devices.simpledae.reducers import (
22+
GoodFramesNormalizer,
23+
)
24+
from ibex_bluesky_core.devices.simpledae.waiters import GoodFramesWaiter
1725
from ibex_bluesky_core.run_engine import get_run_engine
1826

1927
__all__ = ["demo_plan"]
@@ -23,27 +31,45 @@ def demo_plan() -> Generator[Msg, None, None]:
2331
"""Demonstration plan which moves a block and reads the DAE."""
2432
prefix = get_pv_prefix()
2533
block = block_rw_rbv(float, "mot")
26-
dae = Dae(prefix)
34+
35+
controller = RunPerPointController(save_run=True)
36+
waiter = GoodFramesWaiter(500)
37+
reducer = GoodFramesNormalizer(
38+
prefix=prefix,
39+
detector_spectra=[i for i in range(1, 100)],
40+
)
41+
42+
dae = SimpleDae(
43+
prefix=prefix,
44+
controller=controller,
45+
waiter=waiter,
46+
reducer=reducer,
47+
)
48+
49+
# Demo giving some signals more user-friendly names
50+
controller.run_number.set_name("run number")
51+
reducer.intensity.set_name("normalized counts")
2752

2853
yield from ensure_connected(block, dae, force_reconnect=True)
2954

3055
@subs_decorator(
3156
[
32-
LivePlot(y="DAE-good_uah", x=block.name, marker="x", linestyle="none"),
33-
LiveTable([block.name, "DAE-good_uah"]),
57+
LivePlot(y=reducer.intensity.name, x=block.name, marker="x", linestyle="none"),
58+
LiveTable(
59+
[
60+
block.name,
61+
controller.run_number.name,
62+
reducer.intensity.name,
63+
reducer.det_counts.name,
64+
dae.good_frames.name,
65+
]
66+
),
3467
]
3568
)
36-
@run_decorator(md={})
3769
def _inner() -> Generator[Msg, None, None]:
38-
# Acquisition showing arbitrary DAE control to support complex use-cases.
39-
yield from bps.abs_set(block, 2.0, wait=True)
40-
yield from bps.trigger(dae.controls.begin_run, wait=True)
41-
yield from bps.sleep(5) # ... some complicated logic ...
42-
yield from bps.trigger(dae.controls.end_run, wait=True)
43-
yield from bps.create() # Create a bundle of readings
44-
yield from bps.read(block)
45-
yield from bps.read(dae.good_uah)
46-
yield from bps.save()
70+
num_points = 20
71+
yield from bps.mv(dae.number_of_periods, num_points)
72+
yield from bp.scan([dae], block, 0, 10, num=num_points)
4773

4874
yield from _inner()
4975

src/ibex_bluesky_core/devices/dae/dae.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,16 @@ def __init__(self, prefix: str, name: str = "DAE") -> None:
5656
self.good_frames: SignalR[int] = epics_signal_r(int, f"{dae_prefix}GOODFRAMES")
5757
self.raw_frames: SignalR[int] = epics_signal_r(int, f"{dae_prefix}RAWFRAMES")
5858
self.total_counts: SignalR[int] = epics_signal_r(int, f"{dae_prefix}TOTALCOUNTS")
59-
self.run_number: SignalR[int] = epics_signal_r(int, f"{dae_prefix}IRUNNUMBER")
60-
self.run_number_str: SignalR[str] = epics_signal_r(str, f"{dae_prefix}RUNNUMBER")
59+
60+
# Beware that this increments just after a run is ended. So it is generally not correct to
61+
# read this just after a DAE run has been ended().
62+
self.current_or_next_run_number: SignalR[int] = epics_signal_r(
63+
int, f"{dae_prefix}IRUNNUMBER"
64+
)
65+
self.current_or_next_run_number_str: SignalR[str] = epics_signal_r(
66+
str, f"{dae_prefix}RUNNUMBER"
67+
)
68+
6169
self.cycle_number: SignalR[str] = epics_signal_r(str, f"{dae_prefix}ISISCYCLE")
6270
self.inst_name: SignalR[str] = epics_signal_r(str, f"{dae_prefix}INSTNAME")
6371
self.run_start_time: SignalR[str] = epics_signal_r(str, f"{dae_prefix}STARTTIME")

src/ibex_bluesky_core/devices/dae/dae_controls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,4 @@ def __init__(self, dae_prefix: str, name: str = "") -> None:
4343
@AsyncStatus.wrap
4444
async def set(self, value: BeginRunExBits) -> None:
4545
"""Start a run with the specified bits - See BeginRunExBits."""
46-
await self._raw_begin_run_ex.set(value, wait=True)
46+
await self._raw_begin_run_ex.set(value, wait=True, timeout=None)

src/ibex_bluesky_core/devices/dae/dae_spectra.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import asyncio
44

5+
import scipp as sc
6+
from event_model.documents.event_descriptor import DataKey
57
from numpy import float32
68
from numpy.typing import NDArray
79
from ophyd_async.core import SignalR, StandardReadable
@@ -22,6 +24,15 @@ def __init__(self, dae_prefix: str, *, spectra: int, period: int, name: str = ""
2224
int, f"{dae_prefix}SPEC:{period}:{spectra}:X.NORD"
2325
)
2426

27+
# x-axis; time-of-flight.
28+
# These are bin-edge coordinates, with a size one more than the corresponding data.
29+
self.tof_edges: SignalR[NDArray[float32]] = epics_signal_r(
30+
NDArray[float32], f"{dae_prefix}SPEC:{period}:{spectra}:XE"
31+
)
32+
self.tof_edges_size: SignalR[int] = epics_signal_r(
33+
int, f"{dae_prefix}SPEC:{period}:{spectra}:XE.NORD"
34+
)
35+
2536
# y-axis; counts / tof
2637
# This is the number of counts in a ToF bin, normalized by the width of
2738
# that ToF bin.
@@ -57,10 +68,46 @@ async def read_tof(self) -> NDArray[float32]:
5768
"""Read a correctly-sized time-of-flight (x) array representing bin centres."""
5869
return await self._read_sized(self.tof, self.tof_size)
5970

71+
async def read_tof_edges(self) -> NDArray[float32]:
72+
"""Read a correctly-sized time-of-flight (x) array representing bin edges."""
73+
return await self._read_sized(self.tof_edges, self.tof_edges_size)
74+
6075
async def read_counts(self) -> NDArray[float32]:
6176
"""Read a correctly-sized array of counts."""
6277
return await self._read_sized(self.counts, self.counts_size)
6378

6479
async def read_counts_per_time(self) -> NDArray[float32]:
6580
"""Read a correctly-sized array of counts divided by bin width."""
6681
return await self._read_sized(self.counts_per_time, self.counts_per_time_size)
82+
83+
async def read_spectrum_dataarray(self) -> sc.DataArray:
84+
"""Get a scipp DataArray containing the current data from this spectrum.
85+
86+
Variances are set to the counts - i.e. the standard deviation is sqrt(N), which is typical
87+
for counts data.
88+
89+
Data is returned along dimension "tof", which has bin-edge coordinates and units set from
90+
the units of the underlying PVs.
91+
"""
92+
tof_edges, tof_edges_descriptor, counts = await asyncio.gather(
93+
self.read_tof_edges(),
94+
self.tof_edges.describe(),
95+
self.read_counts(),
96+
)
97+
98+
if tof_edges.size != counts.size + 1:
99+
raise ValueError(
100+
"Time-of-flight edges must have size one more than the data. "
101+
"You may be trying to read too many time channels. "
102+
f"Edges size was {tof_edges.size}, counts size was {counts.size}."
103+
)
104+
105+
datakey: DataKey = tof_edges_descriptor[self.tof_edges.name]
106+
unit = datakey.get("units", None)
107+
if unit is None:
108+
raise ValueError("Could not determine engineering units of tof edges.")
109+
110+
return sc.DataArray(
111+
data=sc.Variable(dims=["tof"], values=counts, variances=counts, unit=sc.units.counts),
112+
coords={"tof": sc.array(dims=["tof"], values=tof_edges, unit=sc.Unit(unit))},
113+
)

0 commit comments

Comments
 (0)