write configured parameters to the hardware

writable parameters with a configured value should call write_<param>
on initialization.
+ introduced 'initwrite' parameter property for more fine control over this
+ minor improvements in metaclass.py, param.py, commandhandler.py
+ rearranged test_modules.py

Change-Id: I2eec45da40947a73d9c180f0f146eb62efbda2b3
Reviewed-on: https://forge.frm2.tum.de/review/c/sine2020/secop/playground/+/21986
Tested-by: JenkinsCodeReview <bjoern_pedersen@frm2.tum.de>
Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch>
This commit is contained in:
zolliker 2019-12-10 11:36:40 +01:00
parent 44eeea1159
commit 1521e0a34b
9 changed files with 285 additions and 115 deletions

View File

@ -20,12 +20,36 @@
# *****************************************************************************
"""command handler
utility class for cases, where multiple parameters are treated with a common command.
Utility class for cases, where multiple parameters are treated with a common command.
The support for LakeShore and similar protocols is already included.
For read, instead of the methods read_<parameter> we write one method analyze_<group>
for all parameters with the same handler. Before analyze_<group> is called, the
reply is parsed and converted to values, which are then given as arguments.
def analyze_<group>(self, value1, value2, ...):
# <here> we have to calculate parameters from the values (value1, value2 ...)
# and assign them to self.<parameter>
# no return value is expected
For write, instead of the methods write_<parameter>" we write one method change_<group>
for all parameters with the same handler.
def change_<group>(self, new, value1, value2, ...):
# <new> is a wrapper object around the module, containing already the new values.
# if READ_BEFORE_WRITE is True (the default), the additional arguments (value1, ...)
# must be in the argument list. They contain the values read from the hardware.
# If they are not needed, set READ_BEFORE_WRITE to False, or declare them as '*args'.
# The expression ('<parameter>' in new) returns a boolean indicating, whether
# this parameter is subject to change.
# The return value must be either a sequence of values to be written to the hardware,
# which will be formatted by the handler, or None. The latter is used only in some
# special cases, when nothing has to be written.
"""
import re
from secop.metaclass import Done
from secop.errors import ProgrammingError
@ -113,34 +137,38 @@ class CmdParser:
class ChangeWrapper:
"""store parameter changes before they are applied"""
"""Wrapper around a module
def __init__(self, module, pname, value):
A ChangeWrapper instance is used as the 'new' argument for the change_<group> message.
new.<parameter> is either the new, changed value or the old value from the module.
In addition '<parameter>' indicates, whether <parameter> is to be changed.
setting new.<parameter> does not yet set the value on the module.
"""
def __init__(self, module, valuedict):
self._module = module
for pname, value in valuedict.items():
setattr(self, pname, value)
def __getattr__(self, key):
"""get values from module for unknown keys"""
"""get current values from _module for unchanged parameters"""
return getattr(self._module, key)
def apply(self, module):
"""set only changed values"""
for k, v in self.__dict__.items():
if k != '_module' and v != getattr(module, k):
setattr(module, k, v)
def __repr__(self):
return ', '.join('%s=%r' % (k, v) for k, v in self.__dict__.items() if k != '_module')
def __contains__(self, pname):
"""check whether a specific parameter is to be changed"""
return pname in self.__dict__
class CmdHandlerBase:
"""generic command handler"""
READ_BEFORE_WRITE = True
# if READ_BEFORE_WRITE is True, a read is performed before a write, and the parsed
# additional parameters are added to the argument list of change_<group>.
def __init__(self, group):
# group is used for calling the proper analyze_<group> and change_<group> methods
self.group = group
self.parameters = {}
self.parameters = set()
self._module_class = None
def parse_reply(self, reply):
"""return values from a raw reply"""
@ -179,11 +207,11 @@ class CmdHandlerBase:
and registers the parameter in this handler
"""
if not modclass in self.parameters:
self.parameters[modclass] = []
# Make sure that parameters from different module classes are not mixed
# (not sure if this might happen)
self.parameters[modclass].append(pname)
self._module_class = self._module_class or modclass
if self._module_class != modclass:
raise ProgrammingError("the handler '%s' for '%s.%s' is already used in module '%s'"
% (self.group, modclass.__name__, pname, self._module_class.__name__))
self.parameters.add(pname)
return self.read
def read(self, module):
@ -196,14 +224,15 @@ class CmdHandlerBase:
reply = self.send_command(module)
# convert them to parameters
getattr(module, 'analyze_' + self.group)(*reply)
for pname in self.parameters[module.__class__]:
assert module.__class__ == self._module_class
for pname in self.parameters:
if module.parameters[pname].readerror:
# clear errors on parameters, which were not updated.
# this will also inform all activated clients
setattr(module, pname, getattr(module, pname))
except Exception as e:
# set all parameters of this handler to error
for pname in self.parameters[module.__class__]:
for pname in self.parameters:
module.setError(pname, e)
raise
return Done # parameters should be updated already
@ -215,23 +244,35 @@ class CmdHandlerBase:
"""
def wfunc(module, value, cmd=self, pname=pname):
# do a read of the current hw values
values = cmd.send_command(module)
# convert them to parameters
analyze = getattr(module, 'analyze_' + cmd.group)
analyze(*values)
# create wrapper object 'new' with changed parameter 'pname'
new = ChangeWrapper(module, pname, value)
# call change_* for calculation new hw values
values = getattr(module, 'change_' + cmd.group)(new, *values)
# send the change command and a query command
analyze(*cmd.send_change(module, *values))
# update only changed values
new.apply(module)
return Done # parameter 'pname' should be changed already
cmd.write(module, {pname: value})
return Done
return wfunc
def write(self, module, valuedict, force_read=False):
"""write values to the module
When called from write_<param>, valuedict contains only one item:
the parameter to be changed.
When called from initialization, valuedict may have more items.
"""
analyze = getattr(module, 'analyze_' + self.group)
if self.READ_BEFORE_WRITE or force_read:
# do a read of the current hw values
values = self.send_command(module)
# convert them to parameters
analyze(*values)
if not self.READ_BEFORE_WRITE:
values = ()
# create wrapper object 'new' with changed parameter 'pname'
new = ChangeWrapper(module, valuedict)
# call change_* for calculation new hw values
values = getattr(module, 'change_' + self.group)(new, *values)
if values is None: # this indicates that nothing has to be written
return
# send the change command and a query command
analyze(*self.send_change(module, *values))
class CmdHandler(CmdHandlerBase):
"""more evolved command handler

View File

@ -119,7 +119,6 @@ class ModuleMeta(PropertyMeta):
"and read_%s" % (pname, pname))
rfunc = handler
else:
rfunc = attrs.get('read_' + pname, None)
for base in bases:
if rfunc is not None:
break

View File

@ -28,7 +28,7 @@ from collections import OrderedDict
from secop.datatypes import EnumType, FloatRange, BoolType, IntRange, \
StringType, TupleOf, get_datatype, ArrayOf, TextType
from secop.errors import ConfigError, ProgrammingError, SECoPError
from secop.errors import ConfigError, ProgrammingError, SECoPError, BadValueError
from secop.lib import formatException, formatExtendedStack, mkthread
from secop.lib.enum import Enum
from secop.metaclass import ModuleMeta
@ -178,9 +178,18 @@ class Module(HasProperties, metaclass=ModuleMeta):
(self.name, k, ', '.join(self.parameters.keys())))
# 4) complain if a Parameter entry has no default value and
# is not specified in cfgdict
# is not specified in cfgdict and deal with parameters to be written.
self.writeDict = {} # values of parameters to be written
for pname, pobj in self.parameters.items():
if pname not in cfgdict:
if pname in cfgdict:
if not pobj.readonly and not pobj.initwrite is False:
# parameters given in cfgdict have to call write_<pname>
try:
pobj.value = pobj.datatype(cfgdict[pname])
except BadValueError as e:
raise ConfigError('%s.%s: %s' % (name, pname, e))
self.writeDict[pname] = pobj.value
else:
if pobj.default is None:
if not pobj.poll:
raise ConfigError('Module %s: Parameter %r has no default '
@ -193,7 +202,18 @@ class Module(HasProperties, metaclass=ModuleMeta):
# when not all hardware parameters are read because of startup timeout
pobj.value = pobj.datatype(pobj.datatype.default)
else:
cfgdict[pname] = pobj.default
try:
value = pobj.datatype(pobj.default)
except BadValueError as e:
raise ProgrammingError('bad default for %s.%s: %s'
% (name, pname, e))
if pobj.initwrite:
# we will need to call write_<pname>
# if this is not desired, the default must not be given
pobj.value = value
self.writeDict[pname] = value
else:
cfgdict[pname] = value
# 5) 'apply' config:
# pass values through the datatypes and store as attributes
@ -258,9 +278,35 @@ class Module(HasProperties, metaclass=ModuleMeta):
self.log.debug('empty %s.startModule()' % self.__class__.__name__)
started_callback()
def pollOne(self, pname):
"""call read function and handle error logging"""
def pollOneParam(self, pname):
"""poll parameter <pname> with proper error handling"""
try:
return getattr(self, 'read_'+ pname)()
except SECoPError as e:
self.log.error(str(e))
except Exception as e:
self.log.error(formatException())
def writeOrPoll(self, pname):
"""write configured value for a parameter, if any, else poll
with proper error handling
"""
try:
pobj = self.parameters[pname]
if pobj.handler:
pnames = pobj.handler.parameters
valuedict = {n: self.writeDict.pop(n) for n in pnames if n in self.writeDict}
if valuedict:
self.log.info('write parameters %r', valuedict)
pobj.handler.write(self, valuedict, force_read=True)
return
pobj.handler.read(self)
else:
if pname in self.writeDict:
self.log.info('write parameter %s', pname)
getattr(self, 'write_'+ pname)(self.writeDict.pop(pname))
else:
getattr(self, 'read_'+ pname)()
except SECoPError as e:
self.log.error(str(e))
@ -285,7 +331,7 @@ class Readable(Module):
)
parameters = {
'value': Parameter('current value of the Module', readonly=True,
default=0., datatype=FloatRange(),
datatype=FloatRange(),
poll=True,
),
'pollinterval': Parameter('sleeptime between polls', default=5,
@ -320,6 +366,8 @@ class Readable(Module):
def __pollThread_inner(self, started_callback):
"""super simple and super stupid per-module polling thread"""
for pname in list(self.writeDict):
self.writeOrPoll(pname)
i = 0
fastpoll = self.pollParams(i)
started_callback()
@ -339,7 +387,7 @@ class Readable(Module):
continue
if nr % abs(int(pobj.poll)) == 0:
# pollParams every 'pobj.pollParams' iteration
self.pollOne(pname)
self.pollOneParam(pname)
return False
@ -350,7 +398,7 @@ class Writable(Readable):
"""
parameters = {
'target': Parameter('target value of the Module',
default=0., readonly=False, datatype=FloatRange(),
default=0, readonly=False, datatype=FloatRange(),
),
}
@ -395,7 +443,7 @@ class Drivable(Writable):
nr % abs(int(pobj.poll))) == 0:
# poll always if pobj.poll is negative and fastpoll (i.e. Module is busy)
# otherwise poll every 'pobj.poll' iteration
self.pollOne(pname)
self.pollOneParam(pname)
return fastpoll
def do_stop(self):

