diff --git a/README.md b/README.md index bb1a9a21..38714dd1 100644 --- a/README.md +++ b/README.md @@ -1,147 +1,20 @@ # README.md -This repository, via [Jupyter](https://jupyter.org/) notebooks, demonstrates use -of the Bluesky framework at a typical beamline scenario for a BCDA-sponsored -class. +This repository provides training materials for [APS](https://www.aps.anl.gov) +beamlines to use the [Bluesky](https://blueskyproject.io) framework. + +- [HowTo guides, tutorials, & references](https://bcda-aps.github.io/bluesky_training/) + demonstrating use of the Bluesky framework at a typical beamline. +- a [template](./bluesky/) for beamlines to configure their instrument +- a [list of APS instrument](https://github.com/BCDA-APS/bluesky_training/wiki/) + repositories Unit Tests | License | GH tag --- | --- | --- [![Unit Tests](https://github.com/BCDA-APS/bluesky_training/workflows/Unit%20Tests/badge.svg)](https://github.com/BCDA-APS/bluesky_training/actions/workflows/unit_tests.yml) | [![license: ANL](https://img.shields.io/badge/license-ANL-brightgreen)](/LICENSE.txt) | [![tag](https://img.shields.io/github/tag/BCDA-APS/bluesky_training.svg)](https://github.com/BCDA-APS/bluesky_training/tags) - - -- [README.md](#readmemd) - - [Introductory](#introductory) - - [Basic hardware configuration and measurement](#basic-hardware-configuration-and-measurement) - - [Hardware Configuration - Advanced](#hardware-configuration---advanced) - - [Measurement using the `instrument` package](#measurement-using-the-instrument-package) - - [Measurement - Advanced](#measurement---advanced) - - [Post-measurement (such as Analysis)](#post-measurement-such-as-analysis) - - [Data Processing, Reduction, and/or Analysis](#data-processing-reduction-andor-analysis) - - [Review](#review) - - [Install as Bluesky `instrument` package](#install-as-bluesky-instrument-package) - -name | URL ---- | --- -Documentation | https://BCDA-APS.github.io/bluesky_training -Instrument template | https://github.com/BCDA-APS/bluesky_training/tree/main/bluesky/instrument -APS instruments | https://github.com/BCDA-APS/bluesky_training/wiki/ -Bluesky Framework | https://blueskyproject.io/ -bluesky | https://blueskyproject.io/bluesky -ophyd | https://blueskyproject.io/ophyd -databroker | https://blueskyproject.io/databroker -databroker-pack | https://blueskyproject.io/databroker-pack -apstools | https://BCDA-APS.github.io/apstools -APS Data Management | https://confluence.aps.anl.gov/display/DMGT/Infrastructure - -## Introductory - -The best introduction to Bluesky might come through a _Hello, World!_ example -and then an example connecting EPICS to Bluesky. - -- [Bluesky *Hello, World!*](https://bcda-aps.github.io/bluesky_training/tutor/hello_world.html) -- [Connect with EPICS](https://bcda-aps.github.io/bluesky_training/tutor/connect_epics.html) - -These notebooks, and the notebooks in the next section, comprise the **_APS -Bluesky 101_** introductory course. You can review the [presenter's -notes](https://bcda-aps.github.io/bluesky_training/tutor/_aps101_notes.html) for -details on that course. - -## Basic hardware configuration and measurement - -These notebooks demonstrate the basics of hardware configuration -([ophyd](https://blueskyproject.io/ophyd)) and custom measurement plans -([bluesky](https://blueskyproject.io/bluesky)), in addition to measurement -activities. - -1. [scaler](https://bcda-aps.github.io/bluesky_training/tutor/_basic_a.html) -1. [motor](https://bcda-aps.github.io/bluesky_training/tutor/_basic_b.html) -1. [step scan](https://bcda-aps.github.io/bluesky_training/tutor/_basic_c.html) - -### Hardware Configuration - Advanced - -- [Run a Linux command as an `ophyd.Device`](https://bcda-aps.github.io/bluesky_training/howto/_doodle.html) -- [Store Images, Darks, and Flats frames separately in HDF5 files](https://bcda-aps.github.io/bluesky_training/howto/_images_darks_flats.html) - -## Measurement using the `instrument` package - -As the configuration of a system becomes more complex, it may be easier to -describe (and startup) by making the steps into Python package that can be -`import`ed. These notebooks start with an `instrument` package that is -preconfigured to use the general purpose `gp:` IOC and the area detector `ad:` -IOC. - -1. [Count the scaler](https://bcda-aps.github.io/bluesky_training/howto/_count_scaler.html) -1. [Watch a temperature](https://bcda-aps.github.io/bluesky_training/example/_watch_temperature.html) -1. [Lineup a 1-D peak](https://bcda-aps.github.io/bluesky_training/howto/_lineup_1d_peak.html) -1. [Locate peak on 2-D area detector image](https://bcda-aps.github.io/bluesky_training/howto/_locate_image_peak.html) -1. [Plot $(x,y)$ data from a databroker run](https://bcda-aps.github.io/bluesky_training/howto/_plot_x_y_databroker.html) -1. [Custom bluesky plan](https://bcda-aps.github.io/bluesky_training/howto/_custom_plan.html) - -### Measurement - Advanced - -- [Move 2 motors with dynamic limits](https://bcda-aps.github.io/bluesky_training/howto/_dynamic_limits_2motor.html) - : EPICS : Demo of dynamic limit signal to avoid collision of two motors -- [XAFS scan, multi-segment](https://bcda-aps.github.io/bluesky_training/example/_xafs_scan.html) - : step scan (note: detector data is random numbers) - -## Post-measurement (such as Analysis) - -Typically, measurement data is sent to Bluesky's -[databroker](https://blueskyproject.io/databroker) package for storage in a -MongoDB database (or a structured set of folders) for access and analysis. -These notebooks use data recorded previously and stored in a structured set of -folders (created by tools from Bluesky's -[databroker-pack](https://blueskyproject.io/databroker-pack/) package.) - -### Data Processing, Reduction, and/or Analysis - -- [Access data later, after the measurement](https://bcda-aps.github.io/bluesky_training/howto/_after_measurement.html) - using Data Broker -- [Analyze a 2-D image](https://bcda-aps.github.io/bluesky_training/howto/_locate_image_peak.html) -- [Plot $(x,y)$ data from a databroker run](https://bcda-aps.github.io/bluesky_training/howto/_plot_x_y_databroker.html) - using Data Broker - -## Review - -- [Command Review](https://bcda-aps.github.io/bluesky_training/tutor/_command_review.html) -- [Overview of the `instrument` package](https://bcda-aps.github.io/bluesky_training/instrument/describe_instrument.html) - -## Install as Bluesky `instrument` package - -The [`bluesky`](https://github.com/BCDA-APS/bluesky_training/tree/main/bluesky) -directory of this [repository](https://github.com/BCDA-APS/bluesky_training) -serves as a template for creating a new bluesky `instrument` package. See these -installation -[instructions](https://bcda-aps.github.io/bluesky_training/instrument/_install_new_instrument.html#setup-a-bluesky-instrument). -The process to install on Mac OS and Windows is similar but support for those -operating systems is not provided here. diff --git a/bluesky/README.md b/bluesky/README.md index 2da8e3c0..dded86fa 100644 --- a/bluesky/README.md +++ b/bluesky/README.md @@ -9,7 +9,7 @@ Contains: description | item(s) --- | --- -Introduction | [`intro2bluesky.md`](intro2bluesky.md) +Introduction | [`intro2bluesky.md`](https://bcda-aps.github.io/bluesky_training/reference/_intro2bluesky.html) IPython console startup | [`console/`](console/README.md) Bluesky queueserver support | [introduction](qserver.md), `*qs*` Instrument configuration | `instrument/` diff --git a/bluesky/qserver.md b/bluesky/qserver.md index 2284252d..c4912293 100644 --- a/bluesky/qserver.md +++ b/bluesky/qserver.md @@ -8,7 +8,7 @@ work-in-progress: *very* basic notes for now - [diagnostics and testing](#diagnostics-and-testing) - [graphical user interface](#graphical-user-interface) -**IMPORTANT**: When the queueserver starts, it **must** find only one `/.py` file in this directory and it must find `instrument/` in the same directory. Attempts to place the qserver files in a sub directory result in `'instrument/' directory not found` as queueserver starts. +**IMPORTANT**: When the queueserver starts, it **must** find only one `.py` file in this directory and it must find `instrument/` in the same directory. Attempts to place the qserver files in a sub directory result in `'instrument/' directory not found` as queueserver starts. ## Run the queuserver @@ -16,15 +16,15 @@ work-in-progress: *very* basic notes for now Run in a background screen session. -`./qserver start` +`./qserver.sh start` Stop this with -`./qserver stop` +`./qserver.sh stop` ### diagnostics and testing -`./qserver run` +`./qserver.sh run` ## graphical user interface @@ -33,4 +33,5 @@ Stop this with - connect to the server - open the environment - add tasks to the queue -- run the queue \ No newline at end of file +- run the queue +- diff --git a/docs/source/_static/neat_stage_2apd.png b/docs/source/_static/neat_stage_2apd.png new file mode 100755 index 00000000..d195d7f9 Binary files /dev/null and b/docs/source/_static/neat_stage_2apd.png differ diff --git a/docs/source/howto/_bluesky_queueserver.md b/docs/source/howto/_bluesky_queueserver.md index 0218ae7d..6c6a374d 100644 --- a/docs/source/howto/_bluesky_queueserver.md +++ b/docs/source/howto/_bluesky_queueserver.md @@ -20,15 +20,15 @@ from the [APS beamline data pipelines. Install `redis` package in OS (if not already installed): -```bash -sudo apt install redis -``` +
+$ sudo apt install redis
+
Create conda environment -```bash -conda create -y -n qserver -c conda-forge bluesky-queueserver -``` +
+$ conda create -y -n qserver -c conda-forge bluesky-queueserver
+
## Start a monitor on a qserver @@ -43,6 +43,8 @@ Here, the `training` catalog is subscribed to the RunEngine, in addition to a 0M
+
+$ start-re-manager --zmq-publish-console ON --databroker-config training
 
 $ start-re-manager --zmq-publish-console ON --databroker-config training
 INFO:bluesky_queueserver.manager.manager:Starting ZMQ server at 'tcp://*:60615'
