T controller tutorial and improve documentation

add tutorial for Berlin hands-on workshop

+ improve the documentation (hints for structure welcome)
+ remove 'optional' parameter property
  (is not yet used - should not appear in doc)
+ added test property in frappy_demo.cryo alters Parameter class
  ('test' property appears in Parameter doc)

Change-Id: I3ea08f955a92f72451fd23a5ff00d1185c7fb00e
This commit is contained in:
zolliker 2023-01-26 16:34:48 +01:00
parent a39db9a35d
commit e1d5170a90
21 changed files with 956 additions and 126 deletions

View File

@ -58,7 +58,7 @@ master_doc = 'index'
# General information about the project.
project = 'Frappy'
copyright = '2017-2021, Enrico Faulhaber, Markus Zolliker,'
copyright = '2017-2023, Enrico Faulhaber, Markus Zolliker,'
#copyright = '2017, SECoP Committee'
author = 'Enrico Faulhaber, Markus Zolliker'
@ -211,7 +211,7 @@ epub_exclude_files = ['search.html']
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'https://docs.python.org/3/': None}
# intersphinx_mapping = {'https://docs.python.org/3/': None}
from frappy.lib.classdoc import class_doc_handler

View File

@ -0,0 +1,58 @@
Configuration File
..................
.. _node configuration:
:Node(equipment_id, description, interface, \*\*kwds):
Specify the SEC-node properties.
The arguments are SECoP node properties and additional internal node configurations
:Parameters:
- **equipment_id** - a globally unique string identifying the SEC node
- **description** - a human readable description of the SEC node
- **interface** - an uri style string indication the address for the server
- **kwds** - other SEC node properties
.. _mod configuration:
:Mod(name, cls, description, \*\*kwds):
Create a SECoP module.
Keyworded argument matching a parameter name are used to configure
the initial value of a parameter. For configuring the parameter properties
the value must be an instance of **Param**, using the keyworded arguments
for modifying the default values of the parameter properties. In this case,
the initial value may be given as the first positional argument.
In case command properties are to be modified **Command** has to be used.
:Parameters:
- **name** - the module name
- **cls** - a qualified class name or the python class of a module
- **description** - a human readable description of the module
- **kwds** - parameter, property or command configurations
.. _param configuration:
:Param(value=<undef>, \*\*kwds):
Configure a parameter
:Parameters:
- **value** - if given, the initial value of the parameter
- **kwds** - parameter or datatype SECoP properties (see :class:`frappy.param.Parameter`
and :class:`frappy.datatypes.Datatypes`)
.. _command configuration:
:Command(\*\*kwds):
Configure a command
:Parameters:
- **kwds** - command SECoP properties (see :class:`frappy.param.Commands`)

View File

@ -8,3 +8,7 @@ Demo
.. automodule:: frappy_demo.test
:show-inheritance:
:members:
.. automodule:: frappy_demo.lakeshore
:show-inheritance:
:members:

View File

@ -5,6 +5,10 @@ Frappy Programming Guide
:maxdepth: 2
introduction
structure
programming
magic
server
tutorial
reference
frappy_psi

View File

@ -57,14 +57,30 @@ to provide to the user.
Programming a Driver
--------------------
Programming a driver means extending one of the base classes like :class:`frappy.modules.Readable`
or :class:`frappy.modules.Drivable`. The parameters are defined in the dict :py:attr:`parameters`, as a
class attribute of the extended class, using the :class:`frappy.params.Parameter` constructor, or in case
of altering the properties of an inherited parameter, :class:`frappy.params.Override`.
:ref:`Programming a driver <class_coding>` means:
- selecting a base class to be extended (e.g. :class:`frappy.modules.Readable`
or :class:`frappy.modules.Drivable`).
- defining the parameters
- coding the methods to retrieve and access these parameters
Support for Communication with the Hardware
-------------------------------------------
Often the access to the hardware has to be done over a serial communication over LAN,
RS232 or USB. The mixin :class:`frappy.io.HasIO` and the classes :class:`frappy.io.StringIO`
and :class:`frappy.io.BytesIO` have all the functionality needed for this.
Some hardware also requires calls to libraries offered by the manufacturers, certainly this
is also possible. In case there is no python package for this, but a C/C++ API, you might
use one of the following:
- ``Ctypes (A foreign function library for Python) <https://docs.python.org/3/library/ctypes.html>``
- ``CFFI (C Foreign Function Interface for Python) <https://cffi.readthedocs.io/>``
- ``Extending Python with C or C++ <https://docs.python.org/3/extending/extending.html>``
.. TODO: shift this to an extra section
Parameters usually need a method :meth:`read_<name>()`
implementing the code to retrieve their value from the hardware. Writeable parameters
(with the argument ``readonly=False``) usually need a method :meth:`write_<name>(<value>)`
implementing how they are written to the hardware. Above methods may be omitted, when
there is no interaction with the hardware involved.

