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

This commit is contained in:
zolliker 2023-09-14 11:10:12 +02:00
commit c39aef10aa
14 changed files with 107 additions and 73 deletions

View File

@ -13,4 +13,5 @@ Mod('stickrot',
'stick rotation, typically not used as omega', 'stick rotation, typically not used as omega',
io='stick_io', io='stick_io',
encoder_mode='CHECK', encoder_mode='CHECK',
backlash=-1,
) )

View File

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

View File

@ -1,5 +1,5 @@
{"hcp": {"base": "/hcp", "params": [ {"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": "send", "type": "text", "readonly": false, "cmd": "hcp send", "visibility": 3},
{"path": "status", "type": "text", "visibility": 3}, {"path": "status", "type": "text", "visibility": 3},
{"path": "set", "type": "float", "readonly": false, "cmd": "hcp set"}, {"path": "set", "type": "float", "readonly": false, "cmd": "hcp set"},

View File

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

View File

@ -200,7 +200,10 @@ implemented the readable stuff. We need to define some properties of the ``targe
parameter and add a property ``loop`` indicating, which control loop and parameter and add a property ``loop`` indicating, which control loop and
heater output we use. heater output we use.
In addition, we have to implement the methods ``write_target`` and ``read_target``: In addition, we have to implement the method ``write_target``. Remark: we do not
implement ``read_target`` here, because the lakeshore does not offer to read back the
real target. The SETP command is returning the working setpoint, which may be distinct
from target during a ramp.
.. code:: python .. code:: python
@ -216,11 +219,9 @@ In addition, we have to implement the methods ``write_target`` and ``read_target
def write_target(self, target): def write_target(self, target):
# we always use a request / reply scheme # we always use a request / reply scheme
reply = self.communicate(f'SETP {self.loop},{target};SETP?{self.loop}') self.communicate(f'SETP {self.loop},{target};*OPC?')
return float(reply) return target
def read_target(self):
return float(self.communicate(f'SETP?{self.loop}'))
In order to test this, we will need to change the entry module ``T`` in the In order to test this, we will need to change the entry module ``T`` in the
configuration file: configuration file:
@ -272,13 +273,12 @@ There are two things still missing:
def write_target(self, target): def write_target(self, target):
# reactivate heater in case it was switched off # reactivate heater in case it was switched off
self.communicate(f'RANGE {self.loop},{self.heater_range};RANGE?{self.loop}') self.communicate(f'RANGE {self.loop},{self.heater_range};RANGE?{self.loop}')
reply = self.communicate(f'SETP {self.loop},{target};SETP? {self.loop}') self.communicate(f'SETP {self.loop},{target};*OPC?')
self._driving = True self._driving = True
# Setting the status attribute triggers an update message for the SECoP status # Setting the status attribute triggers an update message for the SECoP status
# parameter. This has to be done before returning from this method! # parameter. This has to be done before returning from this method!
self.status = BUSY, 'target changed' self.status = BUSY, 'target changed'
return float(reply) return target
... ...
def read_status(self): def read_status(self):
@ -403,4 +403,10 @@ Appendix 2: Extract from the LakeShore Manual
Command (340) RANGE? *term* Command (340) RANGE? *term*
Command (336/350) RANGE?<loop> *term* Command (336/350) RANGE?<loop> *term*
Reply <range> *term* Reply <range> *term*
**Operation Complete Query**
----------------------------------------------
Command *OPC?
Reply 1
Description in Frappy, we append this command to request in order
to generate a reply
====================== ======================= ====================== =======================

View File

@ -261,7 +261,6 @@ class SecopClient(ProxyClient):
"""a general SECoP client""" """a general SECoP client"""
reconnect_timeout = 10 reconnect_timeout = 10
_running = False _running = False
_shutdown = False
_rxthread = None _rxthread = None
_txthread = None _txthread = None
_connthread = None _connthread = None
@ -283,6 +282,7 @@ class SecopClient(ProxyClient):
self.uri = uri self.uri = uri
self.nodename = uri self.nodename = uri
self._lock = RLock() self._lock = RLock()
self._shutdown = Event()
def __del__(self): def __del__(self):
try: try:
@ -303,7 +303,7 @@ class SecopClient(ProxyClient):
else: else:
self._set_state(False, 'connecting') self._set_state(False, 'connecting')
deadline = time.time() + try_period deadline = time.time() + try_period
while not self._shutdown: while not self._shutdown.is_set():
try: try:
self.io = AsynConn(self.uri) # timeout 1 sec self.io = AsynConn(self.uri) # timeout 1 sec
self.io.writeline(IDENTREQUEST.encode('utf-8')) self.io.writeline(IDENTREQUEST.encode('utf-8'))
@ -339,8 +339,8 @@ class SecopClient(ProxyClient):
# stay online for now, if activated # stay online for now, if activated
self._set_state(self.online and self.activate) self._set_state(self.online and self.activate)
raise raise
time.sleep(1) self._shutdown.wait(1)
if not self._shutdown: if not self._shutdown.is_set():
self.log.info('%s ready', self.nodename) self.log.info('%s ready', self.nodename)
def __txthread(self): def __txthread(self):
@ -436,7 +436,7 @@ class SecopClient(ProxyClient):
self.log.error('rxthread ended with %r', e) self.log.error('rxthread ended with %r', e)
self._rxthread = None self._rxthread = None
self.disconnect(False) self.disconnect(False)
if self._shutdown: if self._shutdown.is_set():
return return
if self.activate: if self.activate:
self.log.info('try to reconnect to %s', self.uri) self.log.info('try to reconnect to %s', self.uri)
@ -454,7 +454,7 @@ class SecopClient(ProxyClient):
self._connthread = mkthread(self._reconnect, connected_callback) self._connthread = mkthread(self._reconnect, connected_callback)
def _reconnect(self, connected_callback=None): def _reconnect(self, connected_callback=None):
while not self._shutdown: while not self._shutdown.is_set():
try: try:
self.connect() self.connect()
if connected_callback: if connected_callback:
@ -474,15 +474,15 @@ class SecopClient(ProxyClient):
self.log.info('continue trying to reconnect') self.log.info('continue trying to reconnect')
# self.log.warning(formatExtendedTraceback()) # self.log.warning(formatExtendedTraceback())
self._set_state(False) self._set_state(False)
time.sleep(self.reconnect_timeout) self._shutdown.wait(self.reconnect_timeout)
else: else:
time.sleep(1) self._shutdown.wait(1)
self._connthread = None self._connthread = None
def disconnect(self, shutdown=True): def disconnect(self, shutdown=True):
self._running = False self._running = False
if shutdown: if shutdown:
self._shutdown = True self._shutdown.set()
self._set_state(False, 'shutdown') self._set_state(False, 'shutdown')
if self._connthread: if self._connthread:
if self._connthread == current_thread(): if self._connthread == current_thread():

View File

@ -191,8 +191,8 @@ class HasUnit:
class FloatRange(HasUnit, DataType): class FloatRange(HasUnit, DataType):
"""(restricted) float type """(restricted) float type
:param minval: (property **min**) :param min: (property **min**)
:param maxval: (property **max**) :param max: (property **max**)
:param kwds: any of the properties below :param kwds: any of the properties below
""" """
min = Property('low limit', Stub('FloatRange'), extname='min', default=-sys.float_info.max) min = Property('low limit', Stub('FloatRange'), extname='min', default=-sys.float_info.max)
@ -203,11 +203,11 @@ class FloatRange(HasUnit, DataType):
relative_resolution = Property('relative resolution', Stub('FloatRange', 0), relative_resolution = Property('relative resolution', Stub('FloatRange', 0),
extname='relative_resolution', default=1.2e-7) extname='relative_resolution', default=1.2e-7)
def __init__(self, minval=None, maxval=None, **kwds): def __init__(self, min=None, max=None, **kwds): # pylint: disable=redefined-builtin
super().__init__() super().__init__()
kwds['min'] = minval if minval is not None else -sys.float_info.max self.set_properties(min=min if min is not None else -sys.float_info.max,
kwds['max'] = maxval if maxval is not None else sys.float_info.max max=max if max is not None else sys.float_info.max,
self.set_properties(**kwds) **kwds)
def checkProperties(self): def checkProperties(self):
self.default = 0 if self.min <= 0 <= self.max else self.min self.default = 0 if self.min <= 0 <= self.max else self.min
@ -247,9 +247,9 @@ class FloatRange(HasUnit, DataType):
def __repr__(self): def __repr__(self):
hints = self.get_info() hints = self.get_info()
if 'min' in hints: if 'min' in hints:
hints['minval'] = hints.pop('min') hints['min'] = hints.pop('min')
if 'max' in hints: if 'max' in hints:
hints['maxval'] = hints.pop('max') hints['max'] = hints.pop('max')
return 'FloatRange(%s)' % (', '.join('%s=%r' % (k, v) for k, v in hints.items())) return 'FloatRange(%s)' % (', '.join('%s=%r' % (k, v) for k, v in hints.items()))
def export_value(self, value): def export_value(self, value):
@ -281,18 +281,18 @@ class FloatRange(HasUnit, DataType):
class IntRange(DataType): class IntRange(DataType):
"""restricted int type """restricted int type
:param minval: (property **min**) :param min: (property **min**)
:param maxval: (property **max**) :param max: (property **max**)
""" """
min = Property('minimum value', Stub('IntRange', -UNLIMITED, UNLIMITED), extname='min', mandatory=True) min = Property('minimum value', Stub('IntRange', -UNLIMITED, UNLIMITED), extname='min', mandatory=True)
max = Property('maximum value', Stub('IntRange', -UNLIMITED, UNLIMITED), extname='max', mandatory=True) max = Property('maximum value', Stub('IntRange', -UNLIMITED, UNLIMITED), extname='max', mandatory=True)
# a unit on an int is now allowed in SECoP, but do we need them in Frappy? # a unit on an int is now allowed in SECoP, but do we need them in Frappy?
# unit = Property('physical unit', StringType(), extname='unit', default='') # unit = Property('physical unit', StringType(), extname='unit', default='')
def __init__(self, minval=None, maxval=None): def __init__(self, min=None, max=None): # pylint: disable=redefined-builtin
super().__init__() super().__init__()
self.set_properties(min=DEFAULT_MIN_INT if minval is None else minval, self.set_properties(min=DEFAULT_MIN_INT if min is None else min,
max=DEFAULT_MAX_INT if maxval is None else maxval) max=DEFAULT_MAX_INT if max is None else max)
def checkProperties(self): def checkProperties(self):
self.default = 0 if self.min <= 0 <= self.max else self.min self.default = 0 if self.min <= 0 <= self.max else self.min
@ -364,8 +364,8 @@ class IntRange(DataType):
class ScaledInteger(HasUnit, DataType): class ScaledInteger(HasUnit, DataType):
"""scaled integer (= fixed resolution float) type """scaled integer (= fixed resolution float) type
:param minval: (property **min**) :param min: (property **min**)
:param maxval: (property **max**) :param max: (property **max**)
:param kwds: any of the properties below :param kwds: any of the properties below
note: limits are for the scaled float value note: limits are for the scaled float value
@ -380,7 +380,8 @@ class ScaledInteger(HasUnit, DataType):
relative_resolution = Property('relative resolution', FloatRange(0), relative_resolution = Property('relative resolution', FloatRange(0),
extname='relative_resolution', default=1.2e-7) extname='relative_resolution', default=1.2e-7)
def __init__(self, scale, minval=None, maxval=None, absolute_resolution=None, **kwds): # pylint: disable=redefined-builtin
def __init__(self, scale, min=None, max=None, absolute_resolution=None, **kwds):
super().__init__() super().__init__()
try: try:
scale = float(scale) scale = float(scale)
@ -390,8 +391,8 @@ class ScaledInteger(HasUnit, DataType):
absolute_resolution = scale absolute_resolution = scale
self.set_properties( self.set_properties(
scale=scale, scale=scale,
min=DEFAULT_MIN_INT * scale if minval is None else float(minval), min=DEFAULT_MIN_INT * scale if min is None else float(min),
max=DEFAULT_MAX_INT * scale if maxval is None else float(maxval), max=DEFAULT_MAX_INT * scale if max is None else float(max),
absolute_resolution=absolute_resolution, absolute_resolution=absolute_resolution,
**kwds) **kwds)
@ -1327,11 +1328,11 @@ DATATYPES = {
'bool': lambda **kwds: 'bool': lambda **kwds:
BoolType(), BoolType(),
'int': lambda min, max, **kwds: 'int': lambda min, max, **kwds:
IntRange(minval=min, maxval=max), IntRange(min=min, max=max),
'scaled': lambda scale, min, max, **kwds: 'scaled': lambda scale, min, max, **kwds:
ScaledInteger(scale=scale, minval=min*scale, maxval=max*scale, **floatargs(kwds)), ScaledInteger(scale=scale, min=min*scale, max=max*scale, **floatargs(kwds)),
'double': lambda min=None, max=None, **kwds: 'double': lambda min=None, max=None, **kwds:
FloatRange(minval=min, maxval=max, **floatargs(kwds)), FloatRange(min=min, max=max, **floatargs(kwds)),
'blob': lambda maxbytes, minbytes=0, **kwds: 'blob': lambda maxbytes, minbytes=0, **kwds:
BLOBType(minbytes=minbytes, maxbytes=maxbytes), BLOBType(minbytes=minbytes, maxbytes=maxbytes),
'string': lambda minchars=0, maxchars=None, isUTF8=False, **kwds: 'string': lambda minchars=0, maxchars=None, isUTF8=False, **kwds:

View File

@ -63,6 +63,7 @@ class HasIO(Module):
io = self.ioClass(ioname, srv.log.getChild(ioname), opts, srv) # pylint: disable=not-callable io = self.ioClass(ioname, srv.log.getChild(ioname), opts, srv) # pylint: disable=not-callable
io.callingModule = [] io.callingModule = []
srv.modules[ioname] = io srv.modules[ioname] = io
srv.dispatcher.register_module(io, ioname)
self.ioDict[self.uri] = ioname self.ioDict[self.uri] = ioname
self.io = ioname self.io = ioname

View File

@ -30,10 +30,10 @@ Remarks:
import sys import sys
from logging import DEBUG, INFO, addLevelName from logging import DEBUG, INFO, addLevelName
import mlzlog import mlzlog
from frappy.errors import NoSuchModuleError
from frappy.server import Server from frappy.server import Server
from frappy.config import load_config, Mod as ConfigMod from frappy.config import load_config, Mod as ConfigMod
from frappy.lib import generalConfig from frappy.lib import generalConfig
from frappy.protocol import dispatcher
USAGE = """create config on the fly: USAGE = """create config on the fly:
@ -77,27 +77,25 @@ class MainLogger:
self.log.handlers[0].setLevel(LOG_LEVELS['comlog']) self.log.handlers[0].setLevel(LOG_LEVELS['comlog'])
class Dispatcher: class Dispatcher(dispatcher.Dispatcher):
def __init__(self, name, log, opts, srv): def __init__(self, name, log, options, srv):
self.log = log super().__init__(name, log, options, srv)
self._modules = {} self.log = srv.log # overwrite child logger
self.equipment_id = opts.pop('equipment_id', name)
def announce_update(self, modulename, pname, pobj): def announce_update(self, modulename, pname, pobj):
if pobj.readerror: if pobj.readerror:
value = repr(pobj.readerror) value = repr(pobj.readerror)
else: else:
value = pobj.value value = pobj.value
self.log.info('%s:%s %r', modulename, pname, value) logobj = self._modules.get(modulename, self)
# self.log.info('%s:%s %r', modulename, pname, value)
logobj.log.info('%s %r', pname, value)
def register_module(self, moduleobj, modulename, export=True): def register_module(self, moduleobj, modulename, export=True):
self.log.info('registering %s', modulename)
super().register_module(moduleobj, modulename, export)
setattr(main, modulename, moduleobj) setattr(main, modulename, moduleobj)
self._modules[modulename] = moduleobj self.get_module(modulename)
def get_module(self, modulename):
if modulename in self._modules:
return self._modules[modulename]
raise NoSuchModuleError(f'Module {modulename!r} does not exist on this SEC-Node!')
logger = MainLogger() logger = MainLogger()

View File

@ -54,7 +54,7 @@ class SimBase:
attrs['write_' + k] = writer attrs['write_' + k] = writer
return object.__new__(type(f'SimBase_{devname}', (cls,), attrs)) return super().__new__(type(f'SimBase_{devname}', (cls,), attrs))
def initModule(self): def initModule(self):
super().initModule() super().initModule()

View File

@ -69,24 +69,21 @@ class TemperatureSensor(HasIO, Readable):
class TemperatureLoop(TemperatureSensor, Drivable): class TemperatureLoop(TemperatureSensor, Drivable):
# lakeshore loop number to be used for this module # lakeshore loop number to be used for this module
loop = Property('lakeshore loop', IntRange(1, 2), default=1) loop = Property('lakeshore loop', IntRange(1, 2), default=1)
target = Parameter(datatype=FloatRange(min=0, max=1500, unit='K')) target = Parameter(datatype=FloatRange(unit='K', min=0, max=1500))
heater_range = Property('heater power range', IntRange(0, 5)) # max. 3 on LakeShore 336 heater_range = Property('heater power range', IntRange(0, 5)) # max. 3 on LakeShore 336
tolerance = Parameter('convergence criterion', FloatRange(0), default=0.1, readonly = False) tolerance = Parameter('convergence criterion', FloatRange(0), default=0.1, readonly=False)
_driving = False _driving = False
def write_target(self, target): def write_target(self, target):
# reactivate heater in case it was switched off # reactivate heater in case it was switched off
# the command has to be changed in case of model 340 to f'RANGE {self.heater_range};RANGE?' # the command has to be changed in case of model 340 to f'RANGE {self.heater_range};RANGE?'
self.communicate(f'RANGE {self.loop},{self.heater_range};RANGE?{self.loop}') self.communicate(f'RANGE {self.loop},{self.heater_range};RANGE?{self.loop}')
reply = self.communicate(f'SETP {self.loop},{target};SETP? {self.loop}') self.communicate(f'SETP {self.loop},{target};*OPC?')
self._driving = True self._driving = True
# Setting the status attribute triggers an update message for the SECoP status # Setting the status attribute triggers an update message for the SECoP status
# parameter. This has to be done before returning from this method! # parameter. This has to be done before returning from this method!
self.status = BUSY, 'target changed' self.status = BUSY, 'target changed'
return float(reply) return target
def read_target(self):
return float(self.communicate(f'SETP?{self.loop}'))
def read_status(self): def read_status(self):
code = int(self.communicate(f'RDGST?{self.channel}')) code = int(self.communicate(f'RDGST?{self.channel}'))

View File

@ -23,12 +23,36 @@
"""modules to access parameters""" """modules to access parameters"""
from frappy.core import Drivable, IDLE, Attached, StringType, Property, \ from frappy.core import Drivable, IDLE, Attached, StringType, Property, \
Parameter, FloatRange Parameter, FloatRange, Readable
from frappy.errors import ConfigError from frappy.errors import ConfigError
from frappy_psi.convergence import HasConvergence from frappy_psi.convergence import HasConvergence
from frappy_psi.mixins import HasRamp from frappy_psi.mixins import HasRamp
class Par(Readable):
value = Parameter(datatype=FloatRange(unit='$'))
read = Attached(description='<module>.<parameter> for read')
unit = Property('main unit', StringType())
def setProperty(self, key, value):
if key == 'read':
value, param = value.split('.')
setattr(self, f'{key}_param', param)
super().setProperty(key, value)
def checkProperties(self):
self.applyMainUnit(self.unit)
if self.read == self.name :
raise ConfigError('illegal recursive read/write module')
super().checkProperties()
def read_value(self):
return getattr(self.read, f'{self.read_param}')
def read_status(self):
return IDLE, ''
class Driv(Drivable): class Driv(Drivable):
value = Parameter(datatype=FloatRange(unit='$')) value = Parameter(datatype=FloatRange(unit='$'))
target = Parameter(datatype=FloatRange(unit='$')) target = Parameter(datatype=FloatRange(unit='$'))

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# ***************************************************************************** # *****************************************************************************
# This program is free software; you can redistribute it and/or modify it under # 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 # the terms of the GNU General Public License as published by the Free Software
@ -185,8 +184,9 @@ class SeaClient(ProxyClient, Module):
break break
else: else:
raise CommunicationFailedError('reply %r should be "Login OK"' % reply) raise CommunicationFailedError('reply %r should be "Login OK"' % reply)
self.request('frappy_config %s %s' % (self.service, self.config)) result = self.request('frappy_config %s %s' % (self.service, self.config))
if result not in {'0', '1'}:
raise CommunicationFailedError(f'reply from frappy_config: {result}')
# frappy_async_client switches to the json protocol (better for updates) # frappy_async_client switches to the json protocol (better for updates)
self.asynio.writeline(b'frappy_async_client') self.asynio.writeline(b'frappy_async_client')
self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode()) self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode())
@ -248,7 +248,16 @@ class SeaClient(ProxyClient, Module):
raise TimeoutError('no response within 10s') raise TimeoutError('no response within 10s')
def _rxthread(self, started_callback): def _rxthread(self, started_callback):
recheck = None
while not self.shutdown: while not self.shutdown:
if recheck and time.time() > recheck:
# try to collect device changes within 1 sec
recheck = None
result = self.request('check_config %s %s' % (self.service, self.config))
if result == '1':
self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode())
else:
self.DISPATCHER.shutdown()
try: try:
reply = self.asynio.readline() reply = self.asynio.readline()
if reply is None: if reply is None:
@ -307,11 +316,7 @@ class SeaClient(ProxyClient, Module):
if mplist is None: if mplist is None:
if path.startswith('/device'): if path.startswith('/device'):
if path == '/device/changetime': if path == '/device/changetime':
result = self.request('check_config %s %s' % (self.service, self.config)) recheck = time.time() + 1
if result == '1':
self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode())
else:
self.DISPATCHER.shutdown()
elif path.startswith('/device/frappy_%s' % self.service) and value == '': elif path.startswith('/device/frappy_%s' % self.service) and value == '':
self.DISPATCHER.shutdown() self.DISPATCHER.shutdown()
else: else:

