add FloatEnumParam

for the use case where a parameter is a selection of discrete
float parameters

declaring a parameter as FloatEnumParam will create effectively
two parameters, one with datatype FloatRange and another with
datatype Enum, influencing each other automatically.

in a later change StructParam should be moved from
frappy/structparam.py to frappy/extparams.py

Change-Id: Ica3fd8dcaf6e9439e8178390f220dec15e52cc86
Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/32975
Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de>
Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch>
This commit is contained in:
zolliker 2024-01-30 10:56:09 +01:00
parent 8c589cc138
commit 2acab33faa
2 changed files with 241 additions and 1 deletions

164
frappy/extparams.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>
#
# *****************************************************************************
"""extended parameters
special parameter classes with some automatic functionality
"""
import re
from frappy.core import Parameter, Property
from frappy.datatypes import DataType, ValueType, EnumType, \
StringType, FloatRange, DataTypeType
from frappy.errors import ProgrammingError
# TODO: insert StructParam here
class FloatEnumParam(Parameter):
"""combine enum and float parameter
Example Usage:
vrange = FloatEnumParam('sensor range', ['500uV', '20mV', '1V'], 'V')
The following will be created automatically:
- the parameter vrange will get a datatype FloatRange(5e-4, 1, unit='V')
- an additional parameter `vrange_idx` will be created with an enum type
{'500uV': 0, '20mV': 1, '1V': 2}
- the method `write_vrange` will be created automatically
However, the methods `write_vrange_idx` and `read_vrange_idx`, if needed,
have to implemented by the programmer.
Writing to the float parameter involves 'rounding' to the closest allowed value.
Customization:
The individual labels might be customized by defining them as a tuple
(<index>, <label>, <float value>) where either the index or the float value
may be omitted.
When the index is omitted, the element will be the previous index + 1 or
0 when it is the first element.
Omitted values will be determined from the label, assuming that they use
one of the predefined unit prefixes together with the given unit.
The name of the index parameter is by default '<name>_idx' but might be
changed with the idx_name argument.
"""
# use properties, as simple attributes are not considered on copy()
idx_name = Property('name of attached index parameter', StringType(), default='')
valuedict = Property('dict <index> of <value>', ValueType(dict))
enumtype = Property('dict <label> of <index', DataTypeType())
# TODO: factor out unit handling, at the latest when needed elsewhere
PREFIXES = {'q': -30, 'r': -27, 'y': -24, 'z': -21, 'a': -18, 'f': -15,
'p': -12, 'n': -9, 'u': -6, 'µ': -6, 'm': -3,
'': 0, 'k': 3, 'M': 6, 'G': 9, 'T': 12,
'P': 15, 'E': 18, 'Z': 21, 'Y': 24, 'R': 25, 'Q': 30}
def __init__(self, description=None, labels=None, unit='',
*, datatype=None, readonly=False, **kwds):
if labels is None:
# called on Parameter.copy()
super().__init__(description, datatype, readonly=readonly, **kwds)
return
if isinstance(labels, DataType):
raise ProgrammingError('second argument must be a list of labels, not a datatype')
nextidx = 0
try:
edict = {}
vdict = {}
for elem in labels:
if isinstance(elem, str):
idx, label = [nextidx, elem]
else:
if isinstance(elem[0], str):
elem = [nextidx] + list(elem)
idx, label, *tail = elem
if tail:
vdict[idx], = tail
edict[label] = idx
nextidx = idx + 1
except (ValueError, TypeError) as e:
raise ProgrammingError('labels must be a list of labels or tuples '
'([index], label, [value])') from e
pat = re.compile(rf'([+-]?\d*\.?\d*) *({"|".join(self.PREFIXES)}){unit}$')
try:
# determine missing values from labels
for label, idx in edict.items():
if idx not in vdict:
value, prefix = pat.match(label).groups()
vdict[idx] = float(f'{value}e{self.PREFIXES[prefix]}')
except (AttributeError, ValueError) as e:
raise ProgrammingError(f"{label!r} has not the form '<float><prefix>{unit}'") from e
try:
enumtype = EnumType(**edict)
except TypeError as e:
raise ProgrammingError(str(e)) from e
datatype = FloatRange(min(vdict.values()), max(vdict.values()), unit=unit)
super().__init__(description, datatype, enumtype=enumtype, valuedict=vdict,
readonly=readonly, **kwds)
def __set_name__(self, owner, name):
super().__set_name__(owner, name)
if not self.idx_name:
self.idx_name = name + '_idx'
iname = self.idx_name
idx_param = Parameter(f'index of {name}', self.enumtype,
readonly=self.readonly, influences={name})
idx_param.init({})
setattr(owner, iname, idx_param)
idx_param.__set_name__(owner, iname)
self.setProperty('influences', {iname})
if not hasattr(owner, f'write_{name}'):
# customization (like rounding up or down) might be
# achieved by adding write_<name>. if not, the default
# is rounding to the closest value
def wfunc(mobj, value, vdict=self.valuedict, fname=name, wfunc_iname=f'write_{iname}'):
getattr(mobj, wfunc_iname)(
min(vdict, key=lambda i: abs(vdict[i] - value)))
return getattr(mobj, fname)
setattr(owner, f'write_{name}', wfunc)
def __get__(self, instance, owner):
"""getter for value"""
if instance is None:
return self
return self.valuedict[instance.parameters[self.idx_name].value]
def finish(self, modobj=None):
"""register callbacks for consistency"""
super().finish(modobj)
if modobj:
# trigger setter of float parameter on change of enum parameter
def cb(value, modobj=modobj, name=self.name):
setattr(modobj, name, getattr(modobj, name))
modobj.valueCallbacks[self.idx_name].append(cb)

