{ "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_3", "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 }