61
doc/source/magic.rst Normal file
View File

@ -0,0 +1,61 @@
Frappy Internals
----------------
Frappy is a powerful framework, which does everything behind the
scenes you need for getting a SEC node to work. This section describes
what the framwork does for you.
Startup
.......
TODO: describe startup: init methods, first polls
.. _polling:
Polling
.......
By default, a module inheriting from :class:`Readable <frappy.modules.Readable>` is
polled every :attr:`pollinterval` seconds. More exactly, the :meth:`doPoll`
method is called, which by default calls :meth:`read_value` and :meth:`read_status`.
The programmer might override the behaviour of :meth:`doPoll`, often it is wise
to super call the inherited method.
:Note:
Even for modules not inheriting from :class:`Readable <frappy.modules.Readable>`,
:meth:`doPoll` is called regularly. Its default implementation is doing nothing,
but may be overridden to do customized polling.
In addition, the :meth:`read_<param>` method is called every :attr:`slowinterval`
seconds for all parameters, in case the value was not updated since :attr:`pollinterval`
seconds.
The decorator :func:`nopoll <frappy.rwhandler.nopoll>` might be used on a :meth:`read_<param>`
method in order to indicate, that the value is not polled by the slow poll mechanism.
.. _client notification:
Client Notification
...................
Whenever a parameter is changed by assigning a value to the attribute or by
means of the access method, an ``update`` message is sent to all activated clients.
Frappy implements the extended version of the ``activate`` message, where single modules
and parameters might be activated.
.. _type check:
Type check and type conversion
..............................
Assigning a parameter to a value by setting the attribute via ``self.<param> = <value>``
or ``<module>.<param> = <value>`` involves a type check and possible a type conversion,
but not a range check for numeric types. The range check is only done on a ``change``
message.
TODO: error handling, logging

105
doc/source/programming.rst Normal file
View File

@ -0,0 +1,105 @@
Coding
======
.. _class_coding:
Coding a Class for a SECoP Module
---------------------------------
A SECoP module is represented as an instance of a python class.
For programming such a class, typically you create a
subclass of one of the base classes :class:`Readable <frappy.modules.Readable>`,
:class:`Writable <frappy.modules.Writable>` or :class:`Drivable <frappy.modules.Drivable>`.
It is also quite common to inherit from classes created for similar modules,
and or to inherit from a mixin class like :class:`HasIO <frappy.io.HasIO>`.
For creating the :ref:`parameters <module structure parameters>`,
class attributes are used, using the name of
the parameter as the attribute name and an instantiation of :class:`frappy.params.Parameter`
for defining the parameter. If a parameter is already given by an inherited class,
the parameter declaration might be omitted, or just its altered properties
have to be given.
In addition, you might need one or several configurable items
(see :ref:`properties <module structure properties>`), declared in the same way, with
``<property name> =`` :class:`frappy.params.Property` ``(...)``.
For each of the parameters, the behaviour has to be programmed with the
following access methods:
def read\_\ *<parameter>*\ (self):
Called on a ``read`` SECoP message and whenever the internal poll mechanism
of Frappy tries to get a new value. The return value should be the
retrieved value.
In special cases :data:`Done <frappy.modules.Done>` might be returned instead,
when the internal code has already updated the parameter, or
when the value has not changed and no updates should be emitted.
This method might also be called internally, in case a fresh value of
the parameter is needed.
.. admonition:: polling
The Frappy framework has a built in :ref:`polling <polling>` mechanism,
which calls above method regularely. Each time ``read_<parameter>`` is
called, the Frappy framework ensures then that the value of the parameter
is updated and the activated clients will be notified by means of an
``update`` message.
def write\_\ *<parameter>*\ (self, value):
Called on a ``change`` SECoP message. The ``value`` argument is the value
given by the change message, and the method should implement the change,
typically by handing it over to the hardware. On success, the method must
return the accepted value. If the value may be read back
from the hardware, the readback value should be returned, which might be
slighly altered for example by rounding. The idea is, that the returned
value would be the same, as if it would be done by the ``read_<parameter>``
method. Often the easiest implementation is just returning the result of
a call to the ``read_<parameter>`` method.
Also, :ref:`Done <done unique>` might be returned in special
cases, e.g. when the code was written in a way, when self.<parameter> is
assigned already before returning from the method.
.. admonition:: behind the scenes
Assigning a parameter to a value by setting the attribute via
``self.<param> = <value>`` or ``<module>.<param> = <value>`` includes
a :ref:`type check <type check>`, some type conversion and ensures that
a :ref:`notification <client notification>` with an
``update`` message is sent to all activated clients.
Example code:
.. code:: python
from frappy.core import HasIO, Drivable, Property, Parameter, StringType
class TemperatureLoop(HasIO, Drivable):
"""a temperature sensor with loop"""
# internal property to configure the channel
channel = Property('the Lakeshore channel', datatype=StringType())
# modifying a property of inherited parameters (unit is propagated to the FloatRange datatype)
value = Parameter(unit='K')
target = Parameter(unit='K')
def read_value(self):
# using the inherited HasIO.communicate method to send a command and get the reply
reply = self.communicate(f'KRDG?{self.channel}')
return float(reply)
def read_status(self):
... determine the status from the hardware and return it ...
return status_code, status_text
def read_target(self):
... read back the target value ...
return target
def write_target(self, target):
... write here the target to the hardware ...
# important: make sure that the status is changed to BUSY within this method:
self.status = BUSY, 'target changed'
return self.read_target() # return the read back value
.. TODO: io, state machine, persistent parameters, rwhandler, datatypes, features, commands, proxies

