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 `. 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_*. 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 ` 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 ` is used instead of a :class:`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 ` 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 ` 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?`` 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, ''`` 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 `. 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 ,,/