View File

@ -18,12 +18,15 @@
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""test frappy.mixins.HasCtrlPars"""
"""test frappy.extparams"""
from test.test_modules import LoggerStub, ServerStub
import pytest
from frappy.core import FloatRange, Module, Parameter
from frappy.structparam import StructParam
from frappy.extparams import FloatEnumParam
from frappy.errors import ProgrammingError
def test_with_read_ctrlpars():
@ -130,3 +133,76 @@ def test_order_dependence1():
def test_order_dependence2():
test_with_read_ctrlpars()
test_without_read_ctrlpars()
def test_float_enum():
class Mod(Module):
vrange = FloatEnumParam('voltage range', [
(1, '50uV'), '200 µV', '1mV', ('5mV', 0.006), (9, 'max', 0.024)], 'V')
gain = FloatEnumParam('gain factor', ('1', '2', '4', '8'), idx_name='igain')
dist = FloatEnumParam('distance', ('1m', '1mm', '1µm'), unit='m')
_vrange_idx = None
def write_vrange_idx(self, value):
self._vrange_idx = value
logger = LoggerStub()
updates = {}
srv = ServerStub(updates)
m = Mod('m', logger, {'description': ''}, srv)
assert m.write_vrange_idx(1) == 1
assert m._vrange_idx == '50uV'
assert m._vrange_idx == 1
assert m.vrange == 5e-5
assert m.write_vrange_idx(2) == 2
assert m._vrange_idx == '200 µV'
assert m._vrange_idx == 2
assert m.vrange == 2e-4
assert m.write_vrange(6e-5) == 5e-5 # round to the next value
assert m._vrange_idx == '50uV'
assert m._vrange_idx == 1
assert m.write_vrange(20e-3) == 24e-3 # round to the next value
assert m._vrange_idx == 'max'
assert m._vrange_idx == 9
for idx in range(4):
value = 2 ** idx
updates.clear()
assert m.write_igain(idx) == idx
assert updates == {'m': {'igain': idx, 'gain': value}}
assert m.igain == idx
assert m.igain == str(value)
assert m.gain == value
for idx in range(4):
value = 2 ** idx
assert m.write_gain(value) == value
assert m.igain == idx
assert m.igain == str(value)
for idx in range(3):
value = 10 ** (-3 * idx)
assert m.write_dist(value) == value
assert m.dist_idx == idx
@pytest.mark.parametrize('labels, unit, error', [
(FloatRange(), '', 'not a datatype'), # 2nd arg must not be a datatype
([(1, 2, 3)], '', 'must be strings'), # label is not a string
([(1, '1V', 3, 4)], 'V', 'labels or tuples'), # 4-tuple
([('1A', 3, 4)], 'A', 'labels or tuples'), # two values after label
(('1m', (0, '1k')), '', 'conflicts with'), # two times index 0
(['1mV', '10mA'], 'V', 'not the form'), # wrong unit
(['.mV'], 'V', 'not the form'), # bad number
(['mV'], 'V', 'not the form'), # missing number
(['1+mV'], 'V', 'not the form'), # bad number
])
def test_bad_float_enum(labels, unit, error):
with pytest.raises(ProgrammingError, match=error):
class Mod(Module): # pylint:disable=unused-variable
param = FloatEnumParam('', labels, unit)