View File

@ -1,9 +1,18 @@
Reference
---------
Core
....
For convenience everything documented on this page may also be
imported from the frappy.core module.
Module Base Classes
...................
.. _done unique:
.. autodata:: frappy.modules.Done
.. autoclass:: frappy.modules.Module
@ -27,21 +36,61 @@ Parameters, Commands and Properties
.. autoclass:: frappy.modules.Attached
:show-inheritance:
Access method decorators
........................
.. autofunction:: frappy.rwhandler.nopoll
.. _datatypes:
Datatypes
.........
.. autoclass:: frappy.datatypes.FloatRange
.. autoclass:: frappy.datatypes.IntRange
.. autoclass:: frappy.datatypes.BoolType
.. autoclass:: frappy.datatypes.ScaledInteger
.. autoclass:: frappy.datatypes.EnumType
.. autoclass:: frappy.datatypes.StringType
.. autoclass:: frappy.datatypes.TupleOf
.. autoclass:: frappy.datatypes.ArrayOf
.. autoclass:: frappy.datatypes.StructOf
.. autoclass:: frappy.datatypes.BLOBType
:members: __call__
.. autoclass:: frappy.datatypes.IntRange
:members: __call__
.. autoclass:: frappy.datatypes.BoolType
:members: __call__
.. autoclass:: frappy.datatypes.ScaledInteger
:members: __call__
.. autoclass:: frappy.datatypes.EnumType
:members: __call__
.. autoclass:: frappy.datatypes.StringType
:members: __call__
.. autoclass:: frappy.datatypes.TupleOf
:members: __call__
.. autoclass:: frappy.datatypes.ArrayOf
:members: __call__
.. autoclass:: frappy.datatypes.StructOf
:members: __call__
.. autoclass:: frappy.datatypes.BLOBType
:members: __call__
.. autoclass:: frappy.datatypes.DataTypeType
:members: __call__
.. autoclass:: frappy.datatypes.ValueType
:members: __call__
.. autoclass:: frappy.datatypes.NoneOr
:members: __call__
.. autoclass:: frappy.datatypes.OrType
:members: __call__
.. autoclass:: frappy.datatypes.LimitsType
:members: __call__
Communication
@ -51,6 +100,9 @@ Communication
:show-inheritance:
:members: communicate
.. autoclass:: frappy.io.IOBase
:show-inheritance:
.. autoclass:: frappy.io.StringIO
:show-inheritance:
:members: communicate, multicomm
@ -62,6 +114,12 @@ Communication
.. autoclass:: frappy.io.HasIO
:show-inheritance:
.. autoclass:: frappy.lib.asynconn.AsynTcp
:show-inheritance:
.. autoclass:: frappy.lib.asynconn.AsynSerial
:show-inheritance:
.. autoclass:: frappy.rwhandler.ReadHandler
:show-inheritance:
:members:
@ -85,5 +143,4 @@ Exception classes
.. automodule:: frappy.errors
:members:
.. include:: server.rst
.. include:: configuration.rst

View File