View File

@ -26,7 +26,7 @@ from collections import OrderedDict
from secop.datatypes import CommandType, DataType, StringType, BoolType, EnumType, DataTypeType, ValueType, OrType, \
NoneOr, TextType, IntRange
from secop.errors import ProgrammingError
from secop.errors import ProgrammingError, BadValueError
from secop.properties import HasProperties, Property
@ -114,6 +114,8 @@ class Parameter(Accessible):
settable=False, default=False),
'handler': Property('[internal] overload the standard read and write functions',
ValueType(), export=False, default=None, mandatory=False, settable=False),
'initwrite': Property('[internal] write this parameter on initialization (default None: write if given in config)',
NoneOr(BoolType()), export=False, default=None, mandatory=False, settable=False),
}
def __init__(self, description, datatype, ctr=None, unit=None, **kwds):
@ -126,7 +128,7 @@ class Parameter(Accessible):
# goodie: make an instance from a class (forgotten ()???)
datatype = datatype()
else:
raise ValueError(
raise ProgrammingError(
'datatype MUST be derived from class DataType!')
kwds['description'] = description
@ -139,6 +141,9 @@ class Parameter(Accessible):
if self.handler and not self.poll:
self.properties['poll'] = True
if self.readonly and self.initwrite:
raise ProgrammingError('can not have both readonly and initwrite!')
if self.constant is not None:
self.properties['readonly'] = True
# The value of the `constant` property should be the
@ -233,6 +238,12 @@ class Override(CountedObj):
constant = obj.datatype(self.kwds.pop('constant'))
self.kwds['constant'] = obj.datatype.export_value(constant)
self.kwds['readonly'] = True
if 'datatype' in self.kwds and 'default' not in self.kwds:
try:
self.kwds['datatype'](obj.default)
except BadValueError:
# clear default, if it does not match datatype
props['default'] = None
props.update(self.kwds)
if self.reorder:

View File

@ -186,7 +186,7 @@ class Poller(PollerBase):
mininterval = interval
due = max(lastdue + interval, pobj.timestamp + interval * 0.5)
if now >= due:
module.pollOne(pname)
module.pollOneParam(pname)
done = True
lastdue = due
due = max(lastdue + mininterval, now + min(self.maxwait, mininterval * 0.5))
@ -214,7 +214,7 @@ class Poller(PollerBase):
for _, queue in sorted(self.queues.items()): # do SLOW polls first
for idx, (_, _, (_, module, pobj, pname, factor)) in enumerate(queue):
lastdue = time.time()
module.pollOne(pname)
module.writeOrPoll(pname)
due = lastdue + min(self.maxwait, module.pollinterval * factor)
# in python 3 comparing tuples need some care, as not all objects
# are comparable. Inserting a unique idx solves the problem.

View File

@ -26,6 +26,7 @@ import pytest
from secop.commandhandler import CmdParser, CmdHandler
from secop.modules import Module, Parameter
from secop.datatypes import FloatRange, StringType, IntRange, Property
from secop.errors import ProgrammingError
@pytest.mark.parametrize('fmt, text, values, text2', [
('%d,%d', '2,3', [2,3], None),
@ -99,7 +100,7 @@ def test_CmdHandler():
group2 = Hdl('group2', 'CMD?%(channel)d', '%g,%s,%d')
class TestModule(Module):
class Module1(Module):
properties = {
'channel': Property('the channel', IntRange(), default=3),
'loop': Property('the loop', IntRange(), default=2),
@ -130,7 +131,8 @@ def test_CmdHandler():
data = Data()
updates = {}
module = TestModule('mymodule', LoggerStub(), {'.description': ''}, ServerStub(updates))
module = Module1('mymodule', LoggerStub(), {'.description': ''}, ServerStub(updates))
print(updates)
updates.clear() # get rid of updates from initialisation
# for sendRecv
@ -182,3 +184,10 @@ def test_CmdHandler():
assert module.real == updates.pop('real') == 12.3
assert data.empty()
assert not updates
with pytest.raises(ProgrammingError): # can not use a handler for different modules
# pylint: disable=unused-variable
class Module2(Module):
parameters = {
'simple': Parameter('a readonly', FloatRange(), default=0.77, handler=group1),
}

View File

@ -17,6 +17,7 @@
#
# Module authors:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""test data types."""
@ -25,93 +26,119 @@
#import pytest
import threading
from secop.datatypes import BoolType, EnumType, FloatRange
from secop.metaclass import ModuleMeta
from secop.datatypes import BoolType, FloatRange, StringType
from secop.modules import Communicator, Drivable, Module
from secop.params import Command, Override, Parameter
from secop.poller import BasicPoller
class DispatcherStub:
def __init__(self, updates):
self.updates = updates
def announce_update(self, moduleobj, pname, pobj):
self.updates.setdefault(moduleobj.name, {})
self.updates[moduleobj.name][pname] = pobj.value
def announce_update_error(self, moduleobj, pname, pobj, err):
self.updates['error', moduleobj.name, pname] = str(err)
class LoggerStub:
def debug(self, *args):
print(*args)
info = exception = debug
class ServerStub:
def __init__(self, updates):
self.dispatcher = DispatcherStub(updates)
def test_Communicator():
logger = type('LoggerStub', (object,), dict(
debug = lambda self, *a: print(*a),
info = lambda self, *a: print(*a),
))()
dispatcher = type('DispatcherStub', (object,), dict(
announce_update = lambda self, m, pn, pv: print('%s:%s=%r' % (m.name, pn, pv)),
))()
srv = type('ServerStub', (object,), dict(
dispatcher = dispatcher,
))()
o = Communicator('communicator',logger, {'.description':''}, srv)
o = Communicator('communicator', LoggerStub(), {'.description':''}, ServerStub({}))
o.earlyInit()
o.initModule()
event = threading.Event()
o.startModule(event.set)
assert event.is_set() # event should be set immediately
def test_ModuleMeta():
# pylint: disable=too-many-function-args
newclass1 = ModuleMeta.__new__(ModuleMeta, 'TestDrivable', (Drivable,), {
"parameters" : {
class Newclass1(Drivable):
parameters = {
'pollinterval': Override(reorder=True),
'param1' : Parameter('param1', datatype=BoolType(), default=False),
'param2': Parameter('param2', datatype=FloatRange(unit='Ohm'), default=True),
"cmd": Command('stuff', argument=BoolType(), result=BoolType())
},
"commands": {
}
commands = {
# intermixing parameters with commands is not recommended,
# but acceptable for influencing the order
'a1': Parameter('a1', datatype=BoolType(), default=False),
'a2': Parameter('a2', datatype=BoolType(), default=True),
'value': Override(datatype=BoolType(), default=True),
'value': Override(datatype=StringType(), default='first'),
'cmd2': Command('another stuff', argument=BoolType(), result=BoolType()),
},
"do_cmd": lambda self, arg: not arg,
"do_cmd2": lambda self, arg: not arg,
"read_param1": lambda self, *args: True,
"read_param2": lambda self, *args: False,
"read_a1": lambda self, *args: True,
"read_a2": lambda self, *args: False,
"read_value": lambda self, *args: True,
"init": lambda self, *args: [None for self.accessibles['value'].datatype in [EnumType('value', OK=1, Bad=2)]],
})
}
pollerClass = BasicPoller
def do_cmd(self, arg):
return not arg
def do_cmd2(self, arg):
return not arg
def read_param1(self):
return True
def read_param2(self):
return False
def read_a1(self):
return True
def read_a2(self):
return True
def read_value(self):
return 'second'
# first inherited accessibles, then Overrides with reorder=True and new accessibles
sortcheck1 = ['value', 'status', 'target', 'pollinterval',
'param1', 'param2', 'cmd', 'a1', 'a2', 'cmd2']
# pylint: disable=too-many-function-args
newclass2 = ModuleMeta.__new__(ModuleMeta, 'UpperClass', (newclass1,), {
"parameters": {
class Newclass2(Newclass1):
parameters = {
'cmd2': Override('another stuff'),
'value': Override(datatype=FloatRange(unit='deg'), reorder=True),
'a1': Override(datatype=FloatRange(unit='$/s'), reorder=True),
'b2': Parameter('a2', datatype=BoolType(), default=True),
},
})
'a1': Override(datatype=FloatRange(unit='$/s'), reorder=True, readonly=False),
'b2': Parameter('<b2>', datatype=BoolType(), default=True,
poll=True, readonly=False, initwrite=True),
}
def write_a1(self, value):
self._a1_written = value
return value
def write_b2(self, value):
self._b2_written = value
return value
def read_value(self):
return 0
sortcheck2 = ['value', 'status', 'target', 'pollinterval',
'param1', 'param2', 'cmd', 'a2', 'cmd2', 'a1', 'b2']
logger = type('LoggerStub', (object,), dict(
debug = lambda self, *a: print(*a),
info = lambda self, *a: print(*a),
exception = lambda self, *a: print(*a),
))()
dispatcher = type('DispatcherStub', (object,), dict(
announce_update = lambda self, m, pn, po: print('%s:%s=%r' % (m.name, pn, po.value)),
))()
srv = type('ServerStub', (object,), dict(
dispatcher = dispatcher,
))()
logger = LoggerStub()
updates = {}
srv = ServerStub(updates)
params_found = set() # set of instance accessibles
objects = []
for newclass, sortcheck in [(newclass1, sortcheck1), (newclass2, sortcheck2)]:
for newclass, sortcheck in [(Newclass1, sortcheck1), (Newclass2, sortcheck2)]:
o1 = newclass('o1', logger, {'.description':''}, srv)
o2 = newclass('o2', logger, {'.description':''}, srv)
for obj in [o1, o2]:
@ -127,16 +154,48 @@ def test_ModuleMeta():
# HACK: atm. disabled to fix all other problems first.
assert check_order + sorted(check_order)
o1 = newclass1('o1', logger, {'.description':''}, srv)
o2 = newclass2('o2', logger, {'.description':''}, srv)
# check for inital updates working properly
o1 = Newclass1('o1', logger, {'.description':''}, srv)
expectedBeforeStart = {'target': 0.0, 'status': [Drivable.Status.IDLE, ''],
'param1': False, 'param2': 1.0, 'a1': 0.0, 'a2': True, 'pollinterval': 5.0,
'value': 'first'}
assert updates.pop('o1') == expectedBeforeStart
o1.earlyInit()
event = threading.Event()
o1.startModule(event.set)
event.wait()
# should contain polled values
expectedAfterStart = {'status': [Drivable.Status.IDLE, ''],
'value': 'second'}
assert updates.pop('o1') == expectedAfterStart
# check in addition if parameters are written
o2 = Newclass2('o2', logger, {'.description':'', 'a1': 2.7}, srv)
# no update for b2, as this has to be written
expectedBeforeStart['a1'] = 2.7
assert updates.pop('o2') == expectedBeforeStart
o2.earlyInit()
event = threading.Event()
o2.startModule(event.set)
event.wait()
# value has changed type, b2 and a1 are written
expectedAfterStart.update(value=0, b2=True, a1=2.7)
assert updates.pop('o2') == expectedAfterStart
assert o2._a1_written == 2.7
assert o2._b2_written is True
assert not updates
o1 = Newclass1('o1', logger, {'.description':''}, srv)
o2 = Newclass2('o2', logger, {'.description':''}, srv)
assert o2.parameters['a1'].datatype.unit == 'deg/s'
o2 = newclass2('o2', logger, {'.description':'', 'value.unit':'mm', 'param2.unit':'mm'}, srv)
o2 = Newclass2('o2', logger, {'.description':'', 'value.unit':'mm', 'param2.unit':'mm'}, srv)
# check datatype is not shared
assert o1.parameters['param2'].datatype.unit == 'Ohm'
assert o2.parameters['param2'].datatype.unit == 'mm'
# check '$' in unit works properly
assert o2.parameters['a1'].datatype.unit == 'mm/s'
cfg = newclass2.configurables
cfg = Newclass2.configurables
assert set(cfg.keys()) == {'export', 'group', 'description',
'meaning', 'visibility', 'implementation', 'interface_classes', 'target', 'stop',
'status', 'param1', 'param2', 'cmd', 'a2', 'pollinterval', 'b2', 'cmd2', 'value',
@ -147,8 +206,8 @@ def test_ModuleMeta():
'description'}
# check on the level of classes
# this checks newclass1 too, as it is inherited by newclass2
for baseclass in newclass2.__mro__:
# this checks Newclass1 too, as it is inherited by Newclass2
for baseclass in Newclass2.__mro__:
# every cmd/param has to be collected to accessibles
acs = getattr(baseclass, 'accessibles', None)
if issubclass(baseclass, Module):

View File

@ -27,6 +27,7 @@ import pytest
from secop.datatypes import BoolType, IntRange
from secop.params import Command, Override, Parameter, Parameters
from secop.errors import ProgrammingError
def test_Command():
@ -55,7 +56,7 @@ def test_Parameter():
p2 = Parameter('description2', datatype=IntRange(), constant=1)
assert p1 != p2
assert p1.ctr != p2.ctr
with pytest.raises(ValueError):
with pytest.raises(ProgrammingError):
Parameter(None, datatype=float)
p3 = p1.copy()
assert p1.ctr != p3.ctr

View File

@ -157,9 +157,11 @@ class Module:
def isBusy(self):
return self.is_busy
def pollOne(self, pname):
def pollOneParam(self, pname):
getattr(self, 'read_' + pname)()
writeOrPoll = pollOneParam
def __repr__(self):
rdict = self.__dict__.copy()
rdict.pop('parameters')