reading back the target does not work properly, because a) the readback value might be delayed b) there is no command to read back the target, SETP?1 is returning the working setpoint, which might be distinct in case of a ramp Change-Id: I0da2dbfc1a8ddbecbae6d0456ff64e008bc56336 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31983 Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de> Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch>
413 lines
17 KiB
ReStructuredText
413 lines
17 KiB
ReStructuredText
A Simple Temperature Controller
|
|
===============================
|
|
|
|
The Use Case
|
|
------------
|
|
|
|
Let us assume we have simple cryostat or furnace with one temperature sensor
|
|
and a heater. We want first to implement reading the temperature and then
|
|
add the control loop. Assume also we have a LakeShore temperature controller
|
|
to access the hardware.
|
|
|
|
|
|
Coding the Sensor Module
|
|
------------------------
|
|
|
|
A temperature sensor without control loop is to be implemented as a subclass
|
|
of :class:`Readable <frappy.modules.Readable>`. You create this example to be used in your
|
|
facility, so you add it to the subdirectory of your facility. You might need
|
|
to create it, if it is not already there. In this example, you may
|
|
replace *frappy_psi* by *frappy_<your facility>*. The name the python file
|
|
is chosen from the type of temperature controller *lakeshore.py*.
|
|
|
|
We assume that the temperature controller is already configured with input ``A``
|
|
being used, and the proper calibration curve assigned. In productive code
|
|
this configuration may also be done by Frappy, but this would extend the scope
|
|
of this tutorial too much.
|
|
|
|
So we define a class and define the parameter properties for the value:
|
|
|
|
``frappy_psi/lakeshore.py``:
|
|
|
|
.. code:: python
|
|
|
|
# the most common Frappy classes can be imported from frappy.core
|
|
from frappy.core import Readable, Parameter, FloatRange
|
|
|
|
class TemperatureSensor(Readable):
|
|
"""a temperature sensor (generic for different models)"""
|
|
# 1500 is the maximum T allowed for most of the lakeshore models
|
|
# this should be further restricted in the configuration (see below)
|
|
value = Parameter(datatype=FloatRange(0, 1500, unit='K'))
|
|
|
|
|
|
For the next step, we have to code how to retrieve the temperature
|
|
from the controller. For this we add the method ``read_value``.
|
|
In addition, we have to define a communicator class, and make
|
|
``TemperatureSensor`` inherit from :class:`HasIO <frappy.io.HasIO>`
|
|
in order to add the :meth:`communicate` method to the class.
|
|
|
|
See :ref:`lsc_manual_extract` for details of the needed commands.
|
|
|
|
|
|
.. code:: python
|
|
|
|
from frappy.core import Readable, Parameter, FloatRange, HasIO, StringIO, Property, StringType
|
|
|
|
class LakeshoreIO(StringIO):
|
|
wait_before = 0.05 # Lakeshore requires a wait time of 50 ms between commands
|
|
# '*IDN?' is sent on connect, and the reply is checked to match the regexp 'LSCI,.*'
|
|
identification = [('*IDN?', 'LSCI,.*')]
|
|
|
|
class TemperatureSensor(HasIO, Readable):
|
|
"""a temperature sensor (generic for different models)"""
|
|
# internal property to configure the channel
|
|
# see below for the difference of 'Property' and 'Parameter'
|
|
channel = Property('the Lakeshore channel', datatype=StringType())
|
|
# 0, 1500 is the allowed range by the LakeShore controller
|
|
# this should be further restricted in the configuration (see below)
|
|
value = Parameter(datatype=FloatRange(0, 1500, unit='K'))
|
|
|
|
def read_value(self):
|
|
# the communicate method sends a command and returns the reply
|
|
reply = self.communicate(f'KRDG?{self.channel}')
|
|
# convert to float
|
|
return float(reply)
|
|
|
|
|
|
This is the code to run a minimalistic SEC Node, which does just read a temperature
|
|
and nothing else.
|
|
|
|
.. Note::
|
|
|
|
A :class:`Property <frappy.properties.Property>` is used instead of a
|
|
:class:`Parameter <frappy.param.Parameter>`, for a configurable item not changing
|
|
on run time. A ``Property`` is typically only internal needed and by default not
|
|
visible by SECoP.
|
|
|
|
|
|
Before we start the frappy server for the first time, we have to create a configuration file.
|
|
The directory tree of the Frappy framework contains the code for all drivers but the
|
|
configuration file determines, which code will be loaded when a server is started.
|
|
We choose the name *example_cryo* and create therefore a configuration file
|
|
*example_cryo_cfg.py* in the *cfg* subdirectory:
|
|
|
|
``cfg/example_cryo_cfg.py``:
|
|
|
|
.. code:: python
|
|
|
|
Node('example_cryo.psi.ch', # a globally unique identification
|
|
'this is an example cryostat for the Frappy tutorial', # describes the node
|
|
interface='tcp://10767') # you might choose any port number > 1024
|
|
Mod('io', # the name of the module
|
|
'frappy_psi.lakeshore.LakeshoreIO', # the class used for communication
|
|
'communication to main controller', # a description
|
|
# the serial connection, including serial settings (see frappy.io.IOBase):
|
|
uri='serial://COM6:?baudrate=57600+parity=odd+bytesize=7',
|
|
)
|
|
Mod('T',
|
|
'frappy_psi.lakeshore.TemperatureSensor',
|
|
'Sample Temperature',
|
|
io='io', # refers to above defined module 'io'
|
|
channel='A', # the channel on the LakeShore for this module
|
|
value=Param(max=470), # alter the maximum expected T
|
|
)
|
|
|
|
The first section in the configuration file configures the common settings for the server.
|
|
:ref:`Node <node configuration>` describes the main properties of the SEC Node: an identifier,
|
|
which should be globally unique, a description of the node, and an interface defining the server address.
|
|
Usually the only important value in the server address is the TCP port under which the
|
|
server will be accessible. Currently only the tcp scheme is supported.
|
|
|
|
Then for each module a :ref:`Mod <mod configuration>` section follows.
|
|
We have to create the ``io`` module for communication first, with
|
|
the ``uri`` as its most important argument.
|
|
In case of a serial connection the prefix is ``serial://``. On a Windows machine, the full
|
|
uri is something like ``serial://COM6:?baudrate=9600`` on a linux system it might be
|
|
``serial:///dev/ttyUSB0?baudrate=9600``. In case of a LAN connection, the uri should
|
|
be something like ``tcp://129.129.138.78:7777`` or ``tcp://mydevice.psi.ch:7777``, where
|
|
7777 is the tcp port the LakeShore is listening to.
|
|
|
|
Now, we are ready to start our first server. In the main frappy directory, we
|
|
start it with:
|
|
|
|
.. code::
|
|
|
|
python bin/frappy-server example_cryo
|
|
|
|
If error messages appear, you have first to try to fix the errors.
|
|
Else you might open an other console or terminal, in order to start
|
|
a frappy client, for example the GUI client. The argument is
|
|
compose by the machine running the server and the server port chosen
|
|
in the configuration file:
|
|
|
|
.. code::
|
|
|
|
python bin/frappy-gui localhost:10767
|
|
|
|
|
|
A ``Readable`` SECoP module also has a status parameter. Until now, we completely
|
|
ignored it. As you may see, the value of status parameter is always ``(IDLE, '')``.
|
|
However, we should implement the status parameter to give information about the
|
|
validity of the sensor reading. The controller has a query command ``RDGST?<channel>``
|
|
returning a code describing error states. We implement this by adding a the
|
|
``read_status`` method to the class:
|
|
|
|
.. code:: python
|
|
|
|
from frappy.core import Readable, Parameter, FloatRange, HasIO, StringIO, Property, StringType,\
|
|
IDLE, ERROR
|
|
|
|
...
|
|
|
|
class TemperatureSensor(HasIO, Readable):
|
|
|
|
...
|
|
|
|
def read_status(self):
|
|
code = int(self.communicate(f'RDGST?{self.channel}'))
|
|
if code >= 128:
|
|
text = 'units overrange'
|
|
elif code >= 64:
|
|
text = 'units zero'
|
|
elif code >= 32:
|
|
text = 'temperature overrange'
|
|
elif code >= 16:
|
|
text = 'temperature underrange'
|
|
elif code % 2:
|
|
# ignore 'old reading', as this may happen in normal operation
|
|
text = 'invalid reading'
|
|
else:
|
|
return IDLE, ''
|
|
return ERROR, text
|
|
|
|
After a restart of the server and the client, the status should change to
|
|
``ERROR, '<some error message>'`` when the sensor is unplugged.
|
|
|
|
|
|
Extend the Class to a Temperature Loop
|
|
--------------------------------------
|
|
|
|
As we want to implement also temperature control, we have extend the class more.
|
|
Instead of adding just more methods to the ``TemperatureSensor`` class, we
|
|
create a new class ``TemperatureLoop`` inheriting from Temperature sensor.
|
|
This way, we would for example be able to create a node with a controlled
|
|
temperature on one channel, and a sensor module without control on an other channel.
|
|
|
|
Temperature control is represented by a subclass of :class:`Drivable <frappy.modules.Drivable>`.
|
|
So our new class will be based on ``TemperatureSensor`` where we have already
|
|
implemented the readable stuff. We need to define some properties of the ``target``
|
|
parameter and add a property ``loop`` indicating, which control loop and
|
|
heater output we use.
|
|
|
|
In addition, we have to implement the method ``write_target``. Remark: we do not
|
|
implement ``read_target`` here, because the lakeshore does not offer to read back the
|
|
real target. The SETP command is returning the working setpoint, which may be distinct
|
|
from target during a ramp.
|
|
|
|
.. code:: python
|
|
|
|
from frappy.core import Readable, Parameter, FloatRange, HasIO, StringIO, Property, StringType,\
|
|
IDLE, BUSY, WARN, ERROR, Drivable, IntRange
|
|
|
|
...
|
|
|
|
class TemperatureLoop(TemperatureSensor, Drivable):
|
|
# lakeshore loop number to be used for this module
|
|
loop = Property('lakeshore loop', IntRange(1, 2), default=1)
|
|
target = Parameter(datatype=FloatRange(unit='K', min=0, max=1500))
|
|
|
|
def write_target(self, target):
|
|
# we always use a request / reply scheme
|
|
self.communicate(f'SETP {self.loop},{target};*OPC?')
|
|
return target
|
|
|
|
|
|
In order to test this, we will need to change the entry module ``T`` in the
|
|
configuration file:
|
|
|
|
.. code:: python
|
|
|
|
Mod('T',
|
|
'frappy_psi.lakeshore.TemperatureLoop',
|
|
'Sample Temperature',
|
|
io='io',
|
|
channel='A', # the channel on the LakeShore for this module
|
|
loop=1, # the loop to be used
|
|
value=Param(max=470), # set the maximum expected T
|
|
target=Param(max=420), # set the maximum allowed target T
|
|
)
|
|
|
|
To test that this step worked, just restart the server and the client.
|
|
If the temperature controller is not yet configured for controlling the
|
|
temperature on channel A with loop 1, this has to be done first.
|
|
Especially the heater has to be switched on, setting the maximum heater
|
|
range.
|
|
|
|
There are two things still missing:
|
|
|
|
- We want to switch on the heater automatically, when the target is changed.
|
|
A property ``heater_range`` is added for this.
|
|
- We want to handle the status code correctly: set to ``BUSY`` when the
|
|
target is changed, and back to ``IDLE`` when the target temperature is reached.
|
|
The parameter ``tolerance`` is used for this. For the tutorial we use here
|
|
a rather simple mechanism. In reality, often over- or undershoot happens.
|
|
A better algorithm would not switch to IDLE before the temperature was within
|
|
tolerance for some given time.
|
|
|
|
|
|
.. code:: python
|
|
|
|
from frappy.core import Readable, Drivable, Parameter, FloatRange, \
|
|
HasIO, StringIO, IDLE, BUSY, WARN, ERROR
|
|
|
|
...
|
|
|
|
class TemperatureLoop(TemperatureSensor, Drivable):
|
|
...
|
|
heater_range = Property('heater power range', IntRange(0, 5)) # max. 3 on LakeShore 336
|
|
tolerance = Parameter('convergence criterion', FloatRange(0), default=0.1, readonly=False)
|
|
_driving = False
|
|
...
|
|
|
|
def write_target(self, target):
|
|
# reactivate heater in case it was switched off
|
|
self.communicate(f'RANGE {self.loop},{self.heater_range};RANGE?{self.loop}')
|
|
self.communicate(f'SETP {self.loop},{target};*OPC?')
|
|
self._driving = True
|
|
# Setting the status attribute triggers an update message for the SECoP status
|
|
# parameter. This has to be done before returning from this method!
|
|
self.status = BUSY, 'target changed'
|
|
return target
|
|
...
|
|
|
|
def read_status(self):
|
|
code = int(self.communicate(f'RDGST?{self.channel}'))
|
|
if code >= 128:
|
|
text = 'units overrange'
|
|
elif code >= 64:
|
|
text = 'units zero'
|
|
elif code >= 32:
|
|
text = 'temperature overrange'
|
|
elif code >= 16:
|
|
text = 'temperature underrange'
|
|
elif code % 2:
|
|
# ignore 'old reading', as this may happen in normal operation
|
|
text = 'invalid reading'
|
|
elif abs(self.target - self.value) > self.tolerance:
|
|
if self._driving:
|
|
return BUSY, 'approaching setpoint'
|
|
return WARN, 'temperature out of tolerance'
|
|
else: # within tolerance: simple convergence criterion
|
|
self._driving = False
|
|
return IDLE, ''
|
|
return ERROR, text
|
|
|
|
|
|
Finally, the config file would be:
|
|
|
|
``cfg/example_cryo_cfg.py``:
|
|
|
|
.. code:: python
|
|
|
|
Node('example_cryo.psi.ch', # a globally unique identification
|
|
'this is an example cryostat for the Frappy tutorial', # describes the node
|
|
interface='tcp://10767') # you might choose any port number > 1024
|
|
Mod('io', # the name of the module
|
|
'frappy_psi.lakeshore.LakeshoreIO', # the class used for communication
|
|
'communication to main controller', # a description
|
|
uri='serial://COM6:?baudrate=57600+parity=odd+bytesize=7', # the serial connection
|
|
)
|
|
Mod('T',
|
|
'frappy_psi.lakeshore.TemperatureLoop',
|
|
'Sample Temperature',
|
|
io='io',
|
|
channel='A', # the channel on the LakeShore for this module
|
|
loop=1, # the loop to be used
|
|
value=Param(max=470), # set the maximum expected T
|
|
target=Param(max=420), # set the maximum allowed target T
|
|
heater_range=3, # 5 for model 350
|
|
)
|
|
|
|
|
|
Now, you should try again restarting the server and the client, if it works, you have done a good job!
|
|
If not, you might need to fix the code first ...
|
|
|
|
|
|
More Complex Configurations
|
|
...........................
|
|
|
|
Without coding any more class, much more complex situations might be realized just by
|
|
extending the configuration. Using a single LakeShore controller, you might add more
|
|
temperature sensors or (in the case of Model 336 or 350) even a second temperature loop,
|
|
just by adding more ``Mod(`` sections to the configuration file. In case more than 4 channels
|
|
are needed, an other module ``io2`` has to be added for the second controller and so on.
|
|
|
|
|
|
Appendix 1: The Solution
|
|
------------------------
|
|
|
|
You will find the full solution code via the ``[source]`` link in the automatic
|
|
created documentation of the class :class:`frappy_demo.lakeshore.TemperatureLoop`.
|
|
|
|
|
|
|
|
.. _lsc_manual_extract:
|
|
|
|
Appendix 2: Extract from the LakeShore Manual
|
|
---------------------------------------------
|
|
|
|
.. table:: commands used in this tutorial
|
|
|
|
====================== =======================
|
|
**Query Identification**
|
|
----------------------------------------------
|
|
Command \*IDN? *term*
|
|
Reply <manufacturer>,<model>,<instrument serial>/<option serial>, <firmware version> *term*
|
|
Example LSCI,MODEL336,1234567/1234567,1.0
|
|
**Query Kelvin Reading for an Input**
|
|
----------------------------------------------
|
|
Command KRDG?<input> *term*
|
|
Example KRDG?A
|
|
Reply <kelvin value> *term*
|
|
Example +273.15
|
|
**Query Input Status**
|
|
----------------------------------------------
|
|
Command RDGST?<input> *term*
|
|
Reply <status bit weighting> *term*
|
|
Description The integer returned represents the sum of the bit weighting \
|
|
of the input status flag bits. A “000” response indicates a valid reading is present.
|
|
Bit / Value Status
|
|
0 / 1 invalid reading
|
|
1 / 2 old reading (Model 340 only)
|
|
4 / 16 temperature underrange
|
|
5 / 32 temperature overrange
|
|
6 / 64 sensor units zero
|
|
7 / 128 sensor units overrange
|
|
**Set Control Loop Setpoint**
|
|
----------------------------------------------
|
|
Command SETP <loop>,<value> *term*
|
|
Example SETP 1,273.15
|
|
**Query Control Loop Setpoint**
|
|
----------------------------------------------
|
|
Command SETP?<loop> *term*
|
|
Reply <value> *term*
|
|
Example +273.15
|
|
**Set Heater Range**
|
|
----------------------------------------------
|
|
Command (340) RANGE <range number> *term*
|
|
Command (336/350) RANGE <loop>,<range number> *term*
|
|
Description 0: heater off, 1-5: heater range (Model 336: 1-3)
|
|
**Query Heater Range**
|
|
----------------------------------------------
|
|
Command (340) RANGE? *term*
|
|
Command (336/350) RANGE?<loop> *term*
|
|
Reply <range> *term*
|
|
**Operation Complete Query**
|
|
----------------------------------------------
|
|
Command *OPC?
|
|
Reply 1
|
|
Description in Frappy, we append this command to request in order
|
|
to generate a reply
|
|
====================== =======================
|