@ -1,47 +1,42 @@
Server
------
Configuration
.............
The configuration consists of a **NODE** section, an **INTERFACE** section and one
section per SECoP module.
The configuration code consists of a :ref:`Node() <node configuration>` section, and one
:ref:`Mod() <mod configuration>` section per SECoP module.
The **NODE** section contains a description of the SEC node and a globally unique ID of
the SEC node. Example:
The **Node** section contains a globally unique ID of the SEC node,
a description of the SEC node and the server interface uri. Example:
.. code::
.. code:: python
[NODE]
description = a description of the SEC node
id = globally.valid.identifier
Node('globally.valid.identifier',
'a description of the SEC node',
interface = 'tcp://5000')
The **INTERFACE** section defines the server interface. Currently only tcp is supported.
When the TCP port is given as an argument of the server start script, this section is not
needed or ignored. The main information is the port number, in this example 5000:
For the interface scheme currently only tcp is supported.
When the TCP port is given as an argument of the server start script, **interface** is not
needed or ignored. The main information is the port number, in this example 5000.
.. code::
[INTERFACE]
uri = tcp://5000
All other sections define the SECoP modules. The section name itself is the module name,
mandatory fields are **class** and **description**. **class** is a path to the Python class
from there the module is instantiated, separated with dots. In the following example the class
All other :ref:`Mod() <mod configuration>` sections define the SECoP modules.
Mandatory fields are **name**, **cls** and **description**. **cls** is a path to the Python class
from where the module is instantiated, separated with dots. In the following example the class
**HeLevel** used by the **helevel** module can be found in the PSI facility subdirectory
frappy_psi in the python module file ccu4.py:
.. code::
.. code:: python
[helevel]
class = frappy_psi.ccu4.HeLevel
description = this is the He level sensor of the main reservoir
empty = 380
empty.export = False
full = 0
full.export = False
Mod('helevel',
'frappy_psi.ccu4.HeLevel',
'this is the He level sensor of the main reservoir',
empty_length = Param(380, export=False),
full = Param(0, export=False))
It is highly recommended to use all lower case for the module name, as SECoP names have to be
unique despite of casing. In addition, parameters, properties and parameter properties might
be initialized in this section. In the above example **empty** and **full** are parameters,
be initialized in this section. In the above example **empty_length** and **full_length** are parameters,
the resistivity of the He Level sensor at the end of the ranges. In addition, we alter the
default property **export** of theses parameters, as we do not want to expose these parameters to
the SECoP interface.
@ -54,12 +49,12 @@ The Frappy server can be started via the **bin/frappy-server** script.
.. parsed-literal::
usage: frappy-server [-h] [-v | -q] [-d] name
usage: bin/frappy-server [-h] [-v | -q] [-d] [-t] [-p port] [-c cfgfiles] name
Manage a Frappy server
positional arguments:
name name of the instance. Uses etc/name.cfg for configuration
name name of the instance. Uses <config path>/name_cfg.py for configuration
optional arguments:
-c, --cfgfiles config files to be used. Comma separated list.

83
doc/source/structure.rst Normal file
View File

@ -0,0 +1,83 @@
Structure
---------
Node Structure
..............
Before starting to write the code for drivers, you have to think about
the node structure. What are the modules I want to create? What is to
be represented as a SECoP module, what as a parameter? At this point
you should not look what the hardware offers (e.g. channels A and B of
a temperature controller), but on what you need for doing an
experiment. Typically, each quantity you measure or control, has to
be represented by a module. You need module parameters for influencing
how you achieve or control the quantity. And you will need configurable
internal properties to configure the access to the hardware.
Examples:
- A temperature sensor, without an attached control loop, should inherit
from :class:`Readable <frappy.modules.Readable>`
- A temperature sensor with a control loop should inherit from
:class:`Drivable <frappy.modules.Drivable>`. You will need to implement a criterion for
deciding when the temperature is reached (e.g. tolerance and time window)
- If the heater power is a quantity of interest, it should be its own
module inheriting from :class:`Writable <frappy.modules.Writable>`.
- If it is a helium cryostat, you may want to implement a helium level
reading module inheriting from :class:`Readable <frappy.modules.Readable>`
.. _module structure parameters:
Module Structure: Parameters
............................
The next step is to determine which parameters we need in addition to
the standard ones given by the inherited class. As a temperature sensor
inherits from :class:`Readable <frappy.modules.Readable>`, it has already a ``value``
parameter representing the measured temperature. It has also a
``status`` parameter, indicating whether the measured temperature is
valid (``IDLE``), invalid (``ERROR``) or there might be a less
critical issue (``WARN``). In addition you might want additional
parameters, like an alarm threshold.
For the controlled temperature, in addition to above, inherited from
:class:`Drivable <frappy.modules.Drivable>` it has a writable ``target`` parameter.
In addition we might need control parameters or a changeable target limits.
For the heater you might want to have a changeable power limit or power range.
.. _module structure properties:
Module Structure: Properties
............................
For the access to the hardware, we will need internal properties for
configuring the hardware access. This might the IP address of a
LAN connection or the path of an internal serial device.
In Frappy, when inheriting from the mixin :class:`HasIO <frappy.io.HasIO>`,
either the property ``io`` referring to an explicitly configured
communicator or the ``uri`` property, generating a communicator with
the given uri can be used for this.
In addition, depending on the hardware probably you need a property to
configure the channel number or name assigned to the module.
For the heater output, you might need to configure the heater resistance.
Parameter Structure
...................
A parameter also has properties, which have to be set when declaring
the parameter. Even for the inherited parameters, often the properties
have to be overriden. For example, the ``unit`` property of the ``value``
parameter on the temperature sensor will be set to 'K', and the ``max``
property of the ``target`` parameter should be set to the maximum possible
value for the hardware. This value may then probably get more restricted
by an entry in the configuration file.