diff --git a/docs/source/howto/_custom_heater_positioner.ipynb b/docs/source/howto/_custom_heater_positioner.ipynb
new file mode 100644
index 00000000..05623100
--- /dev/null
+++ b/docs/source/howto/_custom_heater_positioner.ipynb
@@ -0,0 +1,866 @@
+{
+ "cells": [
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Heater Simulation using synApps epid and transform records\n",
+    "\n",
+    "
\n", + "TODO\n", + "\n", + "\n", + "Needs GUI screen shots, collected data, ...\n", + "\n", + "```bash\n", + "pushd /tmp/docker_ioc/iocgp/tmp/screens/ui/\n", + "caQtDM -macro \"P=gp:,T=userTran1\" yyTransform_full.ui &\n", + "caQtDM -macro \"P=gp:,PID=epid1,TITLE=fb_epid\" ./pid_control.ui &\n", + "popd\n", + "```\n", + "\n", + "
\n", + "\n", + "Simulate a temperature controller with heater and cooling features. Use it as a positioner.\n", + "\n", + "A temperature sensor can be [simulated](https://github.com/epics-modules/optics/blob/fdf5bc3c2731ba6769b62e71628fe3018c8245e3/opticsApp/Db/fb_epid.db#L236-L270) with a single synApps [`swait`](https://htmlpreview.github.io/?https://raw.githubusercontent.com/epics-modules/calc/R3-6-1/documentation/swaitRecord.html) record. The `swait` record has 16 fields for input values, either as constants or values from other EPICS PVs. Via a custom calculation [expression](https://github.com/epics-modules/optics/blob/fdf5bc3c2731ba6769b62e71628fe3018c8245e3/opticsApp/Db/fb_epid.db#L262), a simulated temperature sensor value is computed.\n", + "\n", + "
\n", + "\n", + "The simulation has these fields:\n", + "\n", + "field | description\n", + "--- | ---\n", + "A | minimum \"temperature\" allowed\n", + "B | cooling rate parameter\n", + "C | heater power\n", + "D | output of PID loop\n", + "F | current \"temperature\"\n", + "\n", + "A cooling simulation is necessary or the controller could never decrease the temperature.\n", + "\n", + "
\n", + "\n", + "This simulation is an example of building a custom\n", + "[ophyd](https://blueskyproject.io/ophyd/)\n", + "[Device](https://blueskyproject.io/ophyd/user_v1/tutorials/device.html#define-a-custom-device).\n", + "\n", + "## Overview\n", + "\n", + "A _PID_ loop (see below) has been [paired](https://github.com/epics-modules/optics/blob/master/opticsApp/Db/fb_epid.db) with the simulated temperature using the synApps [`epid`](https://epics.anl.gov/bcda/synApps/std/epidRecord.html) record, to update the operating power of the heater so that the temperature reaches a desired value.\n", + "\n", + "Significant _additional_ **realism is added to the simulation** by switching to the synApps [`transform`](https://htmlpreview.github.io/?https://raw.githubusercontent.com/epics-modules/calc/R3-6-1/documentation/transformRecord.html) record. The `transform` record also has 16 fields for values plus additional features:\n", + "\n", + "- value (same as `swait` record)\n", + "- (optional) input PV link (same as `swait` record)\n", + "- (optional) calculation expression\n", + "- (optional) output PV link\n", + "- (optional) descriptive text comment\n", + "\n", + "Replacing `swait` with `transform` allows the addition of:\n", + "\n", + "- a random noise simulation\n", + "- a `tolerance` value for evaluating if the setpoint and readback are in agreement\n", + "- an _at temperature_ feature\n", + "\n", + "## Schematic\n", + "The PID loop decreases the difference (the _following error_) between the current temperature (readback) and a given setpoint by controlling the heater's power:\n", + "\n", + "```text\n", + "power_fraction --> temperature --> PID --> output\n", + " ^ |\n", + " | v\n", + " <-----------------------------------------\n", + "```\n", + "\n", + "## Implementation\n", + "\n", + "The simulated temperature signal is computed from the various inputs of the `transform` record. The computation follows:\n", + "\n", + "$T_{sim} = T_{last} + \\Delta T_{cool} + \\Delta T_{heat} + \\Delta T_{noise}$\n", + "\n", + "Certain fields of this `transform` record are linked to fields in an `epid` record. When the `transform` record is [evaluated](https://docs.epics-controls.org/en/latest/guides/EPICS_Process_Database_Concepts.html) (_processed_, in EPICS terms), the `epid` is then _processed_. The `epid` record computes a new value (of _power_fraction_, the fraction of total heater power) for the next computation of the temperature signal.\n", + "\n", + "The simulated temperature is re-computed (processed) [periodically](https://docs.epics-controls.org/en/latest/guides/EPICS_Process_Database_Concepts.html#scanning-specification), as configured by the `transform` record's [`.SCAN`](https://docs.epics-controls.org/en/latest/guides/EPICS_Process_Database_Concepts.html?highlight=.SCAN#periodic-scanning) field.\n", + "When `transform` is processed, it first pulls the _power_fraction_ from `epid` (the output of the PID loop calculation) for the next simulated temperature.\n", + "\n", + "Once `transform` processing is complete, the `epid` record is told to process itself by configuring the `transform` record's [forward link](https://docs.epics-controls.org/en/latest/guides/EPICS_Process_Database_Concepts.html#forward-links) (.`FLNK`) field. The _setpoint_ is pushed from `transform` to `epid`_ while `epid` pulls the current _temperature_ (readback) from `transform`.\n", + "\n", + "In addition to the simulated heater, the temperature calculation includes a simulation of cooling. This cooling is computed as a fraction of the last simulated temperature. Without any applied heating (when the heater power is OFF), the simulated will progress to the minimum temperature. (This simulation is a _heater_ where the simulated temperature is constrained by `T_min <= T <= T_max`.)\n", + "\n", + "## PID Control Loops\n", + "\n", + "See these on-line references which explain PID control loops in detail.\n", + "\n", + "- https://en.wikipedia.org/wiki/PID_controller\n", + "- https://pidexplained.com/pid-controller-explained/\n", + "- https://www.ni.com/en-us/shop/labview/pid-theory-explained.html\n", + "- https://www.mathworks.com/discovery/pid-control.html" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ophyd interface\n", + "\n", + "This notebook will configure both `transform` and `epid` records using `ophyd` Python tools within the [Bluesky](https://blueskyproject.io/) framework. GUI screens from the [`caQtDM`](https://caqtdm.github.io/) application will show the summary settings of each record.\n", + "\n", + "record | EPICS PV | Python object | ophyd support\n", + "--- | --- | --- | ---\n", + "`transform` | `\"gp:userTran1\"` | `heater` | [`apstools.synApps.TransformRecord`](https://bcda-aps.github.io/apstools/latest/api/synApps/_transform.html)\n", + "`epid` | `\"gp:epid1\"` | `pid` | [`apstools.synApps.EpidRecord`](https://bcda-aps.github.io/apstools/latest/api/synApps/_epid.html)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Setup the `heater` and `pid`\n", + "\n", + "There's a lot to configure here. Symbols have been defined as shortcuts to the\n", + "transform record channels and comments have been added to explain.\n", + "\n", + "The `EpidRecord` and `TransformRecord` classes connect with many PVs, far more\n", + "than we need _as an EPICS client_ to operate a temperature controller. We must\n", + "access _some_ of these fields to configure the records for our simulator. Local\n", + "instances are created for each, within the setup function, to access all the\n", + "settings for the EPICS configuration of the simulator. Once set, a simpler\n", + "interface to the simulator PVs will be constructed.\n", + "\n", + "Create a `setup()` which can be called when needed to reset the simulated heater." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from apstools.synApps import EpidRecord\n", + "from apstools.synApps import TransformRecord\n", + "\n", + "def heater_simulator_setup(transform_pv, epid_pv, starting=28.5, title=\"heater simulator\"):\n", + " \"\"\"Configure the transform record as a heater with epid control.\"\"\"\n", + "\n", + " transform = TransformRecord(transform_pv, name=\"transform\")\n", + " epid = EpidRecord(epid_pv, name=\"epid\")\n", + " transform.wait_for_connection()\n", + " epid.wait_for_connection()\n", + "\n", + " transform.reset() # clear all the transform record's configuration\n", + " # TODO: epid.reset() # no such thing available (now)\n", + "\n", + " transform.description.put(title)\n", + " epid.description.put(f\"{title} PID\")\n", + " transform.precision.put(3) # note: real T only needs 1 sig fig\n", + " \n", + " # assign (local) Python symbols since channel names are long.\n", + " # The calculation expressions (evaluated in the IOC) use channel letters.\n", + " t_max = transform.channels.A\n", + " t_min = transform.channels.B\n", + " tolerance = transform.channels.C\n", + " cooling = transform.channels.D\n", + " power_fraction = transform.channels.E\n", + " on_off = transform.channels.F\n", + " noise = transform.channels.G\n", + " t_last = transform.channels.H\n", + " t_cooling = transform.channels.I\n", + " t_heating = transform.channels.J\n", + " t_noise = transform.channels.K\n", + " smoothing = transform.channels.L\n", + " setpoint = transform.channels.M\n", + " readback = transform.channels.N\n", + " difference = transform.channels.O\n", + " at_temperature = transform.channels.P\n", + "\n", + " # simulated T will not go higher than this number\n", + " # also used by heating & cooling\n", + " t_max.comment.put(\"T max\")\n", + " t_max.current_value.put(500)\n", + "\n", + " # simulated T will not go lower than this number\n", + " # also used by heating & cooling\n", + " t_min.comment.put(\"T min\")\n", + " t_min.current_value.put(-10)\n", + "\n", + " # Acceptable range for difference between readback and setpoint\n", + " tolerance.comment.put(\"tolerance\")\n", + " tolerance.current_value.put(1.0) # same units as temperature\n", + "\n", + " # fraction to cool previous simulated temperature\n", + " cooling.comment.put(\"cooling\")\n", + " cooling.current_value.put(0.05) # 0.0 .. 1.0\n", + "\n", + " # PID will control this number from 0 (no power) to 1 (full power)\n", + " power_fraction.comment.put(\"power fraction\")\n", + " power_fraction.current_value.put(0) # 0.0 .. 1.0\n", + " # assume EPICS IOC will take care of adding \" NPP NMS\" to the link\n", + " power_fraction.input_pv.put(epid.output_value.pvname) # _from_ epid\n", + "\n", + " # User controls this ON/OFF switch.\n", + " # No heating if the power is off.\n", + " # This value will be passed _to_ the epid record.\n", + " on_off.comment.put(\"heater ON\")\n", + " on_off.current_value.put(0) # 0 or 1 (as float)\n", + " on_off.output_pv.put(epid.feedback_on.pvname) # _to_ epid\n", + "\n", + " # a random noise amplitude\n", + " noise.comment.put(\"noise\")\n", + " noise.current_value.put(0.15)\n", + "\n", + " # Basis for the next computed temperature.\n", + " # Previous computed temperature, smoothed by L.\n", + " # Smoothing added to _reduce_ effects of simulated sensor noise.\n", + " t_last.comment.put(\"T last\")\n", + " t_last.expression.put(\"H*L + N*(1-L)\") # 0.0 .. 1.0\n", + "\n", + " # temperature change for cooling\n", + " t_cooling.comment.put(\"T cooling\")\n", + " t_cooling.expression.put(\"-(H-B)*D\")\n", + "\n", + " # Temperature change for heating.\n", + " # No heating if on_off is OFF.\n", + " t_heating.comment.put(\"T heating\")\n", + " # Since transform values are floats, we evaluate the \"boolean\" \n", + " # as True if value>0.5 (and False if <= 0.5).\n", + " t_heating.expression.put(\"F>0.5? (A-B)*E: 0\")\n", + "\n", + " # Temperature change for uniform random noise with amplitude G.\n", + " # RNDM returns uniform random number between 0 .. 1.\n", + " # Keep in mind that smoothing can contribute to overshoot since non-zero\n", + " # smoothing introduces some lag in the computed temperature.\n", + " t_noise.comment.put(\"T noise\")\n", + " t_noise.expression.put(\"G * (RNDM-0.5)*2\")\n", + "\n", + " # smoothing fraction (0: only new values, 1: no new values)\n", + " smoothing.comment.put(\"smoothing\")\n", + " smoothing.current_value.put(0.001) # 0.0 .. 1.0\n", + "\n", + " # User changes the desired temperature here.\n", + " # This value will be passed _to_ the epid record.\n", + " setpoint.comment.put(\"setpoint\")\n", + " setpoint.current_value.put(starting)\n", + " setpoint.output_pv.put(epid.final_value.pvname) # _to_ epid\n", + "\n", + " # readback: the current temperature.\n", + " # During steady-state with the heater on, T_heating should balance T_cooling\n", + " # and the power_fraction should remain steady. Any variation in\n", + " # power_fraction should be in response to the effect of T_noise variations.\n", + " readback.comment.put(\"readback\")\n", + " readback.expression.put(\"min(max(B, H+I+J+K), A)\")\n", + "\n", + " # readback - setpoint\n", + " difference.comment.put(\"difference\")\n", + " difference.expression.put(\"N-M\")\n", + "\n", + " # Is the heater \"at temperature\"?\n", + " at_temperature.comment.put(\"at temperature\")\n", + " at_temperature.expression.put(\"abs(O)<=C\")\n", + "\n", + " # process epid after transform\n", + " transform.forward_link.put(epid.prefix)\n", + "\n", + " # epid record wll be processed after transform by FLNK\n", + " epid.scanning_rate.put(\"Passive\") # do not change\n", + " \n", + " # If Kp too low, controller will be slow to respond. \n", + " # If Kp>=0.004, controller will oscillate!\n", + " # If Ki is smaller, controller is slower to reach setpoint.\n", + " # If Ki is larger, controller reacts to noise when at temperature\n", + " # Kd is 0 for non-mechanical systems (do not change from zero)\n", + " epid.proportional_gain.put(0.000_04) # Kp\n", + " epid.integral_gain.put(0.5) # Ki\n", + " epid.derivative_gain.put(0.0) # Kd\n", + " epid.high_limit.put(1.0) # enforce 0.0 .. 1.0 range as power_fraction\n", + " epid.low_limit.put(0.0) # enforce 0.0 .. 1.0 range as power_fraction\n", + " epid.low_operating_range.put(0) # low == high: permissive\n", + " epid.high_operating_range.put(0) # low == high: permissive\n", + "\n", + " # This is readback: PID output is changed to minimize (readback-setpoint)\n", + " epid.controlled_value_link.put(readback.current_value.pvname)\n", + " \n", + " # Since power_fraction.current_value _pulls_ epid.output_value,\n", + " # do not configure epid.output_location to _push_ the value.\n", + " epid.output_location.put(\"\") # leave this empty\n", + "\n", + " transform.scanning_rate.put(\".1 second\")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Make the changes:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "heater_simulator_setup(\"gp:userTran1\", \"gp:epid1\")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## heater as positioner\n", + "\n", + "The heater temperature may be described in ophyd as a _positioner_, enabling temperature scans and (thermal profiles)[https://en.wikipedia.org/wiki/Thermal_profiling] such as _ramp, soak, cool_.\n", + "\n", + "Remember to turn on the power to the heater, or the controller will not come up to temperature." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "from apstools.devices import PVPositionerSoftDoneWithStop\n", + "from ophyd import Component\n", + "from ophyd import EpicsSignal\n", + "\n", + "class HeaterPositioner(PVPositionerSoftDoneWithStop):\n", + " \"\"\"\n", + " Simulated Heater as ophyd Positioner.\n", + " \n", + " PVs for setpoint (``.M``) and readback (``.N``) are defined through keyword\n", + " arguments. It is not necessary to create Components for them here. The\n", + " ``PVPositionerSoftDoneWithStop`` will create components for both of these.\n", + " \n", + " EXAMPLE::\n", + "\n", + " temperature = HeaterPositioner(\n", + " \"gp:userTran1\", name=\"temperature\",\n", + " setpoint_pv=\".M\", readback_pv=\".N\")\n", + " \"\"\"\n", + " tolerance = Component(EpicsSignal, \".C\", kind=\"config\")\n", + " on_off = Component(EpicsSignal, \".F\", kind=\"config\")\n", + "\n", + "temperature = HeaterPositioner(\n", + " \"gp:userTran1\", name=\"temperature\",\n", + " setpoint_pv=\".M\", readback_pv=\".N\")\n", + "temperature.wait_for_connection()\n", + "temperature.on_off.put(1)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Move the positioner using it's ophyd controls:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "MoveStatus(done=True, pos=temperature, elapsed=20.2, success=True, settle_time=0.0)" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "temperature.move(100)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "MoveStatus(done=True, pos=temperature, elapsed=21.3, success=True, settle_time=0.0)" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "temperature.move(28.5)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Minimal setup of bluesky to demonstrate the positioner with the RunEngine. Use the plan_stubs to build a plan." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "from bluesky import RunEngine, plan_stubs as bps\n", + "\n", + "RE = RunEngine()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Make a custom `report()` function." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " rb=28.604 sp=28.500 diff=0.104\n" + ] + } + ], + "source": [ + "def report():\n", + " sp = temperature.setpoint.get()\n", + " rb = temperature.position\n", + " print(\n", + " f\" rb={rb:.3f}\"\n", + " f\" sp={sp:.3f}\"\n", + " f\" diff={rb-sp:.3f}\"\n", + " )\n", + "\n", + "report()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a custom plan to demonstrate (with the RunEngine) the `temperature` object as a positioner. Use the _move relative_ (`bps.mvr()`) plan stub to change the temperature." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "def my_plan(step=None):\n", + " step = step or temperature.tolerance.get()\n", + "\n", + " report()\n", + "\n", + " yield from bps.mvr(temperature, step)\n", + " report()\n", + "\n", + " yield from bps.mvr(temperature, -step)\n", + " report()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First, run the plan, taking a 5 degree step. The step size is chosen to be well beyond the `temperature.tolerance` value." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " rb=28.595 sp=28.500 diff=0.095\n", + " rb=32.750 sp=33.595 diff=-0.845\n", + " rb=28.746 sp=27.750 diff=0.996\n" + ] + }, + { + "data": { + "text/plain": [ + "()" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "RE(my_plan(5))" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are problems with this plan:\n", + "\n", + "- setpoint did not change by the exact step value\n", + "- setpoint did not return to the starting value as expected\n", + "\n", + "That's because `bps.mvr()` set the new setpoint as a relative change from the temperature's position at the time a new step was requested.\n", + "\n", + "Change the plan to control the temperature _setpoint_ instead.\n", + "\n", + "Before running the revsed plan, set the temperature back to 28.5." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " rb=27.877 sp=28.500 diff=-0.623\n", + " rb=27.877 sp=33.500 diff=-5.623\n", + " rb=27.877 sp=28.500 diff=-0.623\n" + ] + }, + { + "data": { + "text/plain": [ + "()" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def my_plan(step=1):\n", + " report()\n", + "\n", + " yield from bps.mvr(temperature.setpoint, step)\n", + " report()\n", + "\n", + " yield from bps.mvr(temperature.setpoint, -step)\n", + " report()\n", + "\n", + "temperature.move(28.5)\n", + "RE(my_plan(5))" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Good, the setpoint returned to the starting 28.5. New problems appeared:\n", + "\n", + "- The starting readback was not the expected 28.5\n", + "- The readback did not change by the expected amount.\n", + "\n", + "Both of these problems have the same cause, the temperature `readback` had not\n", + "yet settled to the `setpoint` when the `setpoint` was next updated. While the\n", + "`readback` was within the `tolerance`, the `readback` will be closer to the\n", + "`setpoint` if we either:\n", + "\n", + "- wait a short time longer\n", + "- reduce the tolerance\n", + "\n", + "If the `tolerance` is too small, the temperature `noise` will cause the\n", + "`at_temperature` value to fluctuate even if the `setpoint` is not changing.\n", + "\n", + "Rather than reduce the `tolerance`, add the settling time into the plan (using\n", + "`bps.sleep()`).\n", + "\n", + "Also, switch back to absolute moves (`bps.mv()`) and controlling `temperature`\n", + "(not `temperature.setpoint`)." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " rb=28.326 sp=28.500 diff=-0.174\n", + " rb=32.872 sp=33.500 diff=-0.628\n", + " rb=28.934 sp=28.500 diff=0.434\n" + ] + }, + { + "data": { + "text/plain": [ + "()" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def my_plan(step=1, settle_time=5):\n", + " report()\n", + " starting = temperature.setpoint.get()\n", + "\n", + " yield from bps.mv(temperature, starting + step)\n", + " yield from bps.sleep(settle_time)\n", + " report()\n", + "\n", + " yield from bps.mv(temperature, starting)\n", + " yield from bps.sleep(settle_time)\n", + " report()\n", + "\n", + "RE(my_plan(5))" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Good. Now we see how to change the temperature in a step-wise manner, using `bps.mv()` and a settling time using `bps.sleep()`." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ramp the temperature\n", + "\n", + "When the tmperature is _ramped_, the setpoint is varied only some predetermined trajectory until some end condition is satisfied. One type of ramp is at constant rate where the temperature changes by a constant number of degrees / second (or minute).\n", + "\n", + "In the `ramp()` plan below, the _duration_ of the ramp is computed, given the _start_ and _final_ positions and the ramp _rate_. The setpoint is continually updated during the ramp (at the given _period_) to keep the temperature changing. The setpoint is a linear function of elapsed time, the _start_ & _final_ temperatures, and the _direction_ of the ramp. The ramp ends when the _duration_ has elapsed. One move to the _final_ temperature finishes the ramp." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "def ramp(positioner, final, rate=1, period=0.1, settle_time=5):\n", + " start = positioner.setpoint.get()\n", + " direction = 1 if final > start else -1\n", + " duration = abs(final - start) / rate\n", + "\n", + " t0 = time.time()\n", + " t_update = t0 + period\n", + " while time.time() < t0 + duration:\n", + " t = time.time()\n", + " if t >= t_update:\n", + " t_update += period\n", + " value = start + direction * rate * (t - t0)\n", + " yield from bps.mv(positioner.setpoint, value)\n", + " yield from bps.sleep(period / 10)\n", + "\n", + " yield from bps.mv(positioner.setpoint, final)\n", + " yield from bps.sleep(settle_time)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ramp the temperature from the current value to 40.0." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " rb=28.500 sp=28.500 diff=0.000\n", + " rb=39.583 sp=40.000 diff=-0.417\n" + ] + } + ], + "source": [ + "report()\n", + "RE(ramp(temperature, 40, settle_time=10))\n", + "report()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are new problems:\n", + "\n", + "- the readback temperature after the first ramp is not within tolerance (1.0)\n", + "- during the `ramp()`, the following error was much greater than the\n", + " `temperature.tolerance` and the controller was not `at_temperature`\n", + ". The controller did not keep up with the setpoint changes\n", + "\n", + "Either reduce the ramp rate or change the PID Kp and Ki terms to keep the\n", + "following error smaller." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " rb=28.752 sp=28.500 diff=0.252\n", + " rb=39.607 sp=40.000 diff=-0.393\n" + ] + } + ], + "source": [ + "# return to room temperature\n", + "temperature.move(28.5)\n", + "RE(bps.sleep(10))\n", + "\n", + "# ramp again, slower this time\n", + "report()\n", + "RE(ramp(temperature, 40, rate=0.2, settle_time=10))\n", + "report()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Good. The following error was smaller in this last ramp and the final readback was within tolerance of the setpoint." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Thermal profile\n", + "\n", + "With the `ramp()` plan above, a plan to _ramp, soak, and cool_ is possible. Remember to control the ramp _rate_." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "def ramp_soak_cool(positioner, high, rate=1, soak_time=10):\n", + " start = positioner.setpoint.get()\n", + " report()\n", + "\n", + " print(f\">>> ramp from {start:.2f} to {high:.2f}\")\n", + " yield from ramp(positioner, high, rate=rate)\n", + " report()\n", + "\n", + " print(f\">>> soak for {soak_time:.2f} s\")\n", + " yield from bps.sleep(soak_time)\n", + " report()\n", + "\n", + " print(f\">>> ramp from {high:.2f} to {start:.2f}\")\n", + " yield from ramp(positioner, start, rate=rate)\n", + " report()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Run the new plan:" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " rb=40.146 sp=40.000 diff=0.146\n", + ">>> ramp from 40.00 to 55.00\n", + " rb=54.550 sp=55.000 diff=-0.450\n", + ">>> soak for 15.00 s\n", + " rb=54.890 sp=55.000 diff=-0.110\n", + ">>> ramp from 55.00 to 40.00\n", + " rb=40.526 sp=40.000 diff=0.526\n" + ] + }, + { + "data": { + "text/plain": [ + "()" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "RE(ramp_soak_cool(temperature, 55, rate=0.2, soak_time=15))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "bluesky_2023_2", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.10" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/howto/index.rst b/docs/source/howto/index.rst index 5adb8d10..98679d5f 100644 --- a/docs/source/howto/index.rst +++ b/docs/source/howto/index.rst @@ -15,6 +15,7 @@ How-To Guides How-to guides are directions that take the reader through the steps required to solve a real-world problem. How-to guides are *goal-oriented*. +Tutorials often involve more depth than HowTo guides. .. toctree:: :maxdepth: 2 diff --git a/docs/source/index.rst b/docs/source/index.rst index 7a86b6d6..cb564c38 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -74,6 +74,8 @@ Other resources * source: https://github.com/pyepics/pyepics * PyPI: https://pypi.org/project/pyepics/ * conda: https://anaconda.org/conda-forge/pyepics +* APS Data Management + * home: https://confluence.aps.anl.gov/display/DMGT/ Index ================== diff --git a/docs/source/reference/_FAQ.rst b/docs/source/reference/_FAQ.rst index 05b1305f..a7335ec1 100644 --- a/docs/source/reference/_FAQ.rst +++ b/docs/source/reference/_FAQ.rst @@ -93,3 +93,24 @@ What does the (**~**) mean in a path? The tilde (**~**) character represents the current user's home directory. This is a shortcut that can be used to specify file paths without having to type out the entire path to the home directory. + +.. _faq-timestamp: + +How to understand a timestamp? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Timestamps are floating point numbers offset from a fixed reference +(1970-01-01 00:00 UTC). Convert that to a format for humans using the +*datetime* package (assuming the local time zone, in this case +`"US/Chicago"`): + +.. code-block:: python + :linenos: + + In [1]: import datetime + + In [2]: datetime.datetime.fromtimestamp(1685123274.1847932) + Out[2]: datetime.datetime(2023, 5, 26, 12, 47, 54, 184793) + + In [3]: str(datetime.datetime.fromtimestamp(1685123274.1847932)) + Out[3]: '2023-05-26 12:47:54.184793' diff --git a/docs/source/tutor/_custom_device.rst b/docs/source/tutor/_custom_device.rst new file mode 100644 index 00000000..5c091d0d --- /dev/null +++ b/docs/source/tutor/_custom_device.rst @@ -0,0 +1,623 @@ +.. index:: ! custom ophyd device + +.. https://github.com/BCDA-APS/bluesky_training/issues/42 + +.. _howto-custom-device: + +=============== +Custom Devices +=============== + +.. sidebar:: Form follows function [#fff]_ + + Integral to the implementation of a custom ``ophyd.Device`` is the + consideration of its architecture: *how is the control provided* and + *how will it be used*. + +Within the Bluesky Framework, the *bluesky* [#bluesky]_ package, orchestrates +measurements and acquires data. Hardware components from various control systems +(such as EPICS [#epics]_) connect to *bluesky* through the +the *ophyd* [#ophyd]_ package. This separation generalizes underlying control +system details of specific devices and separates them from generalized +measurement procedures. + +This document describes how to create custom *ophyd* Devices. + +.. note:: This document was written for the ophyd v1 Device + specification. [#v1_device]_ + + At the time of this writing, the ophyd v2 Device + specification [#v2_device]_ has not been released from its draft form. + +Overview +============== + +*Ophyd* is a hardware abstraction layer connects with the underlying control +system (such as EPICS). *Ophyd* describes the objects for control as either: + +- a *Signal* (a single-valued object for control) +- a *Device* (built up from Signal and/or Device components) + +Both *Signal* and *Device* are Python classes. The methods of these classes +describe actions specific to that class. Subclasses, such as `EpicsSignal` and +`EpicsMotor` augment the basic capabilities with additional capabilities. + +Reasons for a custom *ophyd* Device include: + +- groupings (such as: related metadata or a motor stage) +- modify existing Device + +.. TODO: + - custom configuration (such as area detector) + - new support + - pseudo-positioner + +These will be presented in sections below. + +Simple Devices +============== + +Perhaps the best starting point is one or two simple examples of *ophyd* +Devices. + +Hello, World! +~~~~~~~~~~~~~ + +The ``HelloDevice`` (in the `Hello, World! +`_ +notebook) is a subclass of `ophyd.Device`. + +.. code-block:: python + :linenos: + + from ophyd import Component, Device, Signal + + class HelloDevice(Device): + number = Component(Signal, value=0, kind="hinted") + text = Component(Signal, value="", kind="normal") + +``HelloDevice`` declares two ``ophyd.Signal`` (non-EPICS) components, ``number`` +and ``text``. ``hello_device`` is an instance of ``HelloDevice``: + +.. code-block:: python + :linenos: + + hello_device = HelloDevice(name="hello") + +It is required to specify the ``name`` keyword argument (also known as +':index:`kwarg`'). By convention, we use the same name as the Python `instance +`__ + +The *Hello, World!* example uses `staging +`__ to change +the component values: + +.. code-block:: python + :linenos: + + hello_device.stage_sigs["number"] = 1 + hello_device.stage_sigs["text"] = "Hello, World!" + +.. index:: staging + +When ``hello_device`` is :index:`staged` (by the bluesky RunEngine), the value +of ``hello_device.number`` will be changed from ``0`` to ``1`` and the value of +``hello_device.text`` will be changed from ``""`` to ``"Hello, World!"``. Before +finishing the run, the RunEngine will :index:`unstage` all ophyd Devices, +meaning that the previous values are restored. + +We *expect* the ``number`` component to contain numerical values and the +``text`` component to contain text values. To keep the example simple, we have +not added `type `__ hints. + +``.read()`` +^^^^^^^^^^^ + +All ophyd ``Signal`` and ``Device`` instances have a ``.read()`` [#read]_ method +which is called by data acquisition during execution of a bluesky plan. The +``.read()`` method returns a Python dictionary with the current value of each +component and the timestamp (`time +`__ in seconds since the +system *epoch*) when that value was received in Python. The *keys* of the +Python dictionary returned by ``.read()`` are the full names of each component. +Here's an example: + +.. code-block:: python + :linenos: + + In [4]: hello_device.read() + Out[4]: + OrderedDict([('hello_number', {'value': 0, 'timestamp': 1685123274.1847932}), + ('hello_text', {'value': '', 'timestamp': 1685123274.1848683})]) + +See :ref:`faq-timestamp` + +``.summary()`` +^^^^^^^^^^^^^^ + +All ophyd ``Device`` instances have a ``.summary()`` method to describe +a Device to an interactive user. Here is ``hello_device.summary()``: + +.. code-block:: python + :linenos: + + In [5]: hello_device.summary() + data keys (* hints) + ------------------- + *hello_number + hello_text + + read attrs + ---------- + number Signal ('hello_number') + text Signal ('hello_text') + + config keys + ----------- + + configuration attrs + ------------------- + + unused attrs + ------------ + +``.stage()`` +^^^^^^^^^^^^^^ + +Each device participating in a plan is staged at the beginning of data +acquisition. Here is the result of staging ``hello_device``: + +.. code-block:: python + :linenos: + + In [8]: hello_device.unstage() + Out[8]: [HelloDevice(prefix='', name='hello', read_attrs=['number', 'text'], configuration_attrs=[])] + + In [9]: hello_device.read() + Out[9]: + OrderedDict([('hello_number', {'value': 0, 'timestamp': 1685123542.713047}), + ('hello_text', {'value': '', 'timestamp': 1685123542.7126422})]) + +``.unstage()`` +^^^^^^^^^^^^^^ + +After data acquisition concludes, the participating devices are unstaged. Here +is the result of unstaging ``hello_device``: + +.. code-block:: python + :linenos: + + In [8]: hello_device.unstage() + Out[8]: [HelloDevice(prefix='', name='hello', read_attrs=['number', 'text'], configuration_attrs=[])] + + In [9]: hello_device.read() + Out[9]: + OrderedDict([('hello_number', {'value': 0, 'timestamp': 1685123542.713047}), + ('hello_text', {'value': '', 'timestamp': 1685123542.7126422})]) + +Connect with EPICS +~~~~~~~~~~~~~~~~~~ + +EPICS is a control system completely separate from Python. +``MyGroup`` (in the `Connect Bluesky with EPICS +`_ +notebook) is a subclass of `ophyd.Device` that connects with EPICS. + +When an instance of an ophyd Device is created, a common PV prefix is provided +as the first argument. This prefix is used with all EPICS components in the +class. A *reuseable* class (such as ``ophyd.EpicsMotor``) is created with this +design consideration. The prefix is provided when the instance is created. (If +there is no common prefix, then an empty string is provded.) In this example, +we have these EPICS PVs to connect: + +======================= ======================= +full PV description +======================= ======================= +``kgp:gp:bit1`` enable +``kgp:gp:float1`` setpoint +``kgp:gp:float1.EGU`` units +``kgp:gp:text1`` label +======================= ======================= + +Separating the common PV prefix, we create a ``MyGroup`` Device that connects +these PVs (using the remaining PV suffix for each). Remember to provide the +common PV prefix: + +.. code-block:: python + :linenos: + + from ophyd import Component, Device, EpicsSignal + + class MyGroup(Device): + enable = Component(EpicsSignal, "gp:bit1") + setpoint = Component(EpicsSignal, "gp:float1") + units = Component(EpicsSignal, "gp:float1.EGU") + label = Component(EpicsSignal, "gp:text1") + +``ophyd.EpicsSignal``, a variation of ``ophyd.Signal``, provides a connection +with the EPICS control system. The text argument after ``EpicsSignal`` (such as +``"gp:bit1"``) is the EPICS Process Variable (or suffix). A PV [#pv]_ is a text +identifier for a unit [#pv_intro]_ of EPICS data. EPICS is responsible for +updating the PV with new content, as directed by one or more clients, such as +*ophyd*. + +.. index:: wait_for_connection + +``.wait_for_connection()`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +We must allow some time after creating an instance, albeit short, for the +instance to connect by calling its ``wait_for_connection()`` `method +`__: + +.. code-block:: python + :linenos: + + group = MyGroup("kgp:", name="group") + group.wait_for_connection() + +.. tip:: ``wait_for_connection()`` is not always used + + For most use (such as interactive sessions), a call to an + instance's ``wait_for_connection()`` method does not *appear* + to be necessary. EPICS connections usually happen very fast, + unless a requested PV is not available. This is why you do not see + ``wait_for_connection()`` called in most library code. However, + when the instance is to be used + immediately, you should use the ``wait_for_connection()`` method + before interacting with the instance. + +.. TODO: + 'kind' + ^^^^ + Could divert and explain how the ``kind`` kwarg affects + what components are not reported with .`read()` + +``.summary()`` +^^^^^^^^^^^^^^ + +Here is ``group.summary()``: + +.. code-block:: python + :linenos: + + In [4]: group.summary() + data keys (* hints) + ------------------- + group_enable + group_label + group_setpoint + group_units + + read attrs + ---------- + enable EpicsSignal ('group_enable') + setpoint EpicsSignal ('group_setpoint') + units EpicsSignal ('group_units') + label EpicsSignal ('group_label') + + config keys + ----------- + + configuration attrs + ------------------- + + unused attrs + ------------ + +Groupings +========= + +A custom Device may be created to group several controls together as they relate +to a common object, such as a motorized stage or even an abstract object such as +undulator or monochoromator energy. A Device might refer to some other grouping +of information, such as the proposal information related to the current +measurements. Presented here are a few examples of the many possibilities. + +Neat Stage 2APD +~~~~~~~~~~~~~~~ + +.. rubric:: NEAT Stage + +The *NEAT Stage 2APD*, stage from APS station 3-ID-D, consists of +three motorized axes, as described in the next table. + +============== =========== ====================== +axis name EPICS PV description +============== =========== ====================== +:math:`x` ``3idd:m1`` horizontal translation +:math:`y` ``3idd:m2`` vertical translation +:math:`\theta` ``3idd:m3`` rotation +============== =========== ====================== + +.. image:: ../_static/neat_stage_2apd.png + :width: 80% + +Since each of these axes are EPICS motors, we'll use ``ophyd.EpicsMotor`` +[#epics_motor]_ to connect with the rich set of EPICS controls for each: + +.. code-block:: python + :linenos: + + from ophyd import Component, Device, EpicsMotor + + class NeatStage_3IDD(Device): + x = Component(EpicsMotor, "m1", labels=("NEAT stage",)) + y = Component(EpicsMotor, "m2", labels=("NEAT stage",)) + theta = Component(EpicsMotor, "m3", labels=("NEAT stage",)) + + neat_stage = NeatStage_3IDD("3idd:", name="neat_stage") + +APS Undulator +~~~~~~~~~~~~~~~ + +In the *apstools* [#apstools]_ package, the `ApsUndulator +`_ +Device groups the EPICS PVs into Device. This makes it easy to access useful +controls such as ``undulator.energy``, and to record the undulator configuration +for data acquisition. + +.. code-block:: python + :linenos: + + from ophyd import Component, Device, EpicsSignal + + class ApsUndulator(Device): + """ + APS Undulator + + EXAMPLE:: + + undulator = ApsUndulator("ID09ds:", name="undulator") + """ + + energy = Component(EpicsSignal, "Energy", write_pv="EnergySet", put_complete=True, kind="hinted") + energy_taper = Component(EpicsSignal, "TaperEnergy", write_pv="TaperEnergySet", kind="config") + gap = Component(EpicsSignal, "Gap", write_pv="GapSet") + gap_taper = Component(EpicsSignal, "TaperGap", write_pv="TaperGapSet", kind="config") + start_button = Component(EpicsSignal, "Start", put_complete=True, kind="omitted") + stop_button = Component(EpicsSignal, "Stop", kind="omitted") + harmonic_value = Component(EpicsSignal, "HarmonicValue", kind="config") + gap_deadband = Component(EpicsSignal, "DeadbandGap", kind="config") + device_limit = Component(EpicsSignal, "DeviceLimit", kind="config") + # ... more + +APS Dual Undulator +~~~~~~~~~~~~~~~~~~ + +The APS Dual Undulator consists of two APS Undulator devices, installed +end-to-end in the storage ring. The two devices are referred to as *upstream* +and *downstream*, as described in the next table. + +============== ================= ================== +undulator name EPICS PV (prefix) description +============== ================= ================== +us ``45ID:us:`` upstream undulator +ds ``45ID:ds:`` downstream undulator +============== ================= ================== + +Keep in mind that the overall prefix `45ID:` will be provided when the Python +object is created (below). In the ``ApsUndulatorDual`` class below, the +combined prefix of ``45ID:us:`` will be passed to the upstream undulator. +Similarly, ``45ID:ds:`` for the downstream undulator. + +.. code-block:: python + :linenos: + + class ApsUndulatorDual(Device): + upstream = Component(ApsUndulator, "us:") + downstream = Component(ApsUndulator, "ds:") + +Now, create the Python object for the dual APS Undulator controls: + +.. code-block:: python + :linenos: + + undulator = ApsUndulatorDual("45ID:", name="undulator") + +The undulator energy of each is accessed by ``undulator.us.energy.get()`` and +``undulator.ds.energy.get()``. + +.. index:: mixin device + +Modify existing Device +====================== + +Sometimes, a *standard* device is missing a feature, such as connection with an +additional field (or fields) in an EPICS record. A *mixin* class can modify +a class by providing additional structures and/or methods. +The *apstools* package provides mixin classes [#apstools_mixins]_ for fields common to +various EPICS records types. + +.. tip:: An advantage to using these custom *mixin* classes is that all these + additional fields and methods will have consistent names. This simplifies + both data acquisition and + the process of searching and matching acquired data in the database. + +For example, we might want to define a new feature that is not yet present in +*ophyd*. Here, we define a ``home_value`` component. The position can be +either preset or changed programmatically. + +.. code-block:: python + :linenos: + + from ophyd import Component, Device, Signal + + class HomeValue(Device): + home_value = Component(Signal) + +We can use ``HomeValue()`` as a *mixin* class to modify (actually, create a +variation of) the ``MyGroup`` (above): + +.. code-block:: python + :linenos: + + class MyGroupWithHome(HomeValue, MyGroup): + """MyGroup with known home value.""" + +Create an instance and view its `.summary()`: + +.. code-block:: python + :linenos: + + In [23]: group = MyGroupWithHome("kgp:", name="group") + + In [24]: group.summary() + data keys (* hints) + ------------------- + group_enable + group_home_value + group_label + group_setpoint + group_units + + read attrs + ---------- + enable EpicsSignal ('group_enable') + setpoint EpicsSignal ('group_setpoint') + units EpicsSignal ('group_units') + label EpicsSignal ('group_label') + home_value Signal ('group_home_value') + + config keys + ----------- + + configuration attrs + ------------------- + + unused attrs + ------------ + +Compare this most recent summary with the previous one. Note the ``home_value`` +Signal. + +.. note:: A Device can define (or replace) methods, too. + + The ``apstools.synApps.EpicsSynAppsRecordEnableMixin`` mixin + [#apstools_epics_mixins]_ includes a method in addition to a new component. + +EPICS ``ai`` & ``ao`` Records +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +One variation might be recognizing that all of the PVs are the same (or similar) +EPICS record type, such as EPICS ``ai`` and ``ao`` records. These records are +all floating point PVs which share many extra fields. The difference is that +``ai`` records are read-only while ``ao`` records can be changed from Bluesky. +The extra fields follow two common EPICS patterns: + +- fields common to all EPICS records +- fields common to EPICS floating-point value records + +Support for these common fields [#epics_common_fields]_ is provided in the +*apstools* [#apstools]_ package. Make custom Devices including the additional +configuration support from apstools. Like this: + +.. code-block:: python + :linenos: + + from apstools.synApps import EpicsRecordDeviceCommonAll + from apstools.synApps import EpicsRecordFloatFields + from ophyd import Component, Device, EpicsSignal, EpicsSignalRO + + class EpicsAiRecord(EpicsRecordFloatFields, EpicsRecordDeviceCommonAll): + signal = Component(EpicsSignalRO, ".VAL") # read-only + + class EpicsAoRecord(EpicsRecordFloatFields, EpicsRecordDeviceCommonAll): + signal = Component(EpicsSignal, ".VAL") # read & write + +This gives you many, many additional fields with standard names, such as: + +.. code-block:: python + :linenos: + + description = Component(EpicsSignal, ".DESC", kind="config") + processing_active = Component(EpicsSignalRO, ".PACT", kind="omitted") + scanning_rate = Component(EpicsSignal, ".SCAN", kind="config") + disable_value = Component(EpicsSignal, ".DISV", kind="config") + scan_disable_input_link_value = Component(EpicsSignal, ".DISA", kind="config") + scan_disable_value_input_link = Component(EpicsSignal, ".SDIS", kind="config") + process_record = Component(EpicsSignal, ".PROC", kind="omitted", put_complete=True) + forward_link = Component(EpicsSignal, ".FLNK", kind="config") + trace_processing = Component(EpicsSignal, ".TPRO", kind="omitted") + device_type = Component(EpicsSignalRO, ".DTYP", kind="config") + + + alarm_status = Component(EpicsSignalRO, ".STAT", kind="config") + alarm_severity = Component(EpicsSignalRO, ".SEVR", kind="config") + new_alarm_status = Component(EpicsSignalRO, ".NSTA", kind="config") + new_alarm_severity = Component(EpicsSignalRO, ".NSEV", kind="config") + disable_alarm_severity = Component(EpicsSignal, ".DISS", kind="config") + + units = Component(EpicsSignal, ".EGU", kind="config") + precision = Component(EpicsSignal, ".PREC", kind="config") + + monitor_deadband = Component(EpicsSignal, ".MDEL", kind="config") + +To use these custom Devices, consider a hypothetical controller with these +controls. + +=========== ========= ============ ================= +signal direction EPICS PV description +=========== ========= ============ ================= +pressure input ``ioc:ai4`` pressure gauge +temperature input ``ioc:ai2`` thermocouple +flow output ``ioc:ao12`` flow control +voltage output ``ioc:ao13`` applied voltage +=========== ========= ============ ================= + +Recognize that all these EPICS PVs share a common prefix: ``ioc:``. +Define the custom Device: + +.. code-block:: python + :linenos: + + class MyController(Device): + pressure = Component(EpicsAiRecord, "ai4") + temperature = Component(EpicsAiRecord, "ai2") + flow = Component(EpicsAoRecord, "ao12") + voltage = Component(EpicsAoRecord, "ao13") + +Create the Python object with the common prefix: + +.. code-block:: python + :linenos: + + # create the Python object: + controller = MyController("ioc:", name="controller") + +.. TODO: + Custom configurations + ====================== + + such as area detector + + .. TODO + + New support + ====================== + + .. TODO + + Pseudo-positioner + ====================== + + .. TODO + +------------- + +.. rubric:: Footnotes + +.. [#apstools_epics_mixins] https://bcda-aps.github.io/apstools/latest/_modules/apstools/synApps/_common.html#EpicsSynAppsRecordEnableMixin +.. [#apstools_mixins] ``apstools.synApps`` mixin classes: https://github.com/BCDA-APS/apstools/blob/b9d959cd7beb70994b0fc2fca0f344ef160f9849/apstools/synApps/_common.py#L25-L109 +.. [#apstools] *apstools* : https://bcda-aps.github.io/apstools/latest/ +.. [#bluesky] *bluesky* : https://blueskyproject.io/bluesky +.. [#epics] EPICS : https://epics-controls.org +.. [#epics_common_fields] EPICS common fields : https://bcda-aps.github.io/apstools/latest/api/synApps/__common.html +.. [#epics_motor] ``EpicsMotor``: https://blueskyproject.io/ophyd/builtin-devices.html?highlight=epicsmotor#epicsmotor +.. [#fff] Form follows function : https://en.wikipedia.org/wiki/Form_follows_function +.. [#ophyd] *ophyd* : https://blueskyproject.io/ophyd +.. [#pv_intro] PV introduction: https://docs.epics-controls.org/en/latest/specs/ca_protocol.html?highlight=Process%20Variable#process-variables +.. [#pv] PV: https://docs.epics-controls.org/en/latest/guides/EPICS_Intro.html#appendix-objects-vs-process-variables-discussion +.. [#read] ``.read()``: https://blueskyproject.io/ophyd/user_v1/tutorials/single-PV.html#read +.. [#v1_device] *ophyd* v1 Device : https://blueskyproject.io/ophyd/user_v1/tutorials/device.html#define-a-custom-device +.. [#v2_device] *ophyd* v2 Device : https://blueskyproject.io/ophyd/user_v2/how-to/make-a-simple-device.html diff --git a/docs/source/tutor/index.rst b/docs/source/tutor/index.rst index 2631470b..d0408a34 100644 --- a/docs/source/tutor/index.rst +++ b/docs/source/tutor/index.rst @@ -15,6 +15,7 @@ Tutorials Tutorials are lessons that take the reader by the hand through a series of steps to complete a project of some kind. Tutorials are *learning-oriented*. +Tutorials often involve more depth than HowTo guides. .. NOTE Likely there is duplication here to be resolved.