Skip to content

Commit b8fbe29

Browse files
authoredNov 16, 2023
Merge pull request #886 from BCDA-APS/884-signal-stats
new lineup2() plan can be used with queueserver, console, and notebooks
2 parents eb66d4d + 305d939 commit b8fbe29

File tree

7 files changed

+405
-33
lines changed

7 files changed

+405
-33
lines changed
 

‎CHANGES.rst

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ New Features
3434
------------
3535

3636
* Add ophyd device support for DG-645 digital delay/pulse generator.
37+
* New lineup2() plan can be used in console, notebooks, and queueserver.
3738

3839
1.6.17
3940
******

‎apstools/callbacks/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from .nexus_writer import NEXUS_RELEASE
66
from .nexus_writer import NXWriter
77
from .nexus_writer import NXWriterAPS
8+
from .scan_signal_statistics import factor_fwhm
9+
from .scan_signal_statistics import SignalStatsCallback
810
from .spec_file_writer import SCAN_ID_RESET_VALUE
911
from .spec_file_writer import SPEC_TIME_FORMAT
1012
from .spec_file_writer import SpecWriterCallback
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""
2+
Collect statistics on the signals used in 1-D scans.
3+
====================================================
4+
5+
.. autosummary::
6+
7+
~factor_fwhm
8+
~SignalStatsCallback
9+
"""
10+
11+
__all__ = """
12+
factor_fwhm
13+
SignalStatsCallback
14+
""".split()
15+
16+
import math
17+
import logging
18+
19+
import pyRestTable
20+
import pysumreg
21+
22+
logger = logging.getLogger(__name__)
23+
logger.info(__file__)
24+
25+
factor_fwhm = 2 * math.sqrt(2 * math.log(2))
26+
r"""
27+
FWHM :math:`=2\sqrt{2\ln{2}}\cdot\sigma_c`
28+
29+
see: https://statproofbook.github.io/P/norm-fwhm.html
30+
"""
31+
32+
33+
class SignalStatsCallback:
34+
"""
35+
Callback: Collect peak (& other) statistics during a scan.
36+
37+
.. caution:: This is an early draft and is subject to change!
38+
39+
Subscribe the ``receiver()`` method. Use with step scan plans such as
40+
``bp.scan()`` and ``bp.rel_scan()``.
41+
42+
.. caution:: It is recommended to subscribe this callback to specific plans. It
43+
should not be run with just any plan (it could easily raise exceptions).
44+
45+
.. rubric:: Basic example
46+
.. code-block::
47+
:linenos:
48+
49+
from bluesky import plans as bp
50+
from bluesky import preprocessors as bpp
51+
52+
signal_stats = SignalStats()
53+
54+
def my_plan(detectors, mover, rel_start, rel_stop, points, md={}):
55+
56+
@bpp.subs_decorator(signal_stats.receiver) # collect the data
57+
def _inner():
58+
yield from bp.rel_scan(detectors, mover, rel_start, rel_end, points, md)
59+
60+
yield from _inner() # run the scan
61+
signal_stats.report() # print the statistics
62+
63+
.. rubric:: Public API
64+
.. autosummary::
65+
66+
~receiver
67+
~report
68+
~data_stream
69+
~stop_report
70+
71+
.. rubric:: Internal API
72+
.. autosummary::
73+
74+
~clear
75+
~descriptor
76+
~event
77+
~start
78+
~stop
79+
~_scanning
80+
~_registers
81+
"""
82+
83+
data_stream: str = "primary"
84+
"""RunEngine document with signals to to watch."""
85+
86+
stop_report: bool = True
87+
"""If ``True`` (default), call the ``report()`` method when a ``stop`` document is received."""
88+
89+
_scanning: bool = False
90+
"""Is a run *in progress*?"""
91+
92+
_registers: dict = {}
93+
"""Dictionary (keyed on Signal name) of ``SummationRegister()`` objects."""
94+
95+
# TODO: What happens when the run is paused?
96+
97+
def __repr__(self):
98+
if "_motor" not in dir(self): # not initialized
99+
self.clear()
100+
args = f"motor={self._motor!r}, detectors={self._detectors!r}"
101+
return f"{self.__class__.__name__}({args})"
102+
103+
def clear(self):
104+
"""Clear the internal memory for the next run."""
105+
self._scanning = False
106+
self._detectors = []
107+
self._motor = ""
108+
self._registers = {}
109+
self._descriptor_uid = None
110+
self._x_name = None
111+
self._y_names = []
112+
113+
def descriptor(self, doc):
114+
"""Receives 'descriptor' documents from the RunEngine."""
115+
if not self._scanning:
116+
return
117+
if doc["name"] != self.data_stream:
118+
return
119+
120+
# Remember now, to match with later events.
121+
self._descriptor_uid = doc["uid"]
122+
123+
# Pick the first motor signal.
124+
self._x_name = doc["hints"][self._motor]["fields"][0]
125+
# Get the signals for each detector object.s
126+
for d in self._detectors:
127+
self._y_names += doc["hints"][d]["fields"]
128+
129+
# Keep statistics for each of the Y signals (vs. the one X signal).
130+
self._registers = {y: pysumreg.SummationRegisters() for y in self._y_names}
131+
132+
def event(self, doc):
133+
"""Receives 'event' documents from the RunEngine."""
134+
if not self._scanning:
135+
return
136+
if doc["descriptor"] != self._descriptor_uid:
137+
return
138+
139+
# Collect the data for the signals.
140+
x = doc["data"][self._x_name]
141+
for yname in self._y_names:
142+
self._registers[yname].add(x, doc["data"][yname])
143+
144+
def receiver(self, key, document):
145+
"""Client method used to subscribe to the RunEngine."""
146+
handlers = "start stop descriptor event".split()
147+
if key in handlers:
148+
getattr(self, key)(document)
149+
else:
150+
logger.debug("%s: unhandled document type: %s", self.__class__.__name__, key)
151+
152+
def report(self):
153+
"""Print a table with the collected statistics for each signal."""
154+
if len(self._registers) == 0:
155+
return
156+
keys = "n centroid sigma x_at_max_y max_y min_y mean_y stddev_y".split()
157+
table = pyRestTable.Table()
158+
table.labels = ["detector"] + keys
159+
for yname, stats in self._registers.items():
160+
row = [yname]
161+
for k in keys:
162+
try:
163+
v = getattr(stats, k)
164+
except ZeroDivisionError:
165+
v = 0
166+
row.append(v)
167+
table.addRow(row)
168+
print(f"Motor: {self._x_name}")
169+
print(table)
170+
171+
def start(self, doc):
172+
"""Receives 'start' documents from the RunEngine."""
173+
self.clear()
174+
self._scanning = True
175+
# These command arguments might each have many signals.
176+
self._detectors = doc["detectors"]
177+
self._motor = doc["motors"][0] # just keep the first one
178+
179+
def stop(self, doc):
180+
"""Receives 'stop' documents from the RunEngine."""
181+
if not self._scanning:
182+
return
183+
self._scanning = False
184+
if self.stop_report:
185+
self.report()

‎apstools/plans/alignment.py

+184-33
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
.. autosummary::
66
77
~lineup
8+
~lineup2
89
~tune_axes
910
~TuneAxis
1011
~TuneResults
@@ -42,68 +43,59 @@ def lineup(
4243
# fmt: on
4344
):
4445
"""
45-
Lineup and center a given axis, relative to current position.
46+
(use ``lineup2()`` now) Lineup and center a given axis, relative to current position.
4647
4748
If first run identifies a peak, makes a second run to fine tune the result.
4849
50+
..caution:: ``lineup()`` does not work in the queueserver. Use
51+
:func:`~apstools.plans.alignment.lineup2()` instead.
52+
4953
.. index:: Bluesky Plan; lineup
5054
5155
PARAMETERS
5256
5357
detectors
54-
*[object]* or *object* :
55-
Instance(s) of ophyd.Signal (or subclass such as ophyd.scaler.ScalerChannel)
56-
dependent measurement to be maximized. If a list, the first signal in the
57-
list will be used.
58+
*[object]* or *object* : Instance(s) of ophyd.Signal (or subclass such
59+
as ophyd.scaler.ScalerChannel) dependent measurement to be maximized.
60+
If a list, the first signal in the list will be used.
5861
5962
axis
60-
movable *object* :
61-
instance of ophyd.Signal (or subclass such as EpicsMotor)
62-
independent axis to use for alignment
63+
movable *object* : instance of ophyd.Signal (or subclass such as
64+
EpicsMotor) independent axis to use for alignment
6365
6466
minus
65-
*float* :
66-
first point of scan at this offset from starting position
67+
*float* : first point of scan at this offset from starting position
6768
6869
plus
69-
*float* :
70-
last point of scan at this offset from starting position
70+
*float* : last point of scan at this offset from starting position
7171
7272
npts
73-
*int* :
74-
number of data points in the scan
73+
*int* : number of data points in the scan
7574
7675
time_s
77-
*float* :
78-
Count time per step (if detectors[0] is ScalerChannel object),
79-
other object types not yet supported.
80-
(default: 0.1)
76+
*float* : Count time per step (if detectors[0] is ScalerChannel object),
77+
other object types not yet supported. (default: 0.1)
8178
8279
peak_factor
83-
*float* :
84-
peak maximum must be greater than ``peak_factor*minimum``
80+
*float* : peak maximum must be greater than ``peak_factor*minimum``
8581
(default: 4)
8682
8783
width_factor
88-
*float* :
89-
fwhm must be less than ``width_factor*plot_range``
90-
(default: 0.8)
84+
*float* : fwhm must be less than ``width_factor*plot_range`` (default:
85+
0.8)
9186
9287
feature
93-
*str* :
94-
One of the parameters returned by BestEffortCallback peak stats.
95-
(``cen``, ``com``, ``max``, ``min``)
96-
(default: ``cen``)
88+
*str* : One of the parameters returned by BestEffortCallback peak stats.
89+
(``cen``, ``com``, ``max``, ``min``) (default: ``cen``)
9790
9891
rescan
99-
*bool* :
100-
If first scan indicates a peak, should a second scan refine the result?
101-
(default: ``True``)
92+
*bool* : If first scan indicates a peak, should a second scan refine the
93+
result? (default: ``True``)
10294
10395
bec
104-
*object* :
105-
Instance of ``bluesky.callbacks.best_effort.BestEffortCallback``.
106-
(default: ``None``, meaning look for it from global namespace)
96+
*object* : Instance of
97+
``bluesky.callbacks.best_effort.BestEffortCallback``. (default:
98+
``None``, meaning look for it from global namespace)
10799
108100
EXAMPLE::
109101
@@ -221,6 +213,165 @@ def peak_analysis():
221213
scaler.stage_sigs = old_sigs
222214