View File

@ -5,3 +5,4 @@ Tutorial
:maxdepth: 2
tutorial_helevel
tutorial_t_control

View File

@ -0,0 +1,406 @@
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 methods ``write_target`` and ``read_target``:
.. 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
reply = self.communicate(f'SETP {self.loop},{target};SETP?{self.loop}')
return float(reply)
def read_target(self):
return float(self.communicate(f'SETP?{self.loop}'))
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}')
reply = self.communicate(f'SETP {self.loop},{target};SETP? {self.loop}')
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 float(reply)
...
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*
====================== =======================

View File

@ -27,13 +27,14 @@ from frappy.lib import generalConfig
class Undef:
pass
class Node(dict):
def __init__(
self,
equipment_id,
description,
interface=None,
cls='protocol.dispatcher.Dispatcher',
cls='frappy.protocol.dispatcher.Dispatcher',
omit_unchanged_within=1.1,
**kwds
):
@ -46,6 +47,7 @@ class Node(dict):
**kwds
)
class Param(dict):
def __init__(self, value=Undef, **kwds):
if value is not Undef:

View File

@ -223,6 +223,7 @@ class FloatRange(HasUnit, DataType):
return self.get_info(type='double')
def __call__(self, value):
"""accepts floats, integers and booleans, but not strings"""
try:
value += 0.0 # do not accept strings here
except Exception:
@ -306,6 +307,7 @@ class IntRange(DataType):
return self.get_info(type='int')
def __call__(self, value):
"""accepts integers, booleans and whole-number floats, but not strings"""
try:
fvalue = value + 0.0 # do not accept strings here
value = int(value)
@ -425,6 +427,7 @@ class ScaledInteger(HasUnit, DataType):
max=int(round(self.max / self.scale)))
def __call__(self, value):
"""accepts floats, integers and booleans, but not strings"""
try:
value += 0.0 # do not accept strings here
except Exception:
@ -515,7 +518,7 @@ class EnumType(DataType):
return self(value)
def __call__(self, value):
"""return the validated (internal) value or raise"""
"""accepts integers and strings, converts to EnumMember (may be used like an int)"""
try:
return self._enum[value]
except (KeyError, TypeError): # TypeError will be raised when value is not hashable
@ -565,7 +568,7 @@ class BLOBType(DataType):
return 'BLOBType(%d, %d)' % (self.minbytes, self.maxbytes)
def __call__(self, value):
"""return the validated (internal) value or raise"""
"""accepts bytes only"""
if not isinstance(value, bytes):
raise BadValueError('%s must be of type bytes' % shortrepr(value))
size = len(value)
@ -630,7 +633,7 @@ class StringType(DataType):
return 'StringType(%s)' % (', '.join('%s=%r' % kv for kv in self.get_info().items()))
def __call__(self, value):
"""return the validated (internal) value or raise"""
"""accepts strings only"""
if not isinstance(value, str):
raise BadValueError('%s has the wrong type!' % shortrepr(value))
if not self.isUTF8:
@ -706,7 +709,8 @@ class BoolType(DataType):
return 'BoolType()'
def __call__(self, value):
"""return the validated (internal) value or raise"""
"""accepts 0, False, 1, True"""
# TODO: probably remove conversion from string (not needed anymore with python cfg)
if value in [0, '0', 'False', 'false', 'no', 'off', False]:
return False
if value in [1, '1', 'True', 'true', 'yes', 'on', True]:
@ -785,8 +789,8 @@ class ArrayOf(DataType):
self.members.setProperty(key, value)
def export_datatype(self):
return dict(type='array', minlen=self.minlen, maxlen=self.maxlen,
members=self.members.export_datatype())
return {'type': 'array', 'minlen': self.minlen, 'maxlen': self.maxlen,
'members': self.members.export_datatype()}
def __repr__(self):
return 'ArrayOf(%s, %s, %s)' % (
@ -807,6 +811,7 @@ class ArrayOf(DataType):
% type(value).__name__) from None
def __call__(self, value):
"""accepts any sequence, converts to tuple (immutable!)"""
self.check_type(value)
try:
return tuple(self.members(v) for v in value)
@ -877,7 +882,7 @@ class TupleOf(DataType):
return TupleOf(*(m.copy() for m in self.members))
def export_datatype(self):
return dict(type='tuple', members=[subtype.export_datatype() for subtype in self.members])
return {'type': 'tuple', 'members': [subtype.export_datatype() for subtype in self.members]}
def __repr__(self):
return 'TupleOf(%s)' % ', '.join([repr(st) for st in self.members])
@ -892,6 +897,7 @@ class TupleOf(DataType):
% type(value).__name__) from None
def __call__(self, value):
"""accepts any sequence, converts to tuple"""
self.check_type(value)
try:
return tuple(sub(elem) for sub, elem in zip(self.members, value))
@ -973,8 +979,8 @@ class StructOf(DataType):
return StructOf(self.optional, **{k: v.copy() for k, v in self.members.items()})
def export_datatype(self):
res = dict(type='struct', members=dict((n, s.export_datatype())
for n, s in list(self.members.items())))
res = {'type': 'struct', 'members': dict((n, s.export_datatype())
for n, s in list(self.members.items()))}
if set(self.optional) != set(self.members):
res['optional'] = self.optional
return res
@ -985,6 +991,7 @@ class StructOf(DataType):
['%s=%s' % (n, repr(st)) for n, st in list(self.members.items())]), opt)
def __call__(self, value):
"""accepts any mapping, returns an immutable dict"""
try:
if set(dict(value)) != set(self.members):
raise BadValueError('member names do not match') from None
@ -1118,11 +1125,10 @@ class CommandType(DataType):
class DataTypeType(DataType):
def __call__(self, value):
"""check if given value (a python obj) is a valid datatype
returns the value or raises an appropriate exception"""
"""accepts a datatype"""
if isinstance(value, DataType):
return value
#TODO: not needed anymore?
try:
return get_datatype(value)
except Exception as e:
@ -1144,9 +1150,7 @@ class DataTypeType(DataType):
class ValueType(DataType):
"""validates any python value"""
def __call__(self, value):
"""check if given value (a python obj) is valid for this datatype
returns the value or raises an appropriate exception"""
"""accepts any type -> no conversion"""
return value
def export_value(self, value):
@ -1178,6 +1182,7 @@ class NoneOr(DataType):
self.other = other
def __call__(self, value):
"""accepts None and other type"""
return None if value is None else self.other(value)
def export_value(self, value):
@ -1193,6 +1198,7 @@ class OrType(DataType):
self.default = self.types[0].default
def __call__(self, value):
"""accepts any of the given types, takes the first valid"""
for t in self.types:
try:
return t(value)
@ -1217,6 +1223,7 @@ class LimitsType(TupleOf):
super().__init__(members, members)
def __call__(self, value):
"""accepts an ordered tuple of numeric member types"""
limits = TupleOf.validate(self, value)
if limits[1] < limits[0]:
raise BadValueError('Maximum Value %s must be greater than minimum value %s!' % (limits[1], limits[0]))
@ -1240,32 +1247,32 @@ def floatargs(kwds):
# argumentnames to lambda from spec!
# **kwds at the end are for must-ignore policy
DATATYPES = dict(
bool = lambda **kwds:
DATATYPES = {
'bool': lambda **kwds:
BoolType(),
int = lambda min, max, **kwds:
'int': lambda min, max, **kwds:
IntRange(minval=min, maxval=max),
scaled = lambda scale, min, max, **kwds:
'scaled': lambda scale, min, max, **kwds:
ScaledInteger(scale=scale, minval=min*scale, maxval=max*scale, **floatargs(kwds)),
double = lambda min=None, max=None, **kwds:
'double': lambda min=None, max=None, **kwds:
FloatRange(minval=min, maxval=max, **floatargs(kwds)),
blob = lambda maxbytes, minbytes=0, **kwds:
'blob': lambda maxbytes, minbytes=0, **kwds:
BLOBType(minbytes=minbytes, maxbytes=maxbytes),
string = lambda minchars=0, maxchars=None, isUTF8=False, **kwds:
'string': lambda minchars=0, maxchars=None, isUTF8=False, **kwds:
StringType(minchars=minchars, maxchars=maxchars, isUTF8=isUTF8),
array = lambda maxlen, members, minlen=0, pname='', **kwds:
'array': lambda maxlen, members, minlen=0, pname='', **kwds:
ArrayOf(get_datatype(members, pname), minlen=minlen, maxlen=maxlen),
tuple = lambda members, pname='', **kwds:
'tuple': lambda members, pname='', **kwds:
TupleOf(*tuple((get_datatype(t, pname) for t in members))),
enum = lambda members, pname='', **kwds:
'enum': lambda members, pname='', **kwds:
EnumType(pname, members=members),
struct = lambda members, optional=None, pname='', **kwds:
'struct': lambda members, optional=None, pname='', **kwds:
StructOf(optional, **dict((n, get_datatype(t, pname)) for n, t in list(members.items()))),
command = lambda argument=None, result=None, pname='', **kwds:
'command': lambda argument=None, result=None, pname='', **kwds:
CommandType(get_datatype(argument, pname), get_datatype(result)),
limit = lambda members, pname='', **kwds:
'limit': lambda members, pname='', **kwds:
LimitsType(get_datatype(members, pname)),
)
}
# important for getting the right datatype from formerly jsonified descr.

