Files
frappy/doc/source/tutorial_t_control.rst
Markus Zolliker 158477792f add StringIO.writeline, improve StringIO.multicomm
- 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>
2024-01-29 13:34:45 +01:00

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, 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 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 target

In 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_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.
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:

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

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