View File

@ -160,9 +160,9 @@ def test_ScaledInteger():
with pytest.raises(ProgrammingError): with pytest.raises(ProgrammingError):
ScaledInteger('xc', 'Yx') ScaledInteger('xc', 'Yx')
with pytest.raises(ProgrammingError): with pytest.raises(ProgrammingError):
ScaledInteger(scale=0, minval=1, maxval=2) ScaledInteger(scale=0, min=1, max=2)
with pytest.raises(ProgrammingError): with pytest.raises(ProgrammingError):
ScaledInteger(scale=-10, minval=1, maxval=2) ScaledInteger(scale=-10, min=1, max=2)
# check that unit can be changed # check that unit can be changed
dt.setProperty('unit', 'A') dt.setProperty('unit', 'A')
assert dt.export_datatype() == {'type': 'scaled', 'scale':0.01, 'min':-300, 'max':300, assert dt.export_datatype() == {'type': 'scaled', 'scale':0.01, 'min':-300, 'max':300,
@ -563,7 +563,7 @@ def test_get_datatype():
assert isinstance(get_datatype( assert isinstance(get_datatype(
{'type': 'scaled', 'scale':0.03, 'min':-99, 'max':111}), ScaledInteger) {'type': 'scaled', 'scale':0.03, 'min':-99, 'max':111}), ScaledInteger)
dt = ScaledInteger(scale=0.03, minval=0, maxval=9.9) dt = ScaledInteger(scale=0.03, min=0, max=9.9)
assert dt.export_datatype() == {'type': 'scaled', 'max':330, 'min':0, 'scale':0.03} assert dt.export_datatype() == {'type': 'scaled', 'max':330, 'min':0, 'scale':0.03}
assert get_datatype(dt.export_datatype()).export_datatype() == dt.export_datatype() assert get_datatype(dt.export_datatype()).export_datatype() == dt.export_datatype()