View File

@ -100,7 +100,14 @@ class HasIodev(HasIO):
class IOBase(Communicator):
"""base of StringIO and BytesIO"""
uri = Property('hostname:portnumber', datatype=StringType())
uri = Property("""uri for serial connection
one of the following:
- ``tcp://<host address>:<portnumber>`` (see :class:`frappy.lib.asynconn.AsynTcp`)
- ``serial://<serial device>?baudrate=<value>...`` (see :class:`frappy.lib.asynconn.AsynSerial`)
""", datatype=StringType())
timeout = Parameter('timeout', datatype=FloatRange(0), default=2)
wait_before = Parameter('wait time before sending', datatype=FloatRange(), default=0)
is_connected = Parameter('connection state', datatype=BoolType(), readonly=False, default=False)

View File

@ -157,6 +157,12 @@ class AsynConn:
class AsynTcp(AsynConn):
"""a tcp/ip connection
uri syntax::
tcp://<host address>:<port number>
"""
scheme = 'tcp'
def __init__(self, uri, *args, **kwargs):
@ -210,8 +216,9 @@ class AsynTcp(AsynConn):
class AsynSerial(AsynConn):
"""a serial connection using pyserial
uri syntax:
serial://<path>?[<option>=<value>[+<option>=<value> ...]]
uri syntax::
serial://<serial device>?[<option>=<value>[+<option>=<value> ...]]
options (defaults, other examples):

View File

@ -39,7 +39,7 @@ from frappy.properties import HasProperties, Property
from frappy.logging import RemoteLogHandler, HasComlog
Done = UniqueObject('Done')
"""a special return value for a read/write function
"""a special return value for a read_<param>/write_<param> method
indicating that the setter is triggered already"""

