From e741404d0b9c5837f83c05d313d233807bf48f11 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Mon, 8 Dec 2025 16:19:01 +0100 Subject: [PATCH] simplify configuration of IO modules As the communicator class needed for a module can be specified, in the configuration we do not need to specifiy it explicitly. A new configurator function IO() is introduced for this, defining names and uri only. - update also configuration reference and a tutorial example - update get_class function to accept attributes of classes like 'frappy_demo.lakshore.TemperatureSensor.ioClass' and import from modules other than frappy... like 'test.test_iocfg.Mod'. - add ioClass to the example class for the temperature controller tutorial Change-Id: I3115371d612f14024e43bc6d38b642e1d27b314d Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/38071 Tested-by: Jenkins Automated Tests Reviewed-by: Markus Zolliker --- .pylintrc | 2 +- cfg/ls336_cfg.py | 6 +- .../{configuration.rst => configuration.inc} | 55 +++++++++++++-- doc/source/reference.rst | 2 +- doc/source/tutorial_t_control.rst | 13 ++-- frappy/config.py | 40 +++++++++-- frappy/lib/__init__.py | 39 +++++++---- frappy_demo/lakeshore.py | 2 + test/test_iocfg.py | 68 +++++++++++++++++++ 9 files changed, 190 insertions(+), 37 deletions(-) rename doc/source/{configuration.rst => configuration.inc} (59%) create mode 100644 test/test_iocfg.py diff --git a/.pylintrc b/.pylintrc index 276b768b..618debbf 100644 --- a/.pylintrc +++ b/.pylintrc @@ -86,7 +86,7 @@ dummy-variables-rgx=_|dummy # List of additional names supposed to be defined in builtins. Remember that # you should avoid to define new builtins when possible. -additional-builtins=Node,Mod,Param,Command,Group +additional-builtins=Node,Mod,Param,Command,Group,IO [BASIC] diff --git a/cfg/ls336_cfg.py b/cfg/ls336_cfg.py index 0b857bbf..55e34a4c 100644 --- a/cfg/ls336_cfg.py +++ b/cfg/ls336_cfg.py @@ -6,11 +6,7 @@ lakeshore_uri = environ.get('LS_URI', 'tcp://:7777') 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_demo.lakeshore.LakeshoreIO', # the class used for communication - 'communication to main controller', # a description - uri=lakeshore_uri, # the serial connection - ) +IO('io', lakeshore_uri) # the communicator (its class will be detected automatically) Mod('T', 'frappy_demo.lakeshore.TemperatureLoop', 'Sample Temperature', diff --git a/doc/source/configuration.rst b/doc/source/configuration.inc similarity index 59% rename from doc/source/configuration.rst rename to doc/source/configuration.inc index a23e063a..1a7cd8de 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.inc @@ -3,10 +3,14 @@ Configuration File .. _node configuration: -:Node(equipment_id, description, interface, \*\*kwds): +:Node: Specify the SEC-node properties. + .. code:: + + Node(equipment_id, description, interface, **kwds): + The arguments are SECoP node properties and additional internal node configurations :Parameters: @@ -18,9 +22,14 @@ Configuration File .. _mod configuration: -:Mod(name, cls, description, \*\*kwds): +:Mod: Create a SECoP module. + + .. code:: + + Mod(name, cls, description, **kwds) + 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 @@ -37,22 +46,60 @@ Configuration File .. _param configuration: -:Param(value=, \*\*kwds): +:Param: Configure a parameter + .. code:: + + Param(value=, **kwds): + :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`) +.. _io configuration: + +:IO: + + Configure IO modules (communicators) + + .. code:: + + IO(, , ...) + + + It is recommended that the class of the needed IO is specified as class + attribute ioClass on the modules class. In this case, for the configuration + of the IO modules only their name and URI is needed, for example: + + .. code:: + + IO('io_T', 'tcp://192.168.1.1:7777', export=False) + IO('io_C', 'serial:///dev/tty_USB0&baudrate=9600', export=False) + + Mod('T_sample', 'frappy_psi.lakeshore.TemperatureSensor', 'the sample T', + io='io_T', channel='C') + Mod('T_main', 'frappy_psi.lakeshore.TemperatureLoop', 'the main T', + io='io_T', channel='A') + Mod('C_sample', 'frappy_psi.ah2700.Capacitance', 'the sample capacitance', + io='io_C') + + The ``export=False`` argument tells Frappy to hide both communicators. + + .. _command configuration: -:Command(\*\*kwds): +:Command: Configure a command + .. code:: + + Command(**kwds) + :Parameters: - **kwds** - command SECoP properties (see :class:`frappy.param.Commands`) diff --git a/doc/source/reference.rst b/doc/source/reference.rst index cd624afe..d0ff430a 100644 --- a/doc/source/reference.rst +++ b/doc/source/reference.rst @@ -140,4 +140,4 @@ Exception classes .. automodule:: frappy.errors :members: -.. include:: configuration.rst \ No newline at end of file +.. include:: configuration.inc \ No newline at end of file diff --git a/doc/source/tutorial_t_control.rst b/doc/source/tutorial_t_control.rst index fd5c2d9d..2bad89e8 100644 --- a/doc/source/tutorial_t_control.rst +++ b/doc/source/tutorial_t_control.rst @@ -99,16 +99,11 @@ We choose the name *example_cryo* and create therefore a configuration file 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', - ) + IO('io', 'serial://COM6:?baudrate=57600+parity=odd+bytesize=7') Mod('T', 'frappy_psi.lakeshore.TemperatureSensor', 'Sample Temperature', - io='io', # refers to above defined module 'io' + io='io', # refers to above defined io module called 'io' channel='A', # the channel on the LakeShore for this module value=Param(max=470), # alter the maximum expected T ) @@ -120,8 +115,8 @@ Usually the only important value in the server address is the TCP port under whi 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. +But first we have to create the ``io`` module for communication. +For this we use an :ref:`IO ` section. 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 diff --git a/frappy/config.py b/frappy/config.py index 97ce7027..c67cfa73 100644 --- a/frappy/config.py +++ b/frappy/config.py @@ -114,14 +114,18 @@ class Collector: self.modules = {} self.warnings = [] - def add(self, *args, **kwds): - mod = Mod(*args, **kwds) + def add(self, name, cls, description, **kwds): + mod = Mod(name, cls, description, **kwds) name = mod.pop('name') if name in self.modules: self.warnings.append(f'duplicate module {name} overrides previous') self.modules[name] = mod return mod + def add_io(self, name, uri, **kwds): + mod = Mod(name, cls='', description='', uri=uri, **kwds) + self.modules[mod.pop('name')] = mod + def override(self, name, **kwds): """override properties/parameters of previously configured modules @@ -180,12 +184,37 @@ class Include: exec(compile(filename.read_bytes(), filename, 'exec'), self.namespace) -def process_file(filename, log): - config_text = filename.read_bytes() +def fix_io_modules(cfgdict, log): + node = cfgdict.pop('node') + io_modules = {k: [] for k, v in cfgdict.items() if v.get('cls') == ''} + for modname, modcfg in cfgdict.items(): + ioname = modcfg.get('io', {}).get('value') + if ioname: + iocfg = cfgdict.get(ioname) + if iocfg: + referring_modules = io_modules.get(ioname) + if referring_modules is not None: + iocfg['cls'] = f"{modcfg['cls']}.ioClass" + referring_modules.append(modname) + + # fix description: + for ioname, referring_modules in io_modules.items(): + if referring_modules: + if not cfgdict[ioname]['description']: + cfgdict[ioname]['description'] = f"communicator for {', '.join(k for k in referring_modules)}" + else: + log.warning('remove unused io module %r', ioname) + cfgdict.pop(ioname) + cfgdict['node'] = node + + +def process_file(filename, log, config_text=None): + if config_text is None: + config_text = filename.read_bytes() node = NodeCollector() mods = Collector() ns = {'Node': node.add, 'Mod': mods.add, 'Param': Param, 'Command': Param, 'Group': Group, - 'override': mods.override, 'overrideNode': node.override} + 'override': mods.override, 'overrideNode': node.override, 'IO': mods.add_io} ns['include'] = Include(ns, log) # pylint: disable=exec-used exec(compile(config_text, filename, 'exec'), ns) @@ -236,6 +265,7 @@ def load_config(cfgfiles, log): filename = to_config_path(str(cfgfile), log) log.debug('Parsing config file %s...', filename) cfg = process_file(filename, log) + fix_io_modules(cfg, log) if config: config.merge_modules(cfg) else: diff --git a/frappy/lib/__init__.py b/frappy/lib/__init__.py index 93e05386..b227c13c 100644 --- a/frappy/lib/__init__.py +++ b/frappy/lib/__init__.py @@ -234,22 +234,37 @@ def clamp(_min, value, _max): i.e. value if min <= value <= max, else min or max depending on which side value lies outside the [min..max] interval. This works even when min > max! """ - # return median, i.e. clamp the the value between min and max + # return median, i.e. clamp the value between min and max return sorted([_min, value, _max])[1] def get_class(spec): - """loads a class given by string in dotted notation (as python would do)""" - modname, classname = spec.rsplit('.', 1) - if modname.startswith('frappy'): - module = importlib.import_module(modname) - else: - # rarely needed by now.... - module = importlib.import_module('frappy.' + modname) - try: - return getattr(module, classname) - except AttributeError: - raise AttributeError('no such class') from None + """loads an object given by string in dotted notation (as python would do) + + import the specified module and get the specified item from it + examples: 'frappy_demo.lakeshore.TemperatureSensor', 'frappy.modules.Readable.Status' + + :param spec: a dot-separated list of module names followed by the name of + a class (or any object) and optionally names of attributes + :return: the object + """ + for maxsplit in range(1, len(spec)): + # len(spec) is high enough for all cases + module, *attrs = spec.rsplit('.', maxsplit) + try: + obj = importlib.import_module(module) + break + except ImportError: + if '.' in module: + continue + raise + for na, attr in enumerate(attrs): + try: + obj = getattr(obj, attr) + except AttributeError: + print(na, attrs) + raise AttributeError(f'{".".join(attrs[:na+1])!r} not found in {module!r}') from None + return obj def mkthread(func, *args, **kwds): diff --git a/frappy_demo/lakeshore.py b/frappy_demo/lakeshore.py index 133b6946..77d8f050 100644 --- a/frappy_demo/lakeshore.py +++ b/frappy_demo/lakeshore.py @@ -36,6 +36,7 @@ class LakeshoreIO(StringIO): class TemperatureSensor(HasIO, Readable): """a temperature sensor (generic for different models)""" + ioClass = LakeshoreIO # internal property to configure the channel channel = Property('the Lakeshore channel', datatype=StringType()) # 0, 1500 is the allowed range by the LakeShore controller @@ -66,6 +67,7 @@ class TemperatureSensor(HasIO, Readable): class TemperatureLoop(TemperatureSensor, Drivable): + ioClass = LakeshoreIO # 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)) diff --git a/test/test_iocfg.py b/test/test_iocfg.py new file mode 100644 index 00000000..175022ef --- /dev/null +++ b/test/test_iocfg.py @@ -0,0 +1,68 @@ +# ***************************************************************************** +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# +# ***************************************************************************** + +from logging import Logger +import pytest +from frappy.core import StringIO, Module, HasIO +from frappy.config import process_file, fix_io_modules, Param + + +class Mod(HasIO, Module): + ioClass = StringIO + + +CONFIG = """ +IO('io_a', 'tcp://test.psi.ch:7777', visibility='w--') +IO('io_b', 'tcp://test2.psi.ch:8080') + +Mod('mod1', 'test.test_iocfg.Mod', '', + io='io_a', + ) + +Mod('mod2', 'test.test_iocfg.Mod', '', + io='io_b', + ) + +Mod('mod3', 'test.test_iocfg.Mod', '', + io='io_b', + ) +""" + + +@pytest.mark.parametrize('mod, ioname, iocfg', [ + ('mod1', 'io_a', { + 'cls': 'test.test_iocfg.Mod.ioClass', + 'description': 'communicator for mod1', + 'uri': Param('tcp://test.psi.ch:7777'), + 'visibility': Param('w--') + },), + ('mod2', 'io_b', { + 'cls': 'test.test_iocfg.Mod.ioClass', + 'description': 'communicator for mod2, mod3', + 'uri': Param('tcp://test2.psi.ch:8080'), + }), +]) +def test_process_file(mod, ioname, iocfg): + log = Logger('dummy') + config = process_file('',log, CONFIG) + fix_io_modules(config, log) + assert config[mod]['io'] == {'value': ioname} + assert config[ioname] == iocfg