Merge branch 'wip' of gitlab.psi.ch-samenv:samenv/frappy into wip

This commit is contained in:
zolliker 2023-09-08 10:46:13 +02:00
commit dee8f8929e
31 changed files with 1931 additions and 248 deletions

View File

@ -53,6 +53,8 @@ disable=missing-docstring
,unidiomatic-typecheck
,undefined-loop-variable
,consider-using-f-string
,use-dict-literal
[REPORTS]
# Set the output format. Available formats are text, parseable, colorized, msvs

View File

@ -31,6 +31,7 @@ sys.path.insert(0, path.abspath(path.join(path.dirname(__file__), '..')))
import logging
from mlzlog import ColoredConsoleHandler
from frappy.gui.qt import QApplication
from frappy.gui.cfg_editor.mainwindow import MainWindow

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3
# pylint: disable=invalid-name
# -*- coding: utf-8 -*-
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
@ -23,8 +22,8 @@
#
# *****************************************************************************
import sys
import argparse
import sys
from os import path
# Add import path for inplace usage
@ -61,8 +60,9 @@ def parseArgv(argv):
action='store',
help="comma separated list of cfg files,\n"
"defaults to <name_of_the_instance>.\n"
"cfgfiles given without '.cfg' extension are searched in the configuration directory, "
"else they are treated as path names",
"cfgfiles given without '.cfg' extension are searched"
" in the configuration directory,"
" else they are treated as path names",
default=None)
parser.add_argument('-g',
'--gencfg',
@ -96,15 +96,13 @@ def main(argv=None):
generalConfig.init(args.gencfg)
logger.init(loglevel)
srv = Server(args.name, logger.log, cfgfiles=args.cfgfiles, interface=args.port, testonly=args.test)
srv = Server(args.name, logger.log, cfgfiles=args.cfgfiles,
interface=args.port, testonly=args.test)
if args.daemonize:
srv.start()
else:
try:
srv.run()
except KeyboardInterrupt:
pass
if __name__ == '__main__':

View File

@ -43,6 +43,7 @@ Mod('mf',
'frappy_psi.sea.SeaDrivable', '',
io='sea_main',
sea_object='mf',
rel_paths=['.', 'gen', 'ips'],
)
Mod('lev',

View File

@ -1,5 +1,5 @@
{"hcp": {"base": "/hcp", "params": [
{"path": "", "type": "float", "kids": 10},
{"path": "", "type": "float", "readonly": false, "cmd": "hcp set", "kids": 10},
{"path": "send", "type": "text", "readonly": false, "cmd": "hcp send", "visibility": 3},
{"path": "status", "type": "text", "visibility": 3},
{"path": "set", "type": "float", "readonly": false, "cmd": "hcp set"},

43
cfg/seop_cfg.py Normal file
View File

@ -0,0 +1,43 @@
description = """
3He system in Lab ...
"""
Node('mlz_seop',
description,
'tcp://10767',
)
Mod('cell',
'frappy_mlz.seop.Cell',
'interface module to the driver',
config_directory = '/home/jcns/daemon/config',
)
Mod('afp',
'frappy_mlz.seop.Afp',
'controls the afp flip of the cell',
cell = 'cell'
)
Mod('nmr',
'frappy_mlz.seop.Nmr',
'controls the ',
cell = 'cell'
)
fitparams = [
('amplitude', 'V'),
('T1', 's'),
('T2', 's'),
('b', ''),
('frequency', 'Hz'),
('phase', 'deg'),
]
for param, unit in fitparams:
Mod(f'nmr_{param.lower()}',
'frappy_mlz.seop.FitParam',
f'fittet parameter {param} of NMR',
cell = 'cell',
value = Param(unit=unit),
sigma = Param(unit=unit),
param = param,
)

View File

@ -18,7 +18,7 @@ Mod('ts',
)
Mod('hcp',
'frappy_psi.sea.SeaReadable', '',
'frappy_psi.sea.SeaWritable', '',
io='sea_stick',
sea_object='hcp',
)

View File

@ -10,6 +10,22 @@ The needed fields are Equipment id (1st argument), description (this)
'tcp://10768',
)
Mod('attachtest',
'frappy_demo.test.WithAtt',
'test attached',
att = 'LN2',
)
Mod('pinata',
'frappy_demo.test.Pin',
'scan test',
)
Mod('recursive',
'frappy_demo.test.RecPin',
'scan test',
)
Mod('LN2',
'frappy_demo.test.LN2',
'random value between 0..100%',

46
debian/changelog vendored
View File

@ -1,3 +1,49 @@
frappy-core (0.17.13) focal; urgency=medium
[ Alexander Zaft ]
* add egg-info to gitignore
[ Markus Zolliker ]
* GUI bugfix: use isChecked instead of checkState in BoolInput
* frappy_psi.mercury/triton: add control_off command
* frappy_psi.phytron: rename reset_error to clear_errors
* frappy.mixins.HasOutputModule
* frappy_psi.mercury: proper handling of control_active
* add a hook for reads to be done initially
* frappy_psi.triton: fix HeaterOutput.limit
* frappy_psi.magfield: bug fix
* frappy_psi.sea: bug fixes
[ Alexander Zaft ]
* server: fix systemd variable scope
-- Alexander Zaft <jenkins@frm2.tum.de> Tue, 20 Jun 2023 14:38:00 +0200
frappy-core (0.17.12) focal; urgency=medium
[ Alexander Zaft ]
* Warn about duplicate module definitions in a file
* Add influences property to parameters/commands
[ Markus Zolliker ]
* frappy.client: dummy logger is missing 'exception' method
[ Alexander Zaft ]
* Add SEOP He3-polarization device
* Typo in influences description
* seop: fix fitparam command
[ Markus Zolliker ]
* silently catches error in systemd.daemon.notify
[ Alexander Zaft ]
* io: add option to retry first ident request
* config: fix merge_modules
* io: followup fix for retry-first-ident
* entangle: fix tango guards for pytango 9.3
-- Alexander Zaft <jenkins@frm2.tum.de> Tue, 13 Jun 2023 06:51:27 +0200
frappy-core (0.17.11) focal; urgency=medium
[ Alexander Zaft ]

View File

@ -214,15 +214,25 @@ class Module:
return self.read()
self.target = target # this sets self._is_driving
type(self).value.prev = None # show at least one value
try:
def loop():
while self._is_driving:
self._driving_event.wait()
self._watch_parameter(self._name, 'value', mininterval=self._secnode.mininterval)
self._watch_parameter(self._name, 'status')
self._driving_event.clear()
except KeyboardInterrupt:
self.stop()
try:
loop()
except KeyboardInterrupt as e:
self._secnode.log.info('-- interrupted --')
self.stop()
try:
loop() # wait for stopping to be finished
except KeyboardInterrupt:
# interrupted again while stopping -> definitely quit
pass
clientenv.raise_with_short_traceback(e)
finally:
self._watch_parameter(self._name, 'status')
self._secnode.readParameter(self._name, 'value')
self._watch_parameter(self._name, 'value', forced=True)
@ -331,8 +341,8 @@ def watch(*args, **kwds):
for mobj in modules:
mobj._start_watching()
time.sleep(3600)
except KeyboardInterrupt:
pass
except KeyboardInterrupt as e:
clientenv.raise_with_short_traceback(e)
finally:
for mobj in modules:
mobj._stop_watching()

41
frappy/dynamic.py Normal file
View File

@ -0,0 +1,41 @@
# *****************************************************************************
#
# 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:
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
from .core import Module
class Pinata(Module):
"""Base class for scanning conections and adding modules accordingly.
Like a piñata. You poke it, and modules fall out.
To use it, subclass it for your connection type and override the function
'scanModules'. For each module you want to register, you should yield the
modules name and its config options.
The connection will then be scanned during server startup.
"""
export = False
# POKE
def scanModules(self):
"""yield (modname, options) for each module the Pinata should create.
Options has to include keys for class and the config for the module.
"""
raise NotImplementedError

View File

@ -407,10 +407,15 @@ class UniqueObject:
def merge_status(*args):
"""merge status
the status with biggest code wins
texts matching maximal code are joined with ', '
for combining stati of different mixins
- the status with biggest code wins
- texts matching maximal code are joined with ', '
- if texts already contain ', ', it is considered as composed by
individual texts and duplication is avoided. when commas are used
for other purposes, the behaviour might be surprising
"""
maxcode = max(a[0] for a in args)
merged = [a[1] for a in args if a[0] == maxcode and a[1]]
# use dict instead of set for preserving order
merged = {m: True for mm in merged for m in mm.split(', ')}
return maxcode, ', '.join(merged)

View File

@ -329,7 +329,6 @@ class Module(HasAccessibles):
# reference to the dispatcher (used for sending async updates)
DISPATCHER = None
attachedModules = None
pollInfo = None
triggerPoll = None # trigger event for polls. used on io modules and modules without io
@ -347,7 +346,9 @@ class Module(HasAccessibles):
self.accessLock = threading.RLock() # for read_* / write_* methods
self.updateLock = threading.RLock() # for announceUpdate
self.polledModules = [] # modules polled by thread started in self.startModules
self.attachedModules = {}
errors = []
self._isinitialized = False
# handle module properties
# 1) make local copies of properties
@ -639,6 +640,13 @@ class Module(HasAccessibles):
all parameters are polled once
"""
def shutdownModule(self):
"""called when the sever shuts down
any cleanup-work should be performed here, like closing threads and
saving data.
"""
def doPoll(self):
"""polls important parameters like value and status
@ -932,10 +940,12 @@ class Attached(Property):
def __get__(self, obj, owner):
if obj is None:
return self
if obj.attachedModules is None:
# return the name of the module (called from Server on startup)
return super().__get__(obj, owner)
# return the module (called after startup)
if self.name not in obj.attachedModules:
modobj = obj.DISPATCHER.get_module(super().__get__(obj, owner))
if not isinstance(modobj, self.basecls):
raise ConfigError(f'attached module {self.name}={modobj.name!r} '\
f'must inherit from {self.basecls.__qualname__!r}')
obj.attachedModules[self.name] = modobj
return obj.attachedModules.get(self.name) # return None if not given
def copy(self):

View File

@ -540,7 +540,6 @@ class Limit(Parameter):
if self.hasDatatype():
return # the programmer is responsible that a given datatype is correct
postfix = self.name.rpartition('_')[-1]
postfix = self.name.rpartition('_')[-1]
if postfix == 'limits':
self.datatype = TupleOf(datatype, datatype)
self.default = (datatype.min, datatype.max)
@ -562,6 +561,7 @@ PREDEFINED_ACCESSIBLES = {
'unit': Parameter, # reserved name
'loglevel': Parameter, # reserved name
'mode': Parameter, # reserved name
'ctrlpars': Parameter, # spec to be confirmed
'stop': Command,
'reset': Command,
'go': Command,

View File

@ -39,16 +39,18 @@ Interface to the modules:
"""
import threading
import traceback
from collections import OrderedDict
from time import time as currenttime
from frappy.errors import NoSuchCommandError, NoSuchModuleError, \
NoSuchParameterError, ProtocolError, ReadOnlyError
NoSuchParameterError, ProtocolError, ReadOnlyError, ConfigError
from frappy.params import Parameter
from frappy.protocol.messages import COMMANDREPLY, DESCRIPTIONREPLY, \
DISABLEEVENTSREPLY, ENABLEEVENTSREPLY, ERRORPREFIX, EVENTREPLY, \
HEARTBEATREPLY, IDENTREPLY, IDENTREQUEST, READREPLY, WRITEREPLY, \
LOGGING_REPLY, LOG_EVENT
from frappy.lib import get_class
def make_update(modulename, pobj):
@ -84,6 +86,13 @@ class Dispatcher:
self.name = name
self.restart = srv.restart
self.shutdown = srv.shutdown
# handle to server
self.srv = srv
# set of modules that failed creation
self.failed_modules = set()
# list of errors that occured during initialization
self.errors = []
self.traceback_counter = 0
def broadcast_event(self, msg, reallyall=False):
"""broadcasts a msg to all active connections
@ -147,11 +156,93 @@ class Dispatcher:
self._export.append(modulename)
def get_module(self, modulename):
""" Returns a fully initialized module. Or None, if something went
wrong during instatiating/initializing the module."""
modobj = self.get_module_instance(modulename)
if modobj is None:
return None
if modobj._isinitialized:
return modobj
# also call earlyInit on the modules
self.log.debug('initializing module %r', modulename)
try:
modobj.earlyInit()
if not modobj.earlyInitDone:
self.errors.append(f'{modobj.earlyInit.__qualname__} was not called, probably missing super call')
modobj.initModule()
if not modobj.initModuleDone:
self.errors.append(f'{modobj.initModule.__qualname__} was not called, probably missing super call')
except Exception as e:
if self.traceback_counter == 0:
self.log.exception(traceback.format_exc())
self.traceback_counter += 1
self.errors.append(f'error initializing {modulename}: {e!r}')
modobj._isinitialized = True
self.log.debug('initialized module %r', modulename)
return modobj
def get_module_instance(self, modulename):
""" Returns the module in its current initialization state or creates a
new uninitialized modle to return.
When creating a new module, srv.module_config is accessed to get the
modules configuration.
"""
if modulename in self._modules:
return self._modules[modulename]
if modulename in list(self._modules.values()):
# it's actually already the module object
return modulename
# create module from srv.module_cfg, store and return
self.log.debug('attempting to create module %r', modulename)
opts = self.srv.module_cfg.get(modulename, None)
if opts is None:
raise NoSuchModuleError(f'Module {modulename!r} does not exist on this SEC-Node!')
pymodule = None
try: # pylint: disable=no-else-return
classname = opts.pop('cls')
if isinstance(classname, str):
pymodule = classname.rpartition('.')[0]
if pymodule in self.failed_modules:
# creation has failed already once, do not try again
return None
cls = get_class(classname)
else:
pymodule = classname.__module__
if pymodule in self.failed_modules:
# creation has failed already once, do not try again
return None
cls = classname
except Exception as e:
if str(e) == 'no such class':
self.errors.append(f'{classname} not found')
else:
self.failed_modules.add(pymodule)
if self.traceback_counter == 0:
self.log.exception(traceback.format_exc())
self.traceback_counter += 1
self.errors.append(f'error importing {classname}')
return None
else:
try:
modobj = cls(modulename, self.log.getChild(modulename), opts, self.srv)
except ConfigError as e:
self.errors.append(f'error creating module {modulename}:')
for errtxt in e.args[0] if isinstance(e.args[0], list) else [e.args[0]]:
self.errors.append(' ' + errtxt)
modobj = None
except Exception as e:
if self.traceback_counter == 0:
self.log.exception(traceback.format_exc())
self.traceback_counter += 1
self.errors.append(f'error creating {modulename}')
modobj = None
if modobj:
self.register_module(modobj, modulename, modobj.export)
self.srv.modules[modulename] = modobj # IS HERE THE CORRECT PLACE?
return modobj
def remove_module(self, modulename_or_obj):
moduleobj = self.get_module(modulename_or_obj)
@ -183,6 +274,7 @@ class Dispatcher:
def get_descriptive_data(self, specifier):
"""returns a python object which upon serialisation results in the descriptive data"""
specifier = specifier or ''
modules = {}
result = {'modules': modules}
for modulename in self._export:
@ -194,7 +286,7 @@ class Dispatcher:
mod_desc.update(module.exportProperties())
mod_desc.pop('export', False)
modules[modulename] = mod_desc
modname, _, pname = (specifier or '').partition(':')
modname, _, pname = specifier.partition(':')
if modname in modules: # extension to SECoP standard: description of a single module
result = modules[modname]
if pname in result['accessibles']: # extension to SECoP standard: description of a single accessible

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
@ -24,16 +23,16 @@
"""Define helpers"""
import os
import signal
import sys
import traceback
from collections import OrderedDict
from frappy.errors import ConfigError, SECoPError
from frappy.lib import formatException, get_class, generalConfig
from frappy.config import load_config
from frappy.errors import ConfigError
from frappy.dynamic import Pinata
from frappy.lib import formatException, generalConfig, get_class, mkthread
from frappy.lib.multievent import MultiEvent
from frappy.params import PREDEFINED_ACCESSIBLES
from frappy.modules import Attached
from frappy.config import load_config
try:
from daemon import DaemonContext
@ -106,6 +105,12 @@ class Server:
self._cfgfiles = cfgfiles
self._pidfile = os.path.join(generalConfig.piddir, name + '.pid')
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
def signal_handler(self, _num, _frame):
if hasattr(self, 'interface') and self.interface:
self.shutdown()
def start(self):
if not DaemonContext:
@ -127,17 +132,18 @@ class Server:
return f"{cls.__name__} class don't know how to handle option(s): {', '.join(options)}"
def restart_hook(self):
pass
"""Actions to be done on restart. May be overridden by a subclass."""
def run(self):
global systemd # pylint: disable=global-statement
while self._restart:
self._restart = False
try:
# TODO: make systemd notifications configurable
if systemd: # pylint: disable=used-before-assignment
if systemd:
systemd.daemon.notify("STATUS=initializing")
except Exception:
systemd = None # pylint: disable=redefined-outer-name
systemd = None
try:
self._processCfg()
if self._testonly:
@ -156,12 +162,26 @@ class Server:
self.log.info('startup done, handling transport messages')
if systemd:
systemd.daemon.notify("READY=1\nSTATUS=accepting requests")
self.interface.serve_forever()
self.interface.server_close()
t = mkthread(self.interface.serve_forever)
# we wait here on the thread finishing, which means we got a
# signal to shut down or an exception was raised
# TODO: get the exception (and re-raise?)
t.join()
self.interface = None # fine due to the semantics of 'with'
# server_close() called by 'with'
self.log.info(f'stopped listenning, cleaning up'
f' {len(self.modules)} modules')
# if systemd:
# if self._restart:
# systemd.daemon.notify('RELOADING=1')
# else:
# systemd.daemon.notify('STOPPING=1')
for name in self._getSortedModules():
self.modules[name].shutdownModule()
if self._restart:
self.restart_hook()
self.log.info('restart')
else:
self.log.info('restarting')
self.log.info('shut down')
def restart(self):
@ -174,85 +194,57 @@ class Server:
self.interface.shutdown()
def _processCfg(self):
"""Processes the module configuration.
All modules specified in the config file and read recursively from
Pinata class Modules are instantiated, initialized and started by the
end of this function.
If there are errors that occur, they will be collected and emitted
together in the end.
"""
errors = []
opts = dict(self.node_cfg)
cls = get_class(opts.pop('cls'))
self.dispatcher = cls(opts.pop('name', self._cfgfiles), self.log.getChild('dispatcher'), opts, self)
self.dispatcher = cls(opts.pop('name', self._cfgfiles),
self.log.getChild('dispatcher'), opts, self)
if opts:
errors.append(self.unknown_options(cls, opts))
self.dispatcher.errors.append(self.unknown_options(cls, opts))
self.modules = OrderedDict()
failure_traceback = None # traceback for the first error
failed = set() # python modules failed to load
self.lastError = None
for modname, options in self.module_cfg.items():
opts = dict(options)
pymodule = None
try:
classname = opts.pop('cls')
pymodule = classname.rpartition('.')[0]
if pymodule in failed:
# create and initialize modules
todos = list(self.module_cfg.items())
while todos:
modname, options = todos.pop(0)
if modname in self.modules:
# already created by Dispatcher (via Attached)
continue
# For Pinata modules: we need to access this in Dispatcher.get_module
self.module_cfg[modname] = dict(options)
modobj = self.dispatcher.get_module_instance(modname) # lazy
if modobj is None:
self.log.debug('Module %s returned None', modname)
continue
cls = get_class(classname)
except Exception as e:
if str(e) == 'no such class':
errors.append(f'{classname} not found')
else:
failed.add(pymodule)
if failure_traceback is None:
failure_traceback = traceback.format_exc()
errors.append(f'error importing {classname}')
else:
try:
modobj = cls(modname, self.log.getChild(modname), opts, self)
self.modules[modname] = modobj
except ConfigError as e:
errors.append(f'error creating module {modname}:')
for errtxt in e.args[0] if isinstance(e.args[0], list) else [e.args[0]]:
errors.append(' ' + errtxt)
except Exception:
if failure_traceback is None:
failure_traceback = traceback.format_exc()
errors.append(f'error creating {modname}')
if isinstance(modobj, Pinata):
# scan for dynamic devices
pinata = self.dispatcher.get_module(modname)
pinata_modules = list(pinata.scanModules())
for name, _cfg in pinata_modules:
if name in self.module_cfg:
self.log.error('Module %s, from pinata %s, already'
' exists in config file!', name, modname)
self.log.info('Pinata %s found %d modules', modname, len(pinata_modules))
todos.extend(pinata_modules)
missing_super = set()
# all objs created, now start them up and interconnect
for modname, modobj in self.modules.items():
self.log.info('registering module %r', modname)
self.dispatcher.register_module(modobj, modname, modobj.export)
# also call earlyInit on the modules
modobj.earlyInit()
if not modobj.earlyInitDone:
missing_super.add(f'{modobj.earlyInit.__qualname__} was not called, probably missing super call')
# initialize all modules by getting them with Dispatcher.get_module,
# which is done in the get_descriptive data
# TODO: caching, to not make this extra work
self.dispatcher.get_descriptive_data('')
# =========== All modules are initialized ===========
# handle attached modules
for modname, modobj in self.modules.items():
attached_modules = {}
for propname, propobj in modobj.propertyDict.items():
if isinstance(propobj, Attached):
try:
attname = getattr(modobj, propname)
if attname: # attached module specified in cfg file
attobj = self.dispatcher.get_module(attname)
if isinstance(attobj, propobj.basecls):
attached_modules[propname] = attobj
else:
errors.append(f'attached module {propname}={attname!r} '\
f'must inherit from {propobj.basecls.__qualname__!r}')
except SECoPError as e:
errors.append(f'module {modname}, attached {propname}: {str(e)}')
modobj.attachedModules = attached_modules
# call init on each module after registering all
for modname, modobj in self.modules.items():
try:
modobj.initModule()
if not modobj.initModuleDone:
missing_super.add(f'{modobj.initModule.__qualname__} was not called, probably missing super call')
except Exception as e:
if failure_traceback is None:
failure_traceback = traceback.format_exc()
errors.append(f'error initializing {modname}: {e!r}')
# all errors from initialization process
errors = self.dispatcher.errors
if not self._testonly:
start_events = MultiEvent(default_timeout=30)
@ -261,8 +253,7 @@ class Server:
start_events.name = f'module {modname}'
modobj.startModule(start_events)
if not modobj.startModuleDone:
missing_super.add(f'{modobj.startModule.__qualname__} was not called, probably missing super call')
errors.extend(missing_super)
errors.append(f'{modobj.startModule.__qualname__} was not called, probably missing super call')
if errors:
for errtxt in errors:
@ -271,8 +262,6 @@ class Server:
# print a list of config errors to stderr
sys.stderr.write('\n'.join(errors))
sys.stderr.write('\n')
if failure_traceback:
sys.stderr.write(failure_traceback)
sys.exit(1)
if self._testonly:
@ -299,3 +288,41 @@ class Server:
# history_path = os.environ.get('ALTERNATIVE_HISTORY')
# if history_path:
# from frappy_<xx>.historywriter import ... etc.
def _getSortedModules(self):
"""Sort modules topologically by inverse dependency.
Example: if there is an IO device A and module B depends on it, then
the result will be [B, A].
Right now, if the dependency graph is not a DAG, we give up and return
the unvisited nodes to be dismantled at the end.
Taken from Introduction to Algorithms [CLRS].
"""
def go(name):
if name in done: # visiting a node
return True
if name in visited:
visited.add(name)
return False # cycle in dependencies -> fail
visited.add(name)
if name in unmarked:
unmarked.remove(name)
for module in self.modules[name].attachedModules.values():
res = go(module.name)
if not res:
return False
visited.remove(name)
done.add(name)
l.append(name)
return True
unmarked = set(self.modules.keys()) # unvisited nodes
visited = set() # visited in DFS, but not completed
done = set()
l = [] # list of sorted modules
while unmarked:
if not go(unmarked.pop()):
self.log.error('cyclical dependency between modules!')
return l[::-1] + list(visited) + list(unmarked)
return l[::-1]

164
frappy/structparam.py Normal file
View File

@ -0,0 +1,164 @@
# *****************************************************************************
#
# 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 <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""convenience class to create a struct Parameter together with indivdual params
Usage:
class Controller(Drivable):
...
ctrlpars = StructParam('ctrlpars struct', [
('pid_p', 'p', Parameter('control parameter p', FloatRange())),
('pid_i', 'i', Parameter('control parameter i', FloatRange())),
('pid_d', 'd', Parameter('control parameter d', FloatRange())),
], readonly=False)
...
then implement either read_ctrlpars and write_ctrlpars or
read_pid_p, read_pid_i, read_pid_d, write_pid_p, write_pid_i and write_pid_d
the methods not implemented will be created automatically
"""
from frappy.core import Parameter, Property
from frappy.datatypes import BoolType, DataType, StructOf, ValueType
from frappy.errors import ProgrammingError
class StructParam(Parameter):
"""create a struct parameter together with individual parameters
in addition to normal Parameter arguments:
:param paramdict: dict <member name> of Parameter(...)
:param prefix_or_map: either a prefix for the parameter name to add to the member name
or a dict <member name> or <paramerter name>
"""
# use properties, as simple attributes are not considered on copy()
paramdict = Property('dict <parametername> of Parameter(...)', ValueType())
hasStructRW = Property('has a read_<struct param> or write_<struct param> method',
BoolType(), default=False)
insideRW = 0 # counter for avoiding multiple superfluous updates
def __init__(self, description=None, paramdict=None, prefix_or_map='', *, datatype=None, readonly=False, **kwds):
if isinstance(paramdict, DataType):
raise ProgrammingError('second argument must be a dict of Param')
if datatype is None and paramdict is not None: # omit the following on Parameter.copy()
if isinstance(prefix_or_map, str):
prefix_or_map = {m: prefix_or_map + m for m in paramdict}
for membername, param in paramdict.items():
param.name = prefix_or_map[membername]
datatype = StructOf(**{m: p.datatype for m, p in paramdict.items()})
kwds['influences'] = [p.name for p in paramdict.values()]
self.updateEnable = {}
super().__init__(description, datatype, paramdict=paramdict, readonly=readonly, **kwds)
def __set_name__(self, owner, name):
# names of access methods of structed param (e.g. ctrlpars)
struct_read_name = f'read_{name}' # e.g. 'read_ctrlpars'
struct_write_name = f'write_{name}' # e.h. 'write_ctrlpars'
self.hasStructRW = hasattr(owner, struct_read_name) or hasattr(owner, struct_write_name)
for membername, param in self.paramdict.items():
pname = param.name
changes = {
'readonly': self.readonly,
'influences': set(param.influences) | {name},
}
param.ownProperties.update(changes)
param.init(changes)
setattr(owner, pname, param)
param.__set_name__(owner, param.name)
if self.hasStructRW:
rname = f'read_{pname}'
if not hasattr(owner, rname):
def rfunc(self, membername=membername, struct_read_name=struct_read_name):
return getattr(self, struct_read_name)()[membername]
rfunc.poll = False # read_<struct param> is polled only
setattr(owner, rname, rfunc)
if not self.readonly:
wname = f'write_{pname}'
if not hasattr(owner, wname):
def wfunc(self, value, membername=membername,
name=name, rname=rname, struct_write_name=struct_write_name):
valuedict = dict(getattr(self, name))
valuedict[membername] = value
getattr(self, struct_write_name)(valuedict)
return getattr(self, rname)()
setattr(owner, wname, wfunc)
if not self.hasStructRW:
if not hasattr(owner, struct_read_name):
def struct_read_func(self, name=name, flist=tuple(
(m, f'read_{p.name}') for m, p in self.paramdict.items())):
pobj = self.parameters[name]
# disable updates generated from the callbacks of individual params
pobj.insideRW += 1 # guarded by self.accessLock
try:
return {m: getattr(self, f)() for m, f in flist}
finally:
pobj.insideRW -= 1
setattr(owner, struct_read_name, struct_read_func)
if not (self.readonly or hasattr(owner, struct_write_name)):
def struct_write_func(self, value, name=name, funclist=tuple(
(m, f'write_{p.name}') for m, p in self.paramdict.items())):
pobj = self.parameters[name]
pobj.insideRW += 1 # guarded by self.accessLock
try:
return {m: getattr(self, f)(value[m]) for m, f in funclist}
finally:
pobj.insideRW -= 1
setattr(owner, struct_write_name, struct_write_func)
super().__set_name__(owner, name)
def finish(self, modobj=None):
"""register callbacks for consistency"""
super().finish(modobj)
if modobj:
if self.hasStructRW:
def cb(value, modobj=modobj, structparam=self):
for membername, param in structparam.paramdict.items():
setattr(modobj, param.name, value[membername])
modobj.valueCallbacks[self.name].append(cb)
else:
for membername, param in self.paramdict.items():
def cb(value, modobj=modobj, structparam=self, membername=membername):
if not structparam.insideRW:
prev = dict(getattr(modobj, structparam.name))
prev[membername] = value
setattr(modobj, structparam.name, prev)
modobj.valueCallbacks[param.name].append(cb)

View File

@ -70,9 +70,8 @@ def get_version(abbrev=4):
if git_version != release_version:
write_release_version(git_version)
return git_version
elif release_version:
if release_version:
return release_version
else:
raise ValueError('Cannot find a version number - make sure that '
'git is installed or a RELEASE-VERSION file is '
'present!')

View File

@ -354,7 +354,7 @@ class Cryostat(CryoBase):
timestamp = t
self.read_value()
def shutdown(self):
def shutdownModule(self):
# should be called from server when the server is stopped
self._stopflag = True
if self._thread and self._thread.is_alive():

View File

@ -24,10 +24,33 @@
import random
from frappy.datatypes import FloatRange, StringType, ValueType, TupleOf, StructOf, ArrayOf
from frappy.modules import Communicator, Drivable, Parameter, Property, Readable, Module
from frappy.modules import Communicator, Drivable, Parameter, Property, Readable, Module, Attached
from frappy.params import Command
from frappy.dynamic import Pinata
from frappy.errors import RangeError
class Pin(Pinata):
def scanModules(self):
yield ('pin_a', {'cls': LN2, 'description':'hi'})
yield ('pin_b', {'cls': LN2, 'description':'hi'})
class RecPin(Pinata):
def scanModules(self):
yield ('rec_a', {'cls': RecPinInner, 'description':'hi'})
yield ('rec_b', {'cls': RecPinInner, 'description':'hi'})#, 'idx':'_2'})
class RecPinInner(Pinata):
idx = Property('', StringType(), default='')
def scanModules(self):
yield ('pin_pin_a' + self.idx, {'cls': Mapped, 'description':'recursive!', 'choices':['A', 'B']})
yield ('pin_pin_b' + self.idx, {'cls': Mapped, 'description':'recursive!', 'choices':['A', 'B']})
class WithAtt(Readable):
att = Attached()
def read_value(self):
return self.att.read_value()
class LN2(Readable):
"""Just a readable.

363
frappy_mlz/plc_zapf.py Normal file
View File

@ -0,0 +1,363 @@
# *****************************************************************************
#
# 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:
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
import re
import zapf
import zapf.spec as zspec
from zapf.io import PlcIO
from zapf.scan import Scanner
from frappy.core import BUSY, DISABLED, ERROR, FINALIZING, IDLE, \
INITIALIZING, STARTING, UNKNOWN, WARN, Attached, Command, Communicator, \
Drivable, Parameter, Property, Readable
from frappy.datatypes import UNLIMITED, ArrayOf, BLOBType, EnumType, \
FloatRange, IntRange, StatusType, StringType, ValueType
from frappy.dynamic import Pinata
from frappy.errors import CommunicationFailedError, ImpossibleError, \
IsBusyError, NoSuchParameterError, ReadOnlyError
# Untested with real hardware, only testplc_2021_09.py
def internalize_name(name):
return re.sub(r'[^a-zA-Z0-9_]+', '_', name, re.ASCII)
ERROR_MAP = {
# should not happen. but better to have it here anyway
5: NoSuchParameterError,
# if this occurs, something may have gone wrong with digesting the scanner
# data
6: ReadOnlyError,
# Most likely from devices you cannot poll when busy.
7: IsBusyError,
}
class ZapfPinata(Pinata):
"""The Pinata device for a PLC that can be accessed according to PILS.
See https://forge.frm2.tum.de/public/doc/plc/master/html/
Instantiates the classes with the base mapped class, which will be replaced
by initModule, so modules can also be configured manually in the config
file.
"""
iodev = Property('Connection to PLC', StringType())
def scanModules(self):
try:
self._plcio = PlcIO(self.iodev, self.log)
except zapf.CommError as e:
raise CommunicationFailedError('could not connect to plc') from e
scanner = Scanner(self._plcio, self.log)
for devinfo in scanner.scan_devices():
if zspec.LOWLEVEL in devinfo.info.get('flags'):
self.log.debug('device %d (%s) is lowlevel, skipping',
devinfo.number, devinfo.name)
continue
device = scanner.get_device(devinfo)
if device is None:
self.log.info(f'{devinfo.name} unsupported')
continue
basecls = CLS_MAP.get(device.__class__, None)
if basecls is None:
self.log.info('No mapping found for %s, (class %s)',
devinfo.name, device.__class__.__name__)
continue
mod_cls = basecls.makeModuleClass(device, devinfo)
config = {
'cls': mod_cls,
'plcio': device,
'description': devinfo.info['description'],
'plc_name': devinfo.name,
'_pinata': self.name,
}
if devinfo.info['basetype'] != 'enum' \
and not issubclass(basecls, PLCCommunicator):
config['value'] = {
# internal limit here is 2**64, zapf reports 2**128
'min': max(devinfo.info['absmin'], -UNLIMITED),
'max': min(devinfo.info['absmax'], UNLIMITED),
}
if devinfo.info['access'] == 'rw':
config['target'] = {
'min': config['value']['min'],
'max': config['value']['max'],
}
name = internalize_name(devinfo.name)
yield (name, config)
self._plcio.start_cache()
def shutdownModule(self):
"""Shutdown the module, _plcio might be invalid after this. Needs to be
recreated by scanModules."""
self._plcio.stop_cache()
self._plcio.proto.disconnect()
STATUS_MAP = {
zspec.DevStatus.RESET: (INITIALIZING, 'resetting'),
zspec.DevStatus.IDLE: (IDLE, 'idle'),
zspec.DevStatus.DISABLED: (DISABLED, 'disabled'),
zspec.DevStatus.WARN: (WARN, 'warning'),
zspec.DevStatus.START: (STARTING, 'starting'),
zspec.DevStatus.BUSY: (BUSY, 'busy'),
zspec.DevStatus.STOP: (FINALIZING, 'stopping'),
zspec.DevStatus.ERROR: (ERROR, 'error (please reset)'),
zspec.DevStatus.DIAGNOSTIC_ERROR: (ERROR, 'hard error (please check plc)'),
}
class PLCBase:
status = Parameter(datatype=StatusType(Drivable, 'INITIALIZING',
'DISABLED', 'STARTING'))
status_code = Parameter('raw internal status code',
IntRange(0, 2**32-1))
plcio = Property('plc io device', ValueType())
plc_name = Property('plc io device', StringType(), export=True)
_pinata = Attached(ZapfPinata) # TODO: make this automatic?
@classmethod
def makeModuleClass(cls, device, devinfo):
# add parameters and commands according to device info
add_members = {}
# set correct enums for value/target
if devinfo.info['basetype'] == 'enum':
rmap = {v: k for k, v in devinfo.info['enum_r'].items()}
read_enum = EnumType(rmap)
add_members['value'] = Parameter(datatype=read_enum)
if hasattr(cls, 'target'):
#wmap = {k:v for k, v in devinfo.info['enum_w'].items()}
#write_enum = EnumType(wmap)
write_enum = EnumType(devinfo.info['enum_w'])
add_members['target'] = Parameter(datatype=write_enum)
for parameter in device.list_params():
info = devinfo.info['params'][parameter]
iname = internalize_name(parameter)
readonly = info.get('access', 'ro') != 'rw'
dataty = cls._map_datatype(info)
if dataty is None:
continue
param = Parameter(info['description'],
dataty,
readonly=readonly)
def read_param(self, parameter=parameter):
code, val = self.plcio.get_param_raw(parameter)
if code > 4:
raise ERROR_MAP[code](f'Error when reading parameter'
f'{parameter}: {code}')
return val
def write_param(self, value, parameter=parameter):
code, val = self.plcio.set_param_raw(parameter, value)
if code > 4:
raise ERROR_MAP[code](f'Error when setting parameter'
f'{parameter} to {value!r}: {code}')
return val
# enums can have asymmetric read and write variants. this should be
# checked
if info['basetype'] == 'enum':
allowed = frozenset(info['enum_w'].values())
#pylint: disable=function-redefined
def write_param(self, value, allowed=allowed, parameter=parameter):
if value not in allowed:
raise ValueError(f'Invalid value for writing'
f' {parameter}: {value!r}')
code, val = self.plcio.set_param_raw(parameter, value)
if code > 4:
raise ERROR_MAP[code](f'Error when setting parameter'
f'{parameter} to {value!r}: {code}')
return val
add_members[iname] = param
add_members['read_' + iname] = read_param
if readonly:
continue
add_members['write_' + iname] = write_param
for command in device.list_funcs():
info = devinfo.info['funcs'][command]
iname = internalize_name(command)
if info['argument']:
arg = cls._map_datatype(info['argument'])
else:
arg = None
if info['result']:
result = cls._map_datatype(info['result'])
else:
result = None
def exec_command(self, arg=None, command=command):
# TODO: commands return <err/succ>, <result>
return self.plcio.exec_func(command, arg)
decorator = Command(arg,
result = result,
description=info['description'],
)
func = decorator(exec_command)
add_members['call_' + iname] = func
if not add_members:
return cls
new_name = '_' + cls.__name__ + '_' \
+ internalize_name("blub")
return type(new_name, (cls,), add_members)
@classmethod
def _map_datatype(cls, info):
dataty = info['basetype']
if dataty == 'int':
return IntRange(info['min_value'], info['max_value'])
if dataty == 'float':
return FloatRange(info['min_value'], info['max_value'])
if dataty == 'enum':
mapping = {v: k for k, v in info['enum_r'].items()}
return EnumType(mapping)
return None
def read_status(self):
state, reason, aux, err_id = self.plcio.read_status()
if state in STATUS_MAP:
status, m = STATUS_MAP[state]
else:
status, m = UNKNOWN, 'unknown state 0x%x' % state
msg = [m]
reason = zapf.spec.ReasonMap[reason]
if reason:
msg.append(reason)
if aux:
msg.append(self.plcio.decode_aux(aux))
if err_id:
msg.append(self.plcio.decode_errid(err_id))
return status, ', '.join(msg)
def read_status_code(self):
state, reason, aux, _ = self.plcio.read_status()
return state << 28 | reason << 24 | aux
@Command()
def stop(self):
"""Stop the operation of this module.
:raises:
ImpossibleError: if the command is called while the module is
not busy
"""
if not self.plcio.change_status((zapf.DevStatus.BUSY,),
zapf.DevStatus.STOP):
self.log.info('stop was called when device was not busy')
# TODO: off/on?
@Command()
def reset(self):
"""Tries to reset this module.
:raises:
ImpossibleError: when called while the module is not in an error
state.
"""
if not self.plcio.reset():
raise ImpossibleError('reset called when the device is not in'
'an error state!')
class PLCValue(PLCBase):
"""Base class for all but Communicator"""
def read_value(self):
return self.plcio.read_value_raw() # read_value maps enums on zapf side
def read_target(self):
return self.plcio.read_target_raw()
def write_target(self, value):
self.plcio.change_target_raw(value)
class PLCReadable(PLCValue, Readable):
"""Readable value, scanned from PLC."""
description = Property('the modules description',
datatype=StringType(isUTF8=True))
class PLCDrivable(PLCValue, Drivable):
"""Drivable, scanned from PLC."""
description = Property('the modules description',
datatype=StringType(isUTF8=True))
class PLCCommunicator(PLCBase, Communicator):
status = Parameter('current status of the module')
@Command(BLOBType(), result=BLOBType())
def communicate(self, command):
return self.plcio.communicate(command)
class Sensor(PLCReadable):
pass
class AnalogOutput(PLCDrivable):
pass
class DiscreteInput(PLCReadable):
value = Parameter(datatype=IntRange())
class DiscreteOutput(PLCDrivable):
value = Parameter(datatype=IntRange())
target = Parameter(datatype=IntRange())
class VectorInput(PLCReadable):
value = Parameter(datatype=ArrayOf(FloatRange()))
class VectorOutput(PLCDrivable):
value = Parameter(datatype=ArrayOf(FloatRange()))
target = Parameter(datatype=ArrayOf(FloatRange()))
CLS_MAP = {
zapf.device.SimpleDiscreteIn: DiscreteInput,
zapf.device.SimpleAnalogIn: Sensor,
zapf.device.Keyword: DiscreteOutput,
zapf.device.RealValue: AnalogOutput,
zapf.device.SimpleDiscreteOut: DiscreteOutput,
zapf.device.SimpleAnalogOut: PLCDrivable,
zapf.device.StatusWord: DiscreteInput,
zapf.device.DiscreteIn: DiscreteInput,
zapf.device.AnalogIn: Sensor,
zapf.device.DiscreteOut: DiscreteOutput,
zapf.device.AnalogOut: PLCDrivable,
zapf.device.FlatIn: Sensor,
zapf.device.FlatOut: AnalogOutput,
zapf.device.ParamIn: Sensor,
zapf.device.ParamOut: AnalogOutput,
zapf.device.VectorIn: VectorInput,
zapf.device.VectorOut: VectorOutput,
zapf.device.MessageIO: PLCCommunicator,
}

234
frappy_mlz/seop.py Normal file
View File

@ -0,0 +1,234 @@
# -*- coding: utf-8 -*-
# *****************************************************************************
#
# 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:
# Georg Brandl <g.brandl@fz-juelich.de>
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
"""Adapter to the existing SEOP 3He spin filter system daemon."""
from os import path
# eventually he3control
from he3d import he3cell # pylint: disable=import-error
from frappy.core import Attached
from frappy.datatypes import ArrayOf, FloatRange, IntRange, StatusType, \
StringType, TupleOf
from frappy.errors import CommandRunningError
from frappy.modules import Command, Drivable, Module, Parameter, Property, \
Readable
from frappy.rwhandler import CommonReadHandler
integral = IntRange()
floating = FloatRange()
string = StringType()
# Configuration is kept in YAML files to stay compatible to the
# traditional 3He daemon, for now.
class Cell(Module):
""" Dummy module for creating He3Cell object in order for other modules to talk to the hardware.
Only deals with the config, and rotating the paramlog.
"""
config_directory = Property(
'Directory for the YAML config files', datatype=string)
def initModule(self):
super().initModule()
self.cell = he3cell.He3_cell(
path.join(self.config_directory, 'cell.yml'))
# Commands
@Command(result=string)
def raw_config_file(self):
"""return unparsed contents of yaml file"""
with open(self.cell._He3_cell__cfg_filename, 'r', encoding='utf-8') as f:
return str(f.read())
@Command(string, result=string)
def cfg_get(self, identifier):
"""Get a configuration value."""
return str(self.cell.cfg_get(identifier))
@Command((string, string), result=string)
def cfg_set(self, identifier, value):
"""Set a configuration value."""
try:
value = int(value)
except ValueError:
try:
value = float(value)
except ValueError:
pass
# The type is lost during transmission.
# Check type so the value to be set has the same type and
# is not eg. a string where an int would be needed in the config key.
oldty = type(self.cell.cfg_get(identifier))
if oldty is not type(value):
raise ValueError('Type of value to be set does not match the '
'value in the configuration!')
return str(self.cell.cfg_set(identifier, value))
@Command()
def nmr_paramlog_rotate(self):
"""resets fitting and switches to a new logfile"""
self.cell.nmr_paramlog_rotate()
class Afp(Readable):
"""Polarisation state of the SEOP waveplates"""
value = Parameter('Current polarisation state of the SEOP waveplates', IntRange(0, 1))
cell = Attached(Cell)
def read_value(self):
return self.cell.cell.afp_state_get()
# Commands
@Command(description='Flip polarization of SEOP waveplates')
def afp_flip(self):
self.cell.cell.afp_flip_do()
self.read_value()
class Nmr(Readable):
Status = Drivable.Status
status = Parameter(datatype=StatusType(Drivable.Status))
value = Parameter('Timestamp of last NMR', string)
cell = Attached(Cell)
def initModule(self):
super().initModule()
self.interval = 0
def read_value(self):
return str(self.cell.cell.nmr_timestamp_get())
def read_status(self):
cellstate = self.cell.cell.nmr_state_get()
if self.cell.cell.nmr_background_check():
status = self.Status.BUSY, 'running every %d seconds' % self.interval
else:
status = self.Status.IDLE, 'not running'
# TODO: what do we do here with None and -1?
# -> None basically indicates that the fit for the parameters did not converge
if cellstate is None:
return self.Status.IDLE, f'returned None, {status[1]}'
if cellstate in (0, 1):
return status[0], f'nmr cellstate {cellstate}, {status[1]}'
if cellstate == -1:
return self.Status.WARN, f'got error from cell, {status[1]}'
return self.Status.ERROR, 'Unrecognized cellstate!'
# Commands
@Command()
def nmr_do(self):
"""Triggers the NMR to run"""
self.cell.cell.nmr_do()
self.read_status()
@Command()
def bgstart(self):
"""Start background NMR"""
if self.isBusy():
raise CommandRunningError('backgroundNMR is already running')
interval = self.cell.cell.cfg_get('tasks/nmr/background/interval')
self.interval = interval
self.cell.cell.nmr_background_start(interval)
self.read_status()
@Command()
def bgstop(self):
"""Stop background NMR"""
self.cell.cell.nmr_background_stop()
self.read_status()
# Commands to get large datasets we do not want directly in the NICOS cache
@Command(result=TupleOf(ArrayOf(floating, maxlen=100000),
ArrayOf(floating, maxlen=100000)))
def get_processed_nmr(self):
"""Get data for processed signal."""
val= self.cell.cell.nmr_processed_get()
return (val['xval'], val['yval'])
@Command(result=TupleOf(ArrayOf(floating, maxlen=100000),
ArrayOf(floating, maxlen=100000)))
def get_raw_nmr(self):
"""Get raw signal data."""
val = self.cell.cell.nmr_raw_get()
return (val['xval'], val['yval'])
@Command(result=TupleOf(ArrayOf(floating, maxlen=100000),
ArrayOf(floating, maxlen=100000)))
def get_raw_spectrum(self):
"""Get the raw spectrum."""
val = self.cell.cell.nmr_raw_spectrum_get()
y = val['yval'][:len(val['xval'])]
return (val['xval'], y)
@Command(result=TupleOf(ArrayOf(floating, maxlen=100000),
ArrayOf(floating, maxlen=100000)))
def get_processed_spectrum(self):
"""Get the processed spectrum."""
val = self.cell.cell.nmr_processed_spectrum_get()
x = val['xval'][:len(val['yval'])]
return (x, val['yval'])
@Command(result=TupleOf(ArrayOf(string, maxlen=100),
ArrayOf(floating, maxlen=100)))
def get_amplitude(self):
"""Last 20 amplitude datapoints."""
rv = self.cell.cell.nmr_paramlog_get('amplitude', 20)
x = [ str(timestamp) for timestamp in rv['xval']]
return (x,rv['yval'])
@Command(result=TupleOf(ArrayOf(string, maxlen=100),
ArrayOf(floating, maxlen=100)))
def get_phase(self):
"""Last 20 phase datapoints."""
val = self.cell.cell.nmr_paramlog_get('phase', 20)
return ([str(timestamp) for timestamp in val['xval']], val['yval'])
class FitParam(Readable):
value = Parameter('fitted value', unit='$', default=0.0)
sigma = Parameter('variance of the fitted value', FloatRange(), default=0.0)
param = Property('the parameter that should be accesssed',
StringType(), export=False)
cell = Attached(Cell)
@CommonReadHandler(['value', 'sigma'])
def read_amplitude(self):
ret = self.cell.cell.nmr_param_get(self.param)
self.value = ret['value']
self.sigma = ret['sigma']
# Commands
@Command(integral, result=TupleOf(ArrayOf(string),
ArrayOf(floating)))
def nmr_paramlog_get(self, n):
"""returns the log of the last 'n' values for this parameter"""
val = self.cell.cell.nmr_paramlog_get(self.param, n)
return ([str(timestamp) for timestamp in val['xval']], val['yval'])

296
frappy_mlz/zebra.py Normal file
View File

@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
# *****************************************************************************
# MLZ library of Tango servers
# Copyright (c) 2015-2023 by the authors, see LICENSE
#
# 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:
# Georg Brandl <g.brandl@fz-juelich.de>
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
import threading
from time import sleep, time
from frappy.core import Parameter, Command, nopoll, Readable
from frappy.io import HasIO, BytesIO
from frappy.lib import mkthread
from frappy.errors import CommunicationFailedError
from frappy.datatypes import IntRange, StringType, StatusType
# SSI protocol operations
CMD_ACK = 0xD0
CMD_NAK = 0xD1
DECODE_DATA = 0xF3
BEEP = 0xE6
REQUEST_REVISION = 0xA3
REPLY_REVISION = 0xA4
SCAN_ENABLE = 0xE9
SCAN_DISABLE = 0xEA
# source byte
HOST = 4
DECODER = 0
BARCODE_TYPES = {
0x2d: 'Aztec',
0x2e: 'Aztec Rune',
0x16: 'Bookland',
0x72: 'C 2 of 5',
0x02: 'Codabar',
0x0c: 'Code 11',
0x03: 'Code 128',
0x12: 'Code 16K',
0x20: 'Code 32',
0x01: 'Code 39',
0x13: 'Code 39 ASCII',
0x0d: 'Code 49',
0x07: 'Code 93',
0x17: 'Coupon',
0x38: 'Cue CAT',
0x04: 'D25',
0x1b: 'Data Matrix',
0x0f: 'GS1-128',
0xc2: 'GS1 QR',
0x0b: 'EAN-13',
0x4b: 'EAN-13 + 2',
0x8b: 'EAN-13 + 5',
0x0a: 'EAN-8',
0x4a: 'EAN-8 + 2',
0x8a: 'EAN-8 + 5',
0x2f: 'French Lottery',
0x32: 'GS1 DataBar Expanded',
0x31: 'GS1 DataBar Limited',
0x30: 'GS1 DataBar-14',
0xc1: 'GS1 Datamatrix',
0xb7: 'Han Xin',
0x05: 'IATA',
0x19: 'ISBT-128',
0x21: 'ISBT-128 Concat',
0x36: 'ISSN',
0x06: 'ITF',
0x73: 'Korean 2 of 5',
0x9a: 'Macro Micro PDF',
0x28: 'Macro PDF-417',
0x29: 'Macro QR',
0x39: 'Matrix 2 of 5',
0x25: 'Maxicode',
0x1a: 'Micro PDF',
0x1d: 'Micro PDF CCA',
0x2c: 'Micro QR',
0x0e: 'MSI',
0x99: 'Multipacket Format',
0x18: 'NW7',
0xa0: 'OCRB',
0x33: 'Parameter FNC3',
0x11: 'PDF-417',
0x1f: 'Planet US',
0x23: 'Postal AUS',
0x24: 'Postal NL',
0x22: 'Postal JAP',
0x27: 'Postal UK',
0x26: 'Postbar CA',
0x1e: 'Postnet US',
0x1c: 'QR',
0xe0: 'RFID Raw',
0xe1: 'RFID URI',
0xb4: 'RSS Expanded',
0x37: 'Scanlet Webcode',
0x69: 'Signature',
0x5a: 'TLC-39',
0x15: 'Trioptic',
0x08: 'UPCA',
0x48: 'UPCA + 2',
0x88: 'UPCA + 5',
0x14: 'UPCD',
0x09: 'UPCE',
0x49: 'UPCE + 2',
0x89: 'UPCE + 5',
0x10: 'UPCE1',
0x50: 'UPCE1 + 2',
0x90: 'UPCE1 + 5',
0x34: '4State US',
0x35: '4State US4',
}
def decode_bytes(byte_list):
return bytes(byte_list).decode('latin1')
class ZebraIO(BytesIO):
default_settings = {'baudrate': 115200}
def _cksum(self, data):
cksum = 0x10000 - sum(data)
return [cksum >> 8, cksum & 0xFF]
def _make_package(self, op, data):
msg = [len(data) + 4, op, HOST, 0] + data
return msg + self._cksum(msg)
def _ssi_send(self, op, data):
self.communicate(self._make_package(op, data), 0)
def _ssi_read_n(self, n, timeout, buf):
# read N bytes with specified timeout
end = time() + timeout
delay = 0.00005
while n and time() < end:
sleep(delay)
delay = min(2 * delay, 0.01)
newdata = self.readBytes(int(n))
n -= len(newdata)
buf.extend(newdata)
return buf
def _ssi_recv(self, expected_op, recv_timeout, rest_timeout):
# first determine how much data there is to read
buf = []
if not self._ssi_read_n(1, recv_timeout, buf):
return None
# now read the rest of the data
rest_len = buf[0] + 1
self._ssi_read_n(rest_len, rest_timeout, buf)
if len(buf) != rest_len + 1:
return None
if buf[2] != DECODER:
raise CommunicationFailedError('invalid reply received')
if self._cksum(buf[:-2]) != buf[-2:]:
raise CommunicationFailedError('invalid checksum received')
if buf[1] != expected_op:
raise CommunicationFailedError('got op %r, expected %r' %
(buf[0], expected_op))
return buf[3:-2]
def _ssi_comm(self, op, data):
self._ssi_send(op, data)
if self._ssi_recv(CMD_ACK, 1, 1) is None:
raise CommunicationFailedError('ACK not received')
# Not yet tested
class ZebraReader(HasIO, Readable):
"""Reads scanned barcodes from a Zebra barcode reader, using the USB-CDC
interface mode and the SSI protocol.
TODO: CHANGE this paragraph
The underlying IO device must be a BinaryIO since SSI framing and metadata
is transferred in binary.
Since reading barcodes is initiated by the device and not the host, the
parameter decoded does not give the last decoded value when polled.
Instead, activate updates for this parameter, which are then sent out when
the barcode reader decodes a value. Polling will always return an empty
string.
The update for decoded then contains the decoded barcode type as a string,
a comma as a separator, and then the barcode data.
As a special API, there is a Beep command to make the reader emit some
audible signal.
"""
ioClass = ZebraIO
decoded = Parameter('decoded barcode (updates-only)', StringType(),
update_unchanged='always', default='')
# TODO: Decide, if this is useful, remove otherwise
status = Parameter('status of the module',
StatusType('IDLE', 'WARN', 'ERROR'))
value = Parameter(datatype=StringType(), default='',
update_unchanged='never')
_thread = None
_stoprequest = False
def initModule(self): # or startModule?
super().initModule()
self.io._ssi_send(REQUEST_REVISION, [])
rev = self.io._ssi_recv(REPLY_REVISION, 1, 1)
if rev is None:
raise CommunicationFailedError('got no revision info from decoder')
self.hw_version = decode_bytes(rev).split()[0]
self._lock = threading.Lock()
self._thread = mkthread(self._thread_func)
def shutdownModule(self):
self._stoprequest = True
if self._thread and self._thread.is_alive():
self._thread.join()
@nopoll
def read_value(self):
return ''
@nopoll
def read_decoded(self):
return ''
def read_status(self):
return self.Status.IDLE, ''
def _thread_func(self):
while not self._stoprequest:
with self._lock:
try:
code = self.io._ssi_recv(DECODE_DATA, 0.1, 1)
if code is not None:
self.io._ssi_send(CMD_ACK, [])
# TODO: readBytes from BytesIO always uses self.timeout, so the
# case where None can be returned after the timeout cannot be
# used
except TimeoutError:
code = None
except Exception as e:
self.log.exception('while receiving barcode: %s', e)
self.status = self.Status.ERROR, f'{e!r}'
continue
if code is not None:
codetype = BARCODE_TYPES.get(code[0], str(code[0]))
code = codetype + ',' + decode_bytes(code[1:])
tstamp = time()
self.log.info('decoded barcode %r with timestamp %s',
code, tstamp)
self.decoded = code
self.decoded = '' # clear value of frappy client cache
sleep(0.5)
@Command()
def on(self):
"""Enable the Scanner"""
with self._lock:
self.io._ssi_comm(SCAN_ENABLE, [])
@Command()
def off(self):
"""Disable the Scanner"""
with self._lock:
self.io._ssi_comm(SCAN_DISABLE, [])
@Command(IntRange(0,26))
def beep(self, pattern):
"""
Emit an audible signal from the reader.
:param pattern: The beep pattern (range 0 to 26;
see the manual for interpretation).
"""
with self._lock:
self.io._ssi_comm(BEEP, [pattern])

View File

@ -192,9 +192,17 @@ class Motor(HasOffset, HasStates, PersistentMixin, HasIO, Drivable):
self.hw_stop()
def read_status(self):
for _ in range(3):
sysstatus = self.communicate(f'{self.address:x}SE')
try:
sysstatus = sysstatus[1:4] if self.axis == 'X' else sysstatus[5:8]
status = self.STATUS_MAP.get(sysstatus[1:]) or (ERROR, f'unknown error {sysstatus[1:]}')
status = self.STATUS_MAP[sysstatus[1:]]
except Exception: # can not interprete the reply, probably communication error
self.log.warning('bad status reply %r', sysstatus)
continue
break
else:
status = (ERROR, f'unknown status after 3 tries {sysstatus!r}')
self._running = sysstatus[0] != '1'
if status[0] == ERROR:
self._blocking_error = status[1]
@ -213,7 +221,7 @@ class Motor(HasOffset, HasStates, PersistentMixin, HasIO, Drivable):
enc = self.read_encoder()
else:
enc = self.value
if not self._running: # at target
if not self._running: # at target (self._running is updated in self.read_status())
return False
diff = abs(self.value - self._intermediate_target)
if diff > self._prev_diff and diff > self.encoder_tolerance:

View File

@ -1,5 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# *****************************************************************************
# 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
@ -17,64 +15,13 @@
#
# Module authors:
# Oksana Shliakhtun <oksana.shliakhtun@psi.ch>
# Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
""" RUFS Command: Description of Bits
"""bath thermostat Thermo Scientific™ ARCTIC A10 Refrigerated Circulators"""
====== ======================================================== ==============================================
Value Description
====== ======================================================== ==============================================
V1
B6: warning, rtd1 (internal temp. sensor) is shorted
B0 --> 1
B7: warning, rtd1 is open
B1 --> 2
V2
B0: error, HTC (high temperature cutout) fault B2 --> 4
B1: error, high RA (refrigeration) temperature fault B3 --> 8
V3 B4 --> 16
B0: warning, low level in the bath
B5 --> 32
B1: warning, low temperature
B6 --> 64
B2: warning, high temperature
B7 --> 128
B3: error, low level in the bath
B4: error, low temperature fault
B5: error, high temperature fault
B6: error, low temperature fixed* fault
B7: error, high temperature fixed** fault
V4
B3: idle, circulator** is running
B5: error, circulator** fault
V5
B0: error, pump speed fault
B1: error, motor overloaded
B2: error, high pressure cutout
B3: idle, maximum cooling
B4: idle, cooling
B5: idle, maximum heating
B6: idle, heating
====== ======================================================== ==============================================
"""
from frappy.core import StringIO, Parameter, HasIO, \
from frappy.core import Command, StringIO, Parameter, HasIO, \
Drivable, FloatRange, IDLE, BUSY, ERROR, WARN, BoolType
from frappy.structparam import StructParam
from frappy_psi.convergence import HasConvergence
@ -85,17 +32,17 @@ class ThermFishIO(StringIO):
class TemperatureLoopA10(HasConvergence, HasIO, Drivable):
ioClass = ThermFishIO
value = Parameter('internal temperature', unit='degC')
value = Parameter('temperature', unit='degC')
target = Parameter('setpoint/target', datatype=FloatRange, unit='degC', default=0)
circ_on = Parameter('is circulation running', BoolType(), readonly=False, default=False)
# pids
p_heat = Parameter('proportional heat parameter', FloatRange(), readonly=False)
i_heat = Parameter('integral heat parameter', FloatRange(), readonly=False)
d_heat = Parameter('derivative heat parameter', FloatRange(), readonly=False)
p_cool = Parameter('proportional cool parameter', FloatRange(), readonly=False)
i_cool = Parameter('integral cool parameter', FloatRange(), readonly=False)
d_cool = Parameter('derivative cool parameter', FloatRange(), readonly=False)
control_active = Parameter('circilation and control is on', BoolType(), default=False)
ctrlpars = StructParam('control parameters struct', dict(
p_heat = Parameter('proportional heat parameter', FloatRange()),
i_heat = Parameter('integral heat parameter', FloatRange()),
d_heat = Parameter('derivative heat parameter', FloatRange()),
p_cool = Parameter('proportional cool parameter', FloatRange()),
i_cool = Parameter('integral cool parameter', FloatRange()),
d_cool = Parameter('derivative cool parameter', FloatRange()),
), readonly=False)
status_messages = [
(ERROR, 'high tempr. cutout fault', 2, 0),
@ -122,20 +69,22 @@ class TemperatureLoopA10(HasConvergence, HasIO, Drivable):
]
def get_par(self, cmd):
"""
All the reading commands starts with 'R', in the source code all the commands are written without 'R' (except
'RUFS').The result of a reading command is a value in the format '20C', without spaces.
"""get parameter and convert to float
:param cmd: any hardware command
:param cmd: hardware command without the leading 'R'
:return: 'R'+cmd
:return: result converted to float
"""
new_cmd = 'R' + cmd
reply = self.communicate(new_cmd)
if any(unit.isalpha() for unit in reply):
reply = ''.join(unit for unit in reply if not unit.isalpha())
reply = self.communicate(new_cmd).strip()
while reply[-1].isalpha():
reply = reply[:-1]
return float(reply)
def set_par(self, cmd, value):
self.communicate(f'S{cmd} {value}')
return self.get_par(cmd)
def read_value(self):
"""
Reading internal temperature sensor value.
@ -143,6 +92,34 @@ class TemperatureLoopA10(HasConvergence, HasIO, Drivable):
return self.get_par('T')
def read_status(self):
""" convert from RUFS Command: Description of Bits
====== ======================================================== ===============
Value Description
====== ======================================================== ===============
V1 B6: warning, rtd1 (internal temp. sensor) is shorted B0 --> 1
B7: warning, rtd1 is open B1 --> 2
V2 B0: error, HTC (high temperature cutout) fault B2 --> 4
B1: error, high RA (refrigeration) temperature fault B3 --> 8
V3 B0: warning, low level in the bath B5 --> 32
B1: warning, low temperature B6 --> 64
B2: warning, high temperature B7 --> 128
B3: error, low level in the bath
B4: error, low temperature fault
B5: error, high temperature fault
B6: error, low temperature fixed* fault
B7: error, high temperature fixed** fault
V4 B3: idle, circulator** is running
B5: error, circulator** fault
V5 B0: error, pump speed fault
B1: error, motor overloaded
B2: error, high pressure cutout
B3: idle, maximum cooling
B4: idle, cooling
B5: idle, maximum heating
B6: idle, heating
====== ======================================================== ===============
"""
result_str = self.communicate('RUFS') # read unit fault status
values_str = result_str.strip().split()
values_int = [int(val) for val in values_str]
@ -157,72 +134,55 @@ class TemperatureLoopA10(HasConvergence, HasIO, Drivable):
return status_type, status_msg
return WARN, 'circulation off'
def read_circ_on(self):
return self.communicate('RO')
def read_control_active(self):
return int(self.get_par('O'))
def write_circ_on(self, circ_on):
circ_on_str = '1' if circ_on else '0'
self.communicate(f'SO {circ_on_str}')
return self.read_circ_on()
@Command
def control_off(self):
"""switch control and circulation off"""
self.control_active = self.set_par('O', 0)
def read_target(self):
return self.get_par('S')
def write_target(self, target):
"""
:param target: here, it serves as an equivalent to a setpoint.
"""
self.write_circ_on('1')
self.control_active = self.set_par('O', 1)
self.communicate(f'SS {target}')
self.convergence_start()
return target
## heat PID
def read_p_heat(self):
p_heat = self.get_par('PH')
return float(p_heat)
return self.get_par('PH')
def write_p_heat(self, p_heat):
self.communicate(f'SPH {p_heat}')
return p_heat
def write_p_heat(self, value):
return self.set_par('PH', value)
def read_i_heat(self):
i_heat = self.get_par('IH')
return float(i_heat)
return self.get_par('IH')
def write_i_heat(self, i_heat):
self.communicate(f'SIH {i_heat}')
return i_heat
def write_i_heat(self, value):
return self.set_par('IH', value)
def read_d_heat(self):
d_heat = self.get_par('DH')
return float(d_heat)
return self.get_par('DH')
def write_d_heat(self, d_heat):
self.communicate(f'SDH {d_heat}')
return d_heat
def write_d_heat(self, value):
return self.set_par('DH', value)
## cool PID
def read_p_cool(self):
p_cool = self.get_par('PC')
return float(p_cool)
return self.get_par('PC')
def write_p_cool(self, p_cool):
self.communicate(f'SPC {p_cool}')
return p_cool
def write_p_cool(self, value):
return self.set_par('PC', value)
def read_i_cool(self):
i_cool = self.get_par('IC')
return float(i_cool)
return self.get_par('IC')
def write_i_cool(self, i_cool):
self.communicate(f'SIC {i_cool}')
return i_cool
def write_i_cool(self, value):
return self.set_par('IC', value)
def read_d_cool(self):
d_cool = self.get_par('DC')
return float(d_cool)
return self.get_par('DC')
def write_d_cool(self, d_cool):
self.communicate(f'SDC {d_cool}')
return d_cool
def write_d_cool(self, value):
return self.set_par('DC', value)

View File

@ -7,3 +7,5 @@ markdown>=2.6
pytest
pytest-randomly>=1.1
pytest-cov
# frappy mlz
zapf >= 0.4.7

View File

@ -75,6 +75,4 @@ def test_attach():
assert m.propertyValues['att'] == 'a'
srv.dispatcher.register_module(a, 'a')
srv.dispatcher.register_module(m, 'm')
assert m.att == 'a'
m.attachedModules = {'att': a}
assert m.att == a

141
test/test_config.py Normal file
View File

@ -0,0 +1,141 @@
# *****************************************************************************
#
# 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:
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
# false positive with fixtures
# pylint: disable=redefined-outer-name
import pytest
from frappy.config import Collector, Config, Mod, NodeCollector, load_config, \
process_file, to_config_path
from frappy.errors import ConfigError
from frappy.lib import generalConfig
class LoggerStub:
def debug(self, fmt, *args):
pass
info = warning = exception = error = debug
handlers = []
@pytest.fixture
def log():
return LoggerStub()
PY_FILE = """Node('foonode', 'fodesc', 'fooface')
Mod('foo', 'frappy.modules.Readable', 'description', value=5)
Mod('bar', 'frappy.modules.Readable', 'about me', export=False)
Mod('baz', 'frappy.modules.Readable', 'things', value=Param(3, unit='BAR'))
"""
# fixture file system, TODO: make a bit nicer?
@pytest.fixture
def direc(tmp_path_factory):
d = tmp_path_factory.mktemp('cfgdir')
a = d / 'a'
b = d / 'b'
a.mkdir()
b.mkdir()
f = a / 'config_cfg.py'
pyfile = a / 'pyfile_cfg.py'
ff = b / 'test_cfg.py'
fff = b / 'alsoworks.py'
f.touch()
ff.touch()
fff.touch()
pyfile.write_text(PY_FILE)
generalConfig.testinit(confdir=f'{a}:{b}', piddir=str(d))
return d
files = [('config', 'a/config_cfg.py'),
('config_cfg', 'a/config_cfg.py'),
('config_cfg.py', 'a/config_cfg.py'),
('test', 'b/test_cfg.py'),
('test_cfg', 'b/test_cfg.py'),
('test_cfg.py', 'b/test_cfg.py'),
('alsoworks', 'b/alsoworks.py'),
('alsoworks.py', 'b/alsoworks.py'),
]
@pytest.mark.parametrize('file, res', files)
def test_to_cfg_path(log, direc, file, res):
assert to_config_path(file, log).endswith(res)
def test_cfg_not_existing(direc, log):
with pytest.raises(ConfigError):
to_config_path('idonotexist', log)
def collector_helper(node, mods):
n = NodeCollector()
n.add(*node)
m = Collector(Mod)
m.list = [Mod(module, '', '') for module in mods]
return n, m
configs = [
(['n1', 'desc', 'iface'], ['foo', 'bar', 'baz'], ['n2', 'foo', 'bar'],
['foo', 'more', 'other'], ['n1', 'iface', 5, {'foo'}]),
(['n1', 'desc', 'iface'], ['foo', 'bar', 'baz'], ['n2', 'foo', 'bar'],
['different', 'more', 'other'], ['n1', 'iface', 6, set()]),
]
@pytest.mark.parametrize('n1, m1, n2, m2, res', configs)
def test_merge(n1, m1, n2, m2, res):
name, iface, num_mods, ambig = res
c1 = Config(*collector_helper(n1, m1))
c2 = Config(*collector_helper(n2, m2))
c1.merge_modules(c2)
assert c1['node']['equipment_id'] == name
assert c1['node']['interface'] == iface
assert len(c1.module_names) == num_mods
assert c1.ambiguous == ambig
def do_asserts(ret):
assert len(ret.module_names) == 3
assert set(ret.module_names) == set(['foo', 'bar', 'baz'])
assert ret['node']['equipment_id'] == 'foonode'
assert ret['node']['interface'] == 'fooface'
assert ret['foo'] == {'cls': 'frappy.modules.Readable',
'description': 'description', 'value': {'value': 5}}
assert ret['bar'] == {'cls': 'frappy.modules.Readable',
'description': 'about me', 'export': {'value': False}}
assert ret['baz'] == {'cls': 'frappy.modules.Readable',
'description': 'things',
'value': {'value': 3, 'unit': 'BAR'}}
def test_process_file(direc, log):
ret = process_file(str(direc / 'a' / 'pyfile_cfg.py'), log)
do_asserts(ret)
def test_full(direc, log):
ret = load_config('pyfile_cfg.py', log)
do_asserts(ret)

132
test/test_ctrlpars.py Normal file
View File

@ -0,0 +1,132 @@
# *****************************************************************************
#
# 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 <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""test frappy.mixins.HasCtrlPars"""
from test.test_modules import LoggerStub, ServerStub
from frappy.core import FloatRange, Module, Parameter
from frappy.structparam import StructParam
def test_with_read_ctrlpars():
class Mod(Module):
ctrlpars = StructParam('ctrlpar struct', dict(
p = Parameter('control parameter p', FloatRange()),
i = Parameter('control parameter i', FloatRange()),
d = Parameter('control parameter d', FloatRange()),
), 'pid_', readonly=False)
def read_ctrlpars(self):
return self._ctrlpars
def write_ctrlpars(self, value):
self._ctrlpars = value
return self.read_ctrlpars()
logger = LoggerStub()
updates = {}
srv = ServerStub(updates)
ms = Mod('ms', logger, {'description':''}, srv)
value = {'p': 1, 'i': 2, 'd': 3}
assert ms.write_ctrlpars(value) == value
assert ms.read_ctrlpars() == value
assert ms.read_pid_p() == 1
assert ms.read_pid_i() == 2
assert ms.read_pid_d() == 3
assert ms.write_pid_i(5) == 5
assert ms.write_pid_d(0) == 0
assert ms.read_ctrlpars() == {'p': 1, 'i': 5, 'd': 0}
assert set(Mod.ctrlpars.influences) == {'pid_p', 'pid_i', 'pid_d'}
assert Mod.pid_p.influences == ('ctrlpars',)
assert Mod.pid_i.influences == ('ctrlpars',)
assert Mod.pid_d.influences == ('ctrlpars',)
def test_without_read_ctrlpars():
class Mod(Module):
ctrlpars = StructParam('ctrlpar struct', dict(
p = Parameter('control parameter p', FloatRange()),
i = Parameter('control parameter i', FloatRange()),
d = Parameter('control parameter d', FloatRange()),
), readonly=False)
_pid_p = 0
_pid_i = 0
def read_p(self):
return self._pid_p
def write_p(self, value):
self._pid_p = value
return self.read_p()
def read_i(self):
return self._pid_i
def write_i(self, value):
self._pid_i = value
return self.read_i()
logger = LoggerStub()
updates = {}
srv = ServerStub(updates)
ms = Mod('ms', logger, {'description': ''}, srv)
value = {'p': 1, 'i': 2, 'd': 3}
assert ms.write_ctrlpars(value) == value
assert ms.read_ctrlpars() == value
assert ms.read_p() == 1
assert ms.read_i() == 2
assert ms.read_d() == 3
assert ms.write_i(5) == 5
assert ms.write_d(0) == 0
assert ms.read_ctrlpars() == {'p': 1, 'i': 5, 'd': 0}
assert set(Mod.ctrlpars.influences) == {'p', 'i', 'd'}
assert Mod.p.influences == ('ctrlpars',)
assert Mod.i.influences == ('ctrlpars',)
assert Mod.d.influences == ('ctrlpars',)
def test_readonly():
class Mod(Module):
ctrlpars = StructParam('ctrlpar struct', dict(
p = Parameter('control parameter p', FloatRange()),
i = Parameter('control parameter i', FloatRange()),
d = Parameter('control parameter d', FloatRange()),
), {'p': 'pp', 'i':'ii', 'd': 'dd'}, readonly=True)
assert Mod.ctrlpars.readonly is True
assert Mod.pp.readonly is True
assert Mod.ii.readonly is True
assert Mod.dd.readonly is True
def test_order_dependence1():
test_without_read_ctrlpars()
test_with_read_ctrlpars()
def test_order_dependence2():
test_with_read_ctrlpars()
test_without_read_ctrlpars()

View File

@ -22,7 +22,7 @@
import pytest
from frappy.lib import parse_host_port
from frappy.lib import parse_host_port, merge_status
@pytest.mark.parametrize('hostport, defaultport, result', [
@ -46,3 +46,19 @@ def test_parse_host(hostport, defaultport, result):
parse_host_port(hostport, defaultport)
else:
assert result == parse_host_port(hostport, defaultport)
@pytest.mark.parametrize('args, result', [
([(100, 'idle'), (200, 'warning')],
(200, 'warning')),
([(300, 'ramping'), (300, 'within tolerance')],
(300, 'ramping, within tolerance')),
([(300, 'ramping, within tolerance'), (300, 'within tolerance, slow'), (200, 'warning')],
(300, 'ramping, within tolerance, slow')),
# when a comma is used for other purposes than separating individual status texts,
# the behaviour might not be as desired. However, this case is somewhat constructed.
([(100, 'blue, yellow is my favorite'), (100, 'white, blue, red is a bad color mix')],
(100, 'blue, yellow is my favorite, white, red is a bad color mix')),
])
def test_merge_status(args, result):
assert merge_status(*args) == result

55
test/test_server.py Normal file
View File

@ -0,0 +1,55 @@
# *****************************************************************************
#
# 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:
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
import pytest
# pylint: disable=redefined-outer-name
from frappy.server import Server
from .test_config import direc # pylint: disable=unused-import
class LoggerStub:
def debug(self, fmt, *args):
pass
def getChild(self, *args):
return self
info = warning = exception = error = debug
handlers = []
@pytest.fixture
def log():
return LoggerStub()
def test_name_only(direc, log):
"""only see that this does not throw. get config from name."""
s = Server('pyfile', log)
s._processCfg()
def test_file(direc, log):
"""only see that this does not throw. get config from cfgfiles."""
s = Server('foo', log, cfgfiles='pyfile_cfg.py')
s._processCfg()