- StringIO.writeline sends a command and does not expect a reply - StringIO.multicomm and BytesIO.multicomm is improved in order to insert individual delays in between lines and individual noreply flags + fix a bug in tutorial_t_control + improve readability of frappy.lib.classdoc.indent_description Change-Id: I9dea113e19147684ec41aca5267a79816bbf202c Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/32267 Reviewed-by: Enrico Faulhaber <enrico.faulhaber@frm2.tum.de> Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de> Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch>
17 KiB
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 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:
# 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 HasIO <frappy.io.HasIO> in order to add the
communicate method to
the class.
See lsc_manual_extract for details of the needed
commands.
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 Property <frappy.properties.Property> is used
instead of a 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:
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. 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 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:
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:
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:
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, textAfter 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 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.
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 targetIn order to test this, we will need to change the entry module
T in the configuration file:
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_rangeis added for this. - We want to handle the status code correctly: set to
BUSYwhen the target is changed, and back toIDLEwhen the target temperature is reached. The parametertoleranceis 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.
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, textFinally, the config file would be:
cfg/example_cryo_cfg.py:
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 frappy_demo.lakeshore.TemperatureLoop.
Appendix 2: Extract from the LakeShore Manual
| 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 |
|
| 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 |
|