223215

216+
def lineup2(
217+
# fmt: off
218+
detectors, mover, rel_start, rel_end, points,
219+
peak_factor=2.5, width_factor=0.8,
220+
feature="centroid",
221+
nscans=2,
222+
signal_stats=None,
223+
md={},
224+
# fmt: on
225+
):
226+
"""
227+
Lineup and center a given mover, relative to current position.
228+
229+
This plan can be used in the queueserver, Jupyter notebooks, and IPython
230+
consoles. It does not require the bluesky BestEffortCallback. Instead, it
231+
uses *PySumReg* [#pysumreg]_ to compute statistics for each signal in a 1-D
232+
scan.
233+
234+
New in release 1.6.18
235+
236+
.. caution:: This is an early draft and is subject to change!
237+
238+
.. index:: Bluesky Plan; lineup2; lineup
239+
240+
PARAMETERS
241+
242+
detectors *Readable* or [*Readable*]:
243+
Detector object or list of detector objects (each is a Device or
244+
Signal). If a list, the first Signal will be used for alignment.
245+
246+
mover *Movable*:
247+
Mover object, such as motor or other positioner.
248+
249+
rel_start *float*:
250+
Starting point for the scan, relative to the current mover position.
251+
252+
rel_end *float*:
253+
Ending point for the scan, relative to the current mover position.
254+
255+
points *int*:
256+
Number of points in the scan.
257+
258+
peak_factor *float* :
259+
peak maximum must be greater than ``peak_factor*minimum`` (default: 2.5)
260+
261+
width_factor *float* :
262+
fwhm must be less than ``width_factor*plot_range`` (default: 0.8)
263+
264+
feature *str*:
265+
Use this statistical measure (default: centroid) to set the mover
266+
position after a peak has been found. Must be one of these values:
267+
268+
========== ====================
269+
feature description
270+
========== ====================
271+
centroid center of mass
272+
x_at_max_y x location of y maximum
273+
x_at_min_y x location of y minimum
274+
========== ====================
275+
276+
Statistical analysis provided by *PySumReg*. [#pysumreg]_
277+
278+
.. [#pysumreg] https://prjemian.github.io/pysumreg/latest/
279+
280+
nscans *int*:
281+
Number of scans. (default: 2) Scanning will stop if any scan cannot
282+
find a peak.
283+
284+
signal_stats *object*:
285+
Caller could provide an object of
286+
:class:`~apstools.callbacks.scan_signal_statistics.SignalStatsCallback`.
287+
If ``None``, this function will search for the ``signal_stats`` signal
288+
in the global namespace. If not still found, a new one will be created
289+
for the brief lifetime of this function.
290+
291+
md *dict*:
292+
User-supplied metadata for this scan.
293+
"""
294+
from ..callbacks import SignalStatsCallback
295+
from ..callbacks import factor_fwhm
296+
297+
if not isinstance(detectors, (tuple, list)):
298+
detectors = [detectors]
299+
300+
_md = dict(purpose="alignment")
301+
_md.update(md or {})
302+
303+
if signal_stats is None:
304+
signal_stats = utils.ipython_shell_namespace().get("signal_stats")
305+
if signal_stats is None:
306+
signal_stats = SignalStatsCallback()
307+
signal_stats.stop_report = True
308+
else:
309+
signal_stats.stop_report = False # Turn this automation off.
310+
311+
# Allow for feature to be defined using a name from PeakStats.
312+
xref_PeakStats = {
313+
"com": "centroid",
314+
"cen": "x_at_max_y",
315+
"max": "x_at_max_y",
316+
"min": "x_at_min_y",
317+
}
318+
# translate from PeakStats feature to SignalStats
319+
feature = xref_PeakStats.get(feature, feature)
320+
321+
def get_x_by_feature():
322+
"""Return the X value of the specified ``feature``."""
323+
stats = principal_signal_stats()
324+
if strong_peak(stats) and not too_wide(stats):
325+
return getattr(stats, feature)
326+
327+
def principal_signal_stats() -> str:
328+
"""Return the name of the first detector Signal."""
329+
return signal_stats._registers[signal_stats._y_names[0]]
330+
331+
def strong_peak(stats) -> bool:
332+
"""Determine if the peak is strong."""
333+
try:
334+
value = (stats.max_y - stats.min_y) / stats.sigma
335+
return value > peak_factor
336+
except ZeroDivisionError: # not enough samples
337+
try:
338+
value = abs(stats.max_y / stats.min_y)
339+
return value > peak_factor
340+
except ZeroDivisionError:
341+
return False
342+
343+
def too_wide(stats):
344+
"""Does the measured peak width fill the full range of X?"""
345+
try:
346+
x_range = stats.max_x - stats.min_x
347+
fwhm = stats.sigma * factor_fwhm
348+
return fwhm > width_factor * x_range
349+
except ZeroDivisionError: # not enough samples
350+
return True
351+
352+
@bpp.subs_decorator(signal_stats.receiver)
353+
def _inner():
354+
"""Run the scan, collecting statistics at each step."""
355+
# TODO: save signal stats into separate stream
356+
yield from bp.rel_scan(detectors, mover, rel_start, rel_end, points, md=_md)
357+
358+
while nscans > 0: # allow for repeated scans
359+
yield from _inner() # Run the scan.
360+
nscans -= 1
361+
362+
target = get_x_by_feature()
363+
if target is None:
364+
nscans = 0 # Nothing found, no point scanning again.
365+
else:
366+
yield from bps.mv(mover, target) # Move to the feature position.
367+
logger.info("Moved %s to %s: %f", mover.name, feature, target)
368+
369+
if nscans > 0:
370+
# move the end points for the next scan
371+
rel_end = principal_signal_stats().sigma * factor_fwhm
372+
rel_start = -rel_end
373+
374+
224375
class TuneAxis(object):
225376
"""
226377
tune an axis with a signal

‎apstools/plans/tests/test_alignment.py

+27
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,33 @@ def test_lineup(signals, mover, start, finish, npts, feature, rescan):
169169
# # assert lo <= position <= hi, f"{bec=} {bec.peaks=} {position=} {center=} {width=}"
170170

171171

172+
@pytest.mark.parametrize(
173+
"signals, mover, start, finish, npts, feature, nscans",
174+
[
175+
[noisy, m1, -1.2, 1.2, 11, "max", 2], # slower, is motor
176+
[pvoigt, axis, -1.2, 1.2, 11, "cen", 2], # faster, is ao (float)
177+
[pvoigt, axis, -1.2, 1.2, 11, "max", 2],
178+
[[pvoigt], axis, -1.2, 1.2, 11, "max", 2], # list
179+
[[pvoigt, noisy, scaler1], axis, -1.2, 1.2, 11, "max", 2], # more than one detector
180+
[(pvoigt), axis, -1.2, 1.2, 11, "max", 2], # tuple
181+
[pvoigt, axis, -1.2, 1.2, 11, "cen", 1],
182+
[pvoigt, axis, -1.2, 1.2, 11, "com", 1],
183+
[pvoigt, axis, -1.2, 1.2, 11, "max", 1],
184+
[pvoigt, axis, -1.2, 1.2, 11, "min", 1], # pathological
185+
],
186+
)
187+
def test_lineup2(signals, mover, start, finish, npts, feature, nscans):
188+
if isinstance(signals, SynPseudoVoigt):
189+
signals.randomize_parameters(scale=250_000, bkg=0.000_000_000_1)
190+
else:
191+
change_noisy_parameters()
192+
193+
RE(bps.mv(mover, 0))
194+
assert get_position(mover) == 0.0
195+
196+
RE(alignment.lineup2(signals, mover, start, finish, npts, feature=feature, nscans=nscans))
197+
198+
172199
def test_TuneAxis():
173200
signal = SynPseudoVoigt(name="signal", motor=m1, motor_field=m1.name)
174201
signal.kind = "hinted"

‎docs/source/api/_callbacks.rst

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ Callbacks
88
.. automodule:: apstools.callbacks.doc_collector
99
:members:
1010

11+
.. automodule:: apstools.callbacks.scan_signal_statistics
12+
:members:
13+
:private-members:
14+
1115

1216
File Writers
1317
------------

‎docs/source/api/_plans.rst

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Custom Scans
3232
~apstools.plans.doc_run.documentation_run
3333
~apstools.plans.labels_to_streams.label_stream_decorator
3434
~apstools.plans.alignment.lineup
35+
~apstools.plans.alignment.lineup2
3536
~apstools.plans.nscan_support.nscan
3637
~apstools.plans.sscan_support.sscan_1D
3738
~apstools.plans.alignment.TuneAxis
@@ -50,6 +51,7 @@ Overall
5051
~apstools.plans.command_list.execute_command_list
5152
~apstools.plans.command_list.get_command_list
5253
~apstools.plans.alignment.lineup
54+
~apstools.plans.alignment.lineup2
5355
~apstools.plans.nscan_support.nscan
5456
~apstools.plans.command_list.parse_Excel_command_file
5557
~apstools.plans.command_list.parse_text_command_file

0 commit comments

Comments
 (0)
Please sign in to comment.