class TuneAxis(object): """ tune an axis with a signal This class provides a tuning object so that a Device or other entity may gain its own tuning process, keeping track of the particulars needed to tune this device again. For example, one could add a tuner to a motor stage:: motor = EpicsMotor("xxx:motor", "motor") motor.tuner = TuneAxis([det], motor) Then the ``motor`` could be tuned individually:: RE(motor.tuner.tune(md={"activity": "tuning"})) or the :meth:`tune()` could be part of a plan with other steps. Example:: tuner = TuneAxis([det], axis) live_table = LiveTable(["axis", "det"]) RE(tuner.multi_pass_tune(width=2, num=9), live_table) RE(tuner.tune(width=0.05, num=9), live_table) Also see the jupyter notebook referenced here: :ref:`example_tuneaxis`. .. autosummary:: ~tune ~multi_pass_tune ~peak_detected """ _peak_choices_ = "cen com".split() def __init__(self, signals, axis, signal_name=None): self.signals = signals self.signal_name = signal_name or signals[0].name self.axis = axis self.stats = {} self.tune_ok = False self.peaks = None self.peak_choice = self._peak_choices_[0] self.center = None self.stats = [] # defaults self.width = 1 self.num = 10 self.step_factor = 4 self.pass_max = 6 self.snake = True def tune(self, width=None, num=None, md=None): """ BlueSky plan to execute one pass through the current scan range Scan self.axis centered about current position from ``-width/2`` to ``+width/2`` with ``num`` observations. If a peak was detected (default check is that max >= 4*min), then set ``self.tune_ok = True``. PARAMETERS width : float width of the tuning scan in the units of ``self.axis`` Default value in ``self.width`` (initially 1) num : int number of steps Default value in ``self.num`` (initially 10) md : dict, optional metadata """ width = width or self.width num = num or self.num if self.peak_choice not in self._peak_choices_: msg = "peak_choice must be one of {}, geave {}" msg = msg.format(self._peak_choices_, self.peak_choice) raise ValueError(msg) initial_position = self.axis.position final_position = initial_position # unless tuned start = initial_position - width/2 finish = initial_position + width/2 self.tune_ok = False tune_md = dict( width = width, initial_position = self.axis.position, time_iso8601 = str(datetime.datetime.now()), ) _md = {'tune_md': tune_md, 'plan_name': self.__class__.__name__ + '.tune', 'tune_parameters': dict( num = num, width = width, initial_position = self.axis.position, peak_choice = self.peak_choice, x_axis = self.axis.name, y_axis = self.signal_name, ), 'hints': dict( dimensions = [ ( [self.axis.name], 'primary')] ) } _md.update(md or {}) if "pass_max" not in _md: self.stats = [] self.peaks = PeakStats(x=self.axis.name, y=self.signal_name) class Results(Device): """because bps.read() needs a Device or a Signal)""" tune_ok = Component(Signal) initial_position = Component(Signal) final_position = Component(Signal) center = Component(Signal) # - - - - - x = Component(Signal) y = Component(Signal) cen = Component(Signal) com = Component(Signal) fwhm = Component(Signal) min = Component(Signal) max = Component(Signal) crossings = Component(Signal) peakstats_attrs = "x y cen com fwhm min max crossings".split() def report(self): keys = self.peakstats_attrs + "tune_ok center initial_position final_position".split() for key in keys: print("{} : {}".format(key, getattr(self, key).value)) @bpp.subs_decorator(self.peaks) def _scan(md=None): yield from bps.open_run(md) position_list = np.linspace(start, finish, num) signal_list = list(self.signals) signal_list += [self.axis,] for pos in position_list: yield from bps.mv(self.axis, pos) yield from bps.trigger_and_read(signal_list) final_position = initial_position if self.peak_detected(): self.tune_ok = True if self.peak_choice == "cen": final_position = self.peaks.cen elif self.peak_choice == "com": final_position = self.peaks.com else: final_position = None self.center = final_position # add stream with results # yield from add_results_stream() stream_name = "PeakStats" results = Results(name=stream_name) for key in "tune_ok center".split(): getattr(results, key).put(getattr(self, key)) results.final_position.put(final_position) results.initial_position.put(initial_position) for key in results.peakstats_attrs: v = getattr(self.peaks, key) if key in ("crossings", "min", "max"): v = np.array(v) getattr(results, key).put(v) if results.tune_ok.value: yield from bps.create(name=stream_name) yield from bps.read(results) yield from bps.save() yield from bps.mv(self.axis, final_position) self.stats.append(self.peaks) yield from bps.close_run() results.report() return (yield from _scan(md=_md)) def multi_pass_tune(self, width=None, step_factor=None, num=None, pass_max=None, snake=None, md=None): """ BlueSky plan for tuning this axis with this signal Execute multiple passes to refine the centroid determination. Each subsequent pass will reduce the width of scan by ``step_factor``. If ``snake=True`` then the scan direction will reverse with each subsequent pass. PARAMETERS width : float width of the tuning scan in the units of ``self.axis`` Default value in ``self.width`` (initially 1) num : int number of steps Default value in ``self.num`` (initially 10) step_factor : float This reduces the width of the next tuning scan by the given factor. Default value in ``self.step_factor`` (initially 4) pass_max : int Maximum number of passes to be executed (avoids runaway scans when a centroid is not found). Default value in ``self.pass_max`` (initially 10) snake : bool If ``True``, reverse scan direction on next pass. Default value in ``self.snake`` (initially True) md : dict, optional metadata """ width = width or self.width num = num or self.num step_factor = step_factor or self.step_factor snake = snake or self.snake pass_max = pass_max or self.pass_max self.stats = [] def _scan(width=1, step_factor=10, num=10, snake=True): for _pass_number in range(pass_max): _md = {'pass': _pass_number+1, 'pass_max': pass_max, 'plan_name': self.__class__.__name__ + '.multi_pass_tune', } _md.update(md or {}) yield from self.tune(width=width, num=num, md=_md) if not self.tune_ok: return width /= step_factor if snake: width *= -1 return ( yield from _scan( width=width, step_factor=step_factor, num=num, snake=snake)) def peak_detected(self): """ returns True if a peak was detected, otherwise False The default algorithm identifies a peak when the maximum value is four times the minimum value. Change this routine by subclassing :class:`TuneAxis` and override :meth:`peak_detected`. """ if self.peaks is None: return False self.peaks.compute() if self.peaks.max is None: return False ymax = self.peaks.max[-1] ymin = self.peaks.min[-1] return ymax > 4*ymin # this works for USAXS@APS
class TuneAxis(object): """ tune an axis with a signal This class provides a tuning object so that a Device or other entity may gain its own tuning process, keeping track of the particulars needed to tune this device again. For example, one could add a tuner to a motor stage:: motor = EpicsMotor("xxx:motor", "motor") motor.tuner = TuneAxis([det], motor) Then the ``motor`` could be tuned individually:: RE(motor.tuner.tune(md={"activity": "tuning"})) or the :meth:`tune()` could be part of a plan with other steps. Example:: tuner = TuneAxis([det], axis) live_table = LiveTable(["axis", "det"]) RE(tuner.multi_pass_tune(width=2, num=9), live_table) RE(tuner.tune(width=0.05, num=9), live_table) Also see the jupyter notebook referenced here: :ref:`example_tuneaxis`. .. autosummary:: ~tune ~multi_pass_tune ~peak_detected SEE ALSO .. autosummary:: ~tune_axes """ _peak_choices_ = "cen com".split() def __init__(self, signals, axis, signal_name=None): self.signals = signals self.signal_name = signal_name or signals[0].name self.axis = axis self.tune_ok = False self.peaks = None self.peak_choice = self._peak_choices_[0] self.center = None self.stats = [] # defaults self.width = 1 self.num = 10 self.step_factor = 4 self.peak_factor = 4 self.pass_max = 4 self.snake = True def tune(self, width=None, num=None, peak_factor=None, md=None): """ Bluesky plan to execute one pass through the current scan range Scan self.axis centered about current position from ``-width/2`` to ``+width/2`` with ``num`` observations. If a peak was detected (default check is that max >= 4*min), then set ``self.tune_ok = True``. PARAMETERS width : float width of the tuning scan in the units of ``self.axis`` Default value in ``self.width`` (initially 1) num : int number of steps Default value in ``self.num`` (initially 10) md : dict, optional metadata """ width = width or self.width num = num or self.num peak_factor = peak_factor or self.peak_factor if self.peak_choice not in self._peak_choices_: msg = "peak_choice must be one of {}, geave {}" msg = msg.format(self._peak_choices_, self.peak_choice) raise ValueError(msg) initial_position = self.axis.position # final_position = initial_position # unless tuned start = initial_position - width/2 finish = initial_position + width/2 self.tune_ok = False tune_md = dict( width = width, initial_position = self.axis.position, time_iso8601 = str(datetime.datetime.now()), ) _md = {'tune_md': tune_md, 'plan_name': self.__class__.__name__ + '.tune', 'tune_parameters': dict( num = num, width = width, initial_position = self.axis.position, peak_choice = self.peak_choice, x_axis = self.axis.name, y_axis = self.signal_name, ), 'motors': (self.axis.name,), 'detectors': (self.signal_name,), 'hints': dict( dimensions = [ ( [self.axis.name], 'primary')] ) } _md.update(md or {}) if "pass_max" not in _md: self.stats = [] self.peaks = PeakStats(x=self.axis.name, y=self.signal_name) @bpp.subs_decorator(self.peaks) def _scan(md=None): yield from bps.open_run(md) position_list = np.linspace(start, finish, num) signal_list = list(self.signals) signal_list += [self.axis,] for pos in position_list: yield from bps.mv(self.axis, pos) yield from bps.trigger_and_read(signal_list) final_position = initial_position if self.peak_detected(peak_factor=peak_factor): self.tune_ok = True if self.peak_choice == "cen": final_position = self.peaks.cen elif self.peak_choice == "com": final_position = self.peaks.com else: final_position = None self.center = final_position # add stream with results # yield from add_results_stream() stream_name = "PeakStats" results = TuneResults(name=stream_name) results.tune_ok.put(self.tune_ok) results.center.put(self.center) results.final_position.put(final_position) results.initial_position.put(initial_position) results.set_stats(self.peaks) self.stats.append(results) if results.tune_ok.get(): yield from bps.create(name=stream_name) try: yield from bps.read(results) except ValueError as ex: separator = " "*8 + "-"*12 print(separator) print(f"Error saving stream {stream_name}:\n{ex}") print(separator) yield from bps.save() yield from bps.mv(self.axis, final_position) yield from bps.close_run() results.report(stream_name) return (yield from _scan(md=_md)) def multi_pass_tune(self, width=None, step_factor=None, num=None, pass_max=None, peak_factor=None, snake=None, md=None): """ Bluesky plan for tuning this axis with this signal Execute multiple passes to refine the centroid determination. Each subsequent pass will reduce the width of scan by ``step_factor``. If ``snake=True`` then the scan direction will reverse with each subsequent pass. PARAMETERS width : float width of the tuning scan in the units of ``self.axis`` Default value in ``self.width`` (initially 1) num : int number of steps Default value in ``self.num`` (initially 10) step_factor : float This reduces the width of the next tuning scan by the given factor. Default value in ``self.step_factor`` (initially 4) pass_max : int Maximum number of passes to be executed (avoids runaway scans when a centroid is not found). Default value in ``self.pass_max`` (initially 4) peak_factor : float (default: 4) peak maximum must be greater than ``peak_factor*minimum`` snake : bool If ``True``, reverse scan direction on next pass. Default value in ``self.snake`` (initially True) md : dict, optional metadata """ width = width or self.width num = num or self.num step_factor = step_factor or self.step_factor snake = snake or self.snake pass_max = pass_max or self.pass_max peak_factor = peak_factor or self.peak_factor self.stats = [] def _scan(width=1, step_factor=10, num=10, snake=True): for _pass_number in range(pass_max): logger.info("Multipass tune %d of %d", _pass_number+1, pass_max) _md = {'pass': _pass_number+1, 'pass_max': pass_max, 'plan_name': self.__class__.__name__ + '.multi_pass_tune', } _md.update(md or {}) yield from self.tune(width=width, num=num, peak_factor=peak_factor, md=_md) if not self.tune_ok: return if width > 0: sign = 1 else: sign = -1 width = sign * 2 * self.stats[-1].fwhm.get() if snake: width *= -1 return ( yield from _scan( width=width, step_factor=step_factor, num=num, snake=snake)) def multi_pass_tune_summary(self): t = pyRestTable.Table() t.labels = "pass Ok? center width max.X max.Y".split() for i, stat in enumerate(self.stats): row = [i+1,] row.append(stat.tune_ok.get()) row.append(stat.cen.get()) row.append(stat.fwhm.get()) x, y = stat.max.get() row += [x, y] t.addRow(row) return t def peak_detected(self, peak_factor=None): """ returns True if a peak was detected, otherwise False The default algorithm identifies a peak when the maximum value is four times the minimum value. Change this routine by subclassing :class:`TuneAxis` and override :meth:`peak_detected`. """ peak_factor = peak_factor or self.peak_factor if self.peaks is None: return False self.peaks.compute() if self.peaks.max is None: return False ymax = self.peaks.max[-1] ymin = self.peaks.min[-1] ok = ymax >= peak_factor*ymin # this works for USAXS@APS if not ok: logger.info("ymax < ymin * %f: is it a peak?", peak_factor) return ok
class UsaxsTuneAxis(TuneAxis): """use bp.rel_scan() for the tune()""" width_signal = None _width_default = 1 # fallback default when width_signal is None def __init__(self, signals, axis, signal_name=None, width_signal=None): """ Adds to the ``apstools.devices.TuneAxis()`` signature signal_width (obj): Instance of `ophyd.EpicsSignal` connected to PV with default tune width for this axis. If undefined (set to ``None``), then a private attribute (``_width_default``) will be used for the value. """ super().__init__(signals, axis, signal_name=signal_name) self.width_signal = width_signal @property def width(self): """Get default tune width for this axis.""" if self.width_signal is None: return self._width_default else: return self.width_signal.get() @width.setter def width(self, value): """ Set the width PV value - blocking call, not a plan. To set the width PV in a plan, use ``yield from bps.mv(self.width_signal, value)``. CAUTION: Do NOT call this setter from a bluesky plan! """ if self.width_signal is None: self._width_default = value else: self.width_signal.put(value) def peak_analysis(self, initial_position): if self.peak_detected(): self.tune_ok = True if self.peak_choice == "cen": final_position = self.peaks.cen elif self.peak_choice == "com": final_position = self.peaks.com else: final_position = None self.center = final_position else: self.tune_ok = False final_position = initial_position yield from bps.mv(self.axis, final_position) stream_name = "PeakStats" results = TuningResults(name=stream_name) results.tune_ok.put(self.tune_ok) results.center.put(self.center) results.final_position.put(final_position) results.initial_position.put(initial_position) if self.peaks is None: logger.info("PeakStats object is None.") results.put_results({}) else: results.put_results(self.peaks) self.stats.append(results) t = results.report(print_enable=False) logger.info("%s\n%s", stream_name, str(t)) def tune(self, width=None, num=None, md=None): """ Bluesky plan to execute one pass through the current scan range Scan self.axis centered about current position from ``-width/2`` to ``+width/2`` with ``num`` observations. If a peak was detected (default check is that max >= 4*min), then set ``self.tune_ok = True``. PARAMETERS width : float width of the tuning scan in the units of ``self.axis`` Default value in ``self.width`` (initially 1) num : int number of steps Default value in ``self.num`` (initially 10) md : dict, optional metadata """ width = width or self.width num = num or self.num if self.peak_choice not in self._peak_choices_: raise ValueError( f"peak_choice must be one of {self._peak_choices_}," f" gave {self.peak_choice}") initial_position = self.axis.position start = initial_position - width / 2 finish = initial_position + width / 2 self.tune_ok = False signal_list = list(self.signals) signal_list += [ self.axis, ] tune_md = dict( width=width, initial_position=self.axis.position, time_iso8601=str(datetime.datetime.now()), ) _md = { 'tune_md': tune_md, 'plan_name': self.__class__.__name__ + '.tune', 'tune_parameters': dict( num=num, width=width, initial_position=self.axis.position, peak_choice=self.peak_choice, x_axis=self.axis.name, y_axis=self.signal_name, ), 'motors': (self.axis.name, ), 'detectors': (self.signal_name, ), 'hints': dict(dimensions=[([self.axis.name], 'primary')]) } _md.update(md or {}) if "pass_max" not in _md: self.stats = [] self.peaks = PeakStats(x=self.axis.name, y=self.signal_name) @bpp.subs_decorator(self.peaks) def _scan(md=None): yield from bp.scan(signal_list, self.axis, start, finish, num, md=_md) yield from self.peak_analysis(initial_position) yield from _scan() def multi_pass_tune(self, width=None, step_factor=None, num=None, pass_max=None, snake=None, md=None): """ BlueSky plan for tuning this axis with this signal Execute multiple passes to refine the centroid determination. Each subsequent pass will reduce the width of scan by ``step_factor``. If ``snake=True`` then the scan direction will reverse with each subsequent pass. PARAMETERS width : float width of the tuning scan in the units of ``self.axis`` Default value in ``self.width`` (initially 1) num : int number of steps Default value in ``self.num`` (initially 10) step_factor : float This reduces the width of the next tuning scan by the given factor. Default value in ``self.step_factor`` (initially 4) pass_max : int Maximum number of passes to be executed (avoids runaway scans when a centroid is not found). Default value in ``self.pass_max`` (initially 10) snake : bool If ``True``, reverse scan direction on next pass. Default value in ``self.snake`` (initially True) md : dict, optional metadata """ width = width or self.width num = num or self.num step_factor = step_factor or self.step_factor snake = snake or self.snake pass_max = pass_max or self.pass_max self.stats = [] def _scan(width=1, step_factor=10, num=10, snake=True): for _pass_number in range(pass_max): _md = { 'pass': _pass_number + 1, 'pass_max': pass_max, 'plan_name': self.__class__.__name__ + '.multi_pass_tune', } _md.update(md or {}) yield from self.tune(width=width, num=num, md=_md) if not self.tune_ok: break width /= step_factor if snake: width *= -1 t = pyRestTable.Table() t.labels = "pass Ok? center width max.X max.Y".split() for i, stat in enumerate(self.stats): row = [ i + 1, ] row.append(stat.tune_ok.get()) row.append(stat.cen.get()) row.append(stat.fwhm.get()) x, y = stat.max.get() row += [x, y] t.addRow(row) logger.info("Results\n%s", str(t)) logger.info("Final tune position: %s = %f", self.axis.name, self.axis.position) return (yield from _scan(width=width, step_factor=step_factor, num=num, snake=snake)) def peak_detected(self): """ returns True if a peak was detected, otherwise False The default algorithm identifies a peak when the maximum value is four times the minimum value. Change this routine by subclassing :class:`TuneAxis` and override :meth:`peak_detected`. """ if self.peaks is None: logger.info("PeakStats = None") return False self.peaks.compute() if self.peaks.max is None: logger.info("PeakStats : no max reported") return False ymax = self.peaks.max[-1] ymin = self.peaks.min[-1] ok = ymax > 4 * ymin # this works for USAXS@APS if not ok: logger.info("ymax/yman not big enough: is it a peak?") return ok