View File

@ -104,14 +104,21 @@ class Parameter(Accessible):
:param kwds: optional properties
Usage of 'value' and 'default':
- if a value is given for a parameter in the config file, and if the write_<paramname>
method is given, it is called on startup with this value as argument
- if a value should be written to the HW on startup, even when not given in the config
add the value argument to the Parameter definition
- for parameters which are not polling the HW, either a default should be given
as a Parameter argument, or, when needscfg is set to True, a configured value is required
- when default instead of value is given in the cfg file, it is assigne to the parameter
but not written to the HW
Please note that in addition to the listed parameter properties, datatype properties
like ``min``, ``max`` or ``unit`` may be given as keyworded argument.
"""
# storage for Parameter settings + value + qualifiers
@ -153,9 +160,9 @@ class Parameter(Accessible):
needscfg = Property(
'[internal] needs value in config', NoneOr(BoolType()),
export=False, default=False)
optional = Property(
'[internal] is this parameter optional?', BoolType(),
export=False, settable=False, default=False)
# optional = Property(
# '[internal] is this parameter optional?', BoolType(),
# export=False, settable=False, default=False)
# used on the instance copy only
# value = None
@ -330,9 +337,9 @@ class Command(Accessible):
* True: exported, name automatic.
* a string: exported with custom name''', OrType(BoolType(), StringType()),
export=False, default=True)
optional = Property(
'[internal] is the command optional to implement? (vs. mandatory)', BoolType(),
export=False, default=False, settable=False)
# optional = Property(
# '[internal] is the command optional to implement? (vs. mandatory)', BoolType(),
# export=False, default=False, settable=False)
datatype = Property(
"datatype of the command, auto generated from 'argument' and 'result'",
DataTypeType(), extname='datainfo', export='always')
@ -497,22 +504,22 @@ class Command(Accessible):
# list of predefined accessibles with their type
PREDEFINED_ACCESSIBLES = dict(
value=Parameter,
status=Parameter,
target=Parameter,
pollinterval=Parameter,
ramp=Parameter,
user_ramp=Parameter,
setpoint=Parameter,
time_to_target=Parameter,
unit=Parameter, # reserved name
loglevel=Parameter, # reserved name
mode=Parameter, # reserved name
stop=Command,
reset=Command,
go=Command,
abort=Command,
shutdown=Command,
communicate=Command,
)
PREDEFINED_ACCESSIBLES = {
'value': Parameter,
'status': Parameter,
'target': Parameter,
'pollinterval': Parameter,
'ramp': Parameter,
'user_ramp': Parameter,
'setpoint': Parameter,
'time_to_target': Parameter,
'unit': Parameter, # reserved name
'loglevel': Parameter, # reserved name
'mode': Parameter, # reserved name
'stop': Command,
'reset': Command,
'go': Command,
'abort': Command,
'shutdown': Command,
'communicate': Command,
}

View File

@ -19,12 +19,14 @@
# Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
"""decorator class for common read_/write_ methods
"""decorator class for common read_<param>/write_<param> methods
Usage:
Example 1: combined read/write for multiple parameters
.. code:
PID_PARAMS = ['p', 'i', 'd']
@CommonReadHandler(PID_PARAMS)
@ -41,6 +43,8 @@ Example 1: combined read/write for multiple parameters
Example 2: addressable HW parameters
.. code:
HW_ADDR = {'p': 25, 'i': 26, 'd': 27}
@ReadHandler(HW_ADDR)
@ -197,8 +201,10 @@ class CommonWriteHandler(WriteHandler):
calls the wrapped write method function with values as an argument.
- values[pname] returns the to be written value
- values['key'] returns a value taken from writeDict
or, if not available return obj.key
- values.as_tuple() returns a tuple with the items in the same order as keys
"""

View File

@ -31,7 +31,9 @@ from frappy.modules import Command, Drivable, Parameter
# test custom property (value.test can be changed in config file)
from frappy.properties import Property
Parameter.propertyDict['test'] = Property('A Property for testing purposes', StringType(), default='', export=True)
class TestParameter(Parameter):
test = Property('A Property for testing purposes', StringType(), default='', export=True)
class CryoBase(Drivable):
@ -73,7 +75,7 @@ class Cryostat(CryoBase):
target = Parameter("target temperature",
datatype=FloatRange(0), default=0, unit="K",
readonly=False,)
value = Parameter("regulation temperature",
value = TestParameter("regulation temperature",
datatype=FloatRange(0), default=0, unit="K",
test='TEST')
pid = Parameter("regulation coefficients",

View File

@ -39,7 +39,7 @@ class TemperatureSensor(HasIO, Readable):
# internal property to configure the channel
channel = Property('the Lakeshore channel', datatype=StringType())
# 0, 1500 is the allowed range by the LakeShore controller
# this range should be restricted in the configuration (see below)
# this range should be further restricted in the configuration (see below)
value = Parameter(datatype=FloatRange(0, 1500, unit='K'))
def read_value(self):
@ -79,6 +79,8 @@ class TemperatureLoop(TemperatureSensor, Drivable):
self.communicate(f'RANGE {self.loop},{self.heater_range};RANGE?{self.loop}')
reply = self.communicate(f'SETP {self.loop},{target};SETP? {self.loop}')
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 float(reply)
@ -102,7 +104,7 @@ class TemperatureLoop(TemperatureSensor, Drivable):
if self._driving:
return BUSY, 'approaching setpoint'
return WARN, 'temperature out of tolerance'
else:
else: # within tolerance: simple convergence criterion
self._driving = False
return IDLE, ''
return ERROR, text