merge with wip branch

This commit is contained in:
l_samenv 2023-05-17 17:00:06 +02:00
parent 439c9d34c1
commit e1b30bf37e
56 changed files with 6679 additions and 638 deletions

View File

@ -46,10 +46,10 @@ UPDATE_MESSAGES = {EVENTREPLY, READREPLY, WRITEREPLY, ERRORPREFIX + READREQUEST,
VERSIONFMT= re.compile(r'^[^,]*?ISSE[^,]*,SECoP,')
class UNREGISTER:
"""a magic value, used a returned value in a callback
to indicate it has to be unregistered
class UnregisterCallback(Exception):
"""raise in a callback to indicate it has to be unregistered
used to implement one shot callbacks
"""
@ -157,8 +157,8 @@ class CacheItem(tuple):
class ProxyClient:
"""common functionality for proxy clients"""
CALLBACK_NAMES = ('updateEvent', 'updateItem', 'descriptiveDataChange',
'nodeStateChange', 'unhandledMessage')
CALLBACK_NAMES = {'updateEvent', 'updateItem', 'descriptiveDataChange',
'nodeStateChange', 'unhandledMessage'}
online = False # connected or reconnecting since a short time
state = 'disconnected' # further possible values: 'connecting', 'reconnecting', 'connected'
log = None
@ -181,42 +181,41 @@ class ProxyClient:
"""
for cbfunc in args:
kwds[cbfunc.__name__] = cbfunc
for cbname in self.CALLBACK_NAMES:
cbfunc = kwds.pop(cbname, None)
if not cbfunc:
continue
cbdict = self.callbacks[cbname]
cbdict[key].append(cbfunc)
for cbname, cbfunc in kwds.items():
if cbname not in self.CALLBACK_NAMES:
raise TypeError(f"unknown callback: {', '.join(kwds)}")
# immediately call for some callback types
if cbname == 'updateItem':
if key is None:
for (mname, pname), data in self.cache.items():
cbfunc(mname, pname, data)
if cbname in ('updateItem', 'updateEvent'):
if key is None: # case generic callback
cbargs = [(m, p, d) for (m, p), d in self.cache.items()]
else:
data = self.cache.get(key, None)
if data:
cbfunc(*key, data) # case single parameter
if data: # case single parameter
cbargs = [key + (data,)]
else: # case key = module
for (mname, pname), data in self.cache.items():
if mname == key:
cbfunc(mname, pname, data)
elif cbname == 'updateEvent':
if key is None:
for (mname, pname), data in self.cache.items():
cbfunc(mname, pname, *data)
else:
data = self.cache.get(key, None)
if data:
cbfunc(*key, *data) # case single parameter
else: # case key = module
for (mname, pname), data in self.cache.items():
if mname == key:
cbfunc(mname, pname, *data)
cbargs = [(m, p, d) for (m, p), d in self.cache.items() if m == key]
if cbname == 'updateEvent':
# expand entry argument to (value, timestamp, readerror)
cbargs = [a[0:2] + a[2] for a in cbargs]
elif cbname == 'nodeStateChange':
cbfunc(self.online, self.state)
if kwds:
raise TypeError(f"unknown callback: {', '.join(kwds)}")
cbargs = [(self.online, self.state)]
else:
cbargs = []
do_append = True
for args in cbargs:
try:
cbfunc(*args)
except UnregisterCallback:
do_append = False
except Exception as e:
if self.log:
self.log.error('error %r calling %s%r', e, cbfunc.__name__, args)
if do_append:
self.callbacks[cbname][key].append(cbfunc)
def unregister_callback(self, key, *args, **kwds):
"""unregister a callback
@ -240,20 +239,20 @@ class ProxyClient:
key=(<module name>, <parameter name): callbacks for specified parameter
"""
cblist = self.callbacks[cbname].get(key, [])
self.callbacks[cbname][key] = [cb for cb in cblist if cb(*args) is not UNREGISTER]
for cbfunc in list(cblist):
try:
cbfunc(*args)
except UnregisterCallback:
cblist.remove(cbfunc)
except Exception as e:
if self.log:
self.log.error('error %r calling %s%r', e, cbfunc.__name__, args)
return bool(cblist)
def updateValue(self, module, param, value, timestamp, readerror):
entry = CacheItem(value, timestamp, readerror,
self.modules[module]['parameters'][param]['datatype'])
self.cache[(module, param)] = entry
self.callback(None, 'updateItem', module, param, entry)
self.callback(module, 'updateItem', module, param, entry)
self.callback((module, param), 'updateItem', module, param, entry)
# TODO: change clients to use updateItem instead of updateEvent
self.callback(None, 'updateEvent', module, param, value, timestamp, readerror)
self.callback(module, 'updateEvent', module, param, value, timestamp, readerror)
self.callback((module, param), 'updateEvent', module, param, value, timestamp, readerror)
self.callback((module, param), 'updateEvent', module, param,value, timestamp, readerror)
class SecopClient(ProxyClient):
@ -645,6 +644,16 @@ class SecopClient(ProxyClient):
data = datatype.import_value(data)
return data, qualifiers
def updateValue(self, module, param, value, timestamp, readerror):
entry = CacheItem(value, timestamp, readerror,
self.modules[module]['parameters'][param]['datatype'])
self.cache[(module, param)] = entry
self.callback(None, 'updateItem', module, param, entry)
self.callback(module, 'updateItem', module, param, entry)
self.callback((module, param), 'updateItem', module, param, entry)
# TODO: change clients to use updateItem instead of updateEvent
super().updateValue(module, param, value, timestamp, readerror)
# the following attributes may be/are intended to be overwritten by a subclass
PREDEFINED_NAMES = set(frappy.params.PREDEFINED_ACCESSIBLES)

View File

@ -19,25 +19,7 @@
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""simple interactive python client
Usage:
from frappy.client.interactive import Client
client = Client('localhost:5000') # start client.
# this connects and creates objects for all SECoP modules in the main namespace
<module> # list all parameters
<module>.<param> = <value> # change parameter
<module>(<target>) # set target and wait until not busy
# 'status' and 'value' changes are shown every 1 sec
client.mininterval = 0.2 # change minimal update interval to 0.2 sec (default is 1 second)
watch(T) # watch changes of T.status and T.value (stop with ctrl-C)
watch(T='status target') # watch status and target parameters
watch(io, T=True) # watch io and all parameters of T
"""
"""simple interactive python client"""
import sys
import time
@ -45,6 +27,7 @@ import re
import code
import signal
import os
import traceback
from os.path import expanduser
from queue import Queue
from frappy.client import SecopClient
@ -55,7 +38,23 @@ try:
except ImportError:
readline = None
main = sys.modules['__main__']
USAGE = """
Usage:
{client_assign}
# for all SECoP modules objects are created in the main namespace
<module> # list all parameters
<module>.<param> = <value> # change parameter
<module>(<target>) # set target and wait until not busy
# 'status' and 'value' changes are shown every 1 sec
{client_name}.mininterval = 0.2 # change minimal update interval to 0.2 s (default is 1 s)
watch(T) # watch changes of T.status and T.value (stop with ctrl-C)
watch(T='status target') # watch status and target parameters
watch(io, T=True) # watch io and all parameters of T
{tail}"""
LOG_LEVELS = {'debug', 'comlog', 'info', 'warning', 'error', 'off'}
CLR = '\r\x1b[K' # code to move to the left and clear current line
@ -124,7 +123,7 @@ class Module:
self._running = None
self._status = None
props = secnode.modules[name]['properties']
self._title = f"# {props.get('implementation', '')} ({props.get('interface_classes', [''])[0]})"
self._title = f"# {props.get('implementation', '')} ({(props.get('interface_classes') or ['Module'])[0]})"
def _one_line(self, pname, minwid=0):
"""return <module>.<param> = <value> truncated to one line"""
@ -210,7 +209,7 @@ class Module:
def read(self, pname='value'):
value, _, error = self._secnode.readParameter(self._name, pname)
if error:
Console.raise_without_traceback(error)
clientenv.raise_with_short_traceback(error)
return value
def __call__(self, target=None):
@ -231,7 +230,7 @@ class Module:
return self.value
def __repr__(self):
wid = max(len(k) for k in self._parameters)
wid = max((len(k) for k in self._parameters), default=0)
return '%s\n%s%s' % (
self._title,
'\n'.join(self._one_line(k, wid) for k in self._parameters),
@ -260,8 +259,7 @@ class Param:
return self
value, _, error = obj._secnode.cache[obj._name, self.name]
if error:
Console.raise_without_traceback(error)
raise error
clientenv.raise_with_short_traceback(error)
return value
def formatted(self, obj):
@ -275,9 +273,10 @@ class Param:
obj._running = Queue()
try:
obj._secnode.setParameter(obj._name, self.name, value)
return
except SECoPError as e:
Console.raise_without_traceback(e)
# obj._secnode.log.error(repr(e))
clientenv.raise_with_short_traceback(e)
obj._secnode.log.error(repr(e))
def format(self, value):
return self.datatype.format_value(value)
@ -294,6 +293,8 @@ class Command:
if args:
raise TypeError('mixed arguments forbidden')
result, _ = self.exec(self.modname, self.name, kwds)
elif len(args) == 1:
result, _ = self.exec(self.modname, self.name, *args)
else:
result, _ = self.exec(self.modname, self.name, args or None)
return result
@ -306,7 +307,7 @@ class Command:
def show_parameter(modname, pname, *args, forced=False, mininterval=0):
"""show parameter update"""
mobj = getattr(main, modname)
mobj = clientenv.namespace[modname]
mobj._watch_parameter(modname, pname, *args)
@ -320,7 +321,7 @@ def watch(*args, **kwds):
else:
print(f'do not know {mobj!r}')
for key, arg in kwds.items():
mobj = getattr(main, key, None)
mobj = clientenv.namespace.get(key)
if mobj is None:
print(f'do not know {key!r}')
else:
@ -345,6 +346,9 @@ class Client(SecopClient):
mininterval = 1
def __init__(self, uri, loglevel='info', name=''):
if clientenv.namespace is None:
# called from a simple python interpeter
clientenv.init(sys.modules['__main__'].__dict__)
# remove previous client:
prev = self.secnodes.pop(uri, None)
log = Logger(loglevel)
@ -352,10 +356,10 @@ class Client(SecopClient):
if prev:
log.info('remove previous client to %s', uri)
for modname in prev.modules:
prevnode = getattr(getattr(main, modname, None), '_secnode', None)
prevnode = getattr(clientenv.namespace.get(modname), '_secnode', None)
if prevnode == prev:
removed_modules.append(modname)
delattr(main, modname)
clientenv.namespace.pop(modname)
prev.disconnect()
self.secnodes[uri] = self
if name:
@ -365,7 +369,7 @@ class Client(SecopClient):
created_modules = []
skipped_modules = []
for modname, moddesc in self.modules.items():
prev = getattr(main, modname, None)
prev = clientenv.namespace.get(modname)
if prev is None:
created_modules.append(modname)
else:
@ -383,7 +387,7 @@ class Client(SecopClient):
if 'status' in mobj._parameters:
self.register_callback((modname, 'status'), updateEvent=mobj._status_value_update)
self.register_callback((modname, 'value'), updateEvent=mobj._status_value_update)
setattr(main, modname, mobj)
clientenv.namespace[modname] = mobj
if removed_modules:
self.log.info('removed modules: %s', ' '.join(removed_modules))
if skipped_modules:
@ -397,7 +401,7 @@ class Client(SecopClient):
"""handle logging messages"""
if action == 'log':
modname, loglevel = ident.split(':')
modobj = getattr(main, modname, None)
modobj = clientenv.namespace.get(modname)
if modobj:
modobj.handle_log_message_(loglevel, data)
return
@ -408,9 +412,54 @@ class Client(SecopClient):
return f'Client({self.uri!r})'
def run(filepath):
clientenv.namespace.update({
"__file__": filepath,
"__name__": "__main__",
})
with open(filepath, 'rb') as file:
# pylint: disable=exec-used
exec(compile(file.read(), filepath, 'exec'), clientenv.namespace, None)
class ClientEnvironment:
namespace = None
last_frames = 0
def init(self, namespace=None):
self.namespace = namespace or {}
self.namespace.update(run=run, watch=watch, Client=Client)
def raise_with_short_traceback(self, exc):
# count number of lines of internal irrelevant stack (for remote errors)
self.last_frames = len(traceback.format_exception(*sys.exc_info()))
raise exc
def short_traceback(self):
"""cleanup tracback from irrelevant lines"""
lines = traceback.format_exception(*sys.exc_info())
# line 0: Traceback header
# skip line 1+2 (contains unspecific console line and exec code)
lines[1:3] = []
if ' exec(' in lines[1]:
# replace additional irrelevant exec line if needed with run command
lines[1:2] = []
# skip lines of client code not relevant for remote errors
lines[-self.last_frames-1:-1] = []
self.last_frames = 0
if len(lines) <= 2: # traceback contains only console line
lines = lines[-1:]
return ''.join(lines)
clientenv = ClientEnvironment()
class Console(code.InteractiveConsole):
def __init__(self, local, name='cli'):
super().__init__(local)
def __init__(self, name='cli', namespace=None):
if namespace:
clientenv.namespace = namespace
super().__init__(clientenv.namespace)
history = None
if readline:
try:
@ -428,12 +477,31 @@ class Console(code.InteractiveConsole):
Logger.sigwinch = bool(readline) # activate refresh signal
line = input(prompt)
Logger.sigwinch = False
if line.startswith('/'):
line = f"run('{line[1:].strip()}')"
return line
@classmethod
def raise_without_traceback(cls, exc):
def omit_traceback_once(cls):
del Console.showtraceback
cls.showtraceback = omit_traceback_once
print('ERROR:', repr(exc))
raise exc
def showtraceback(self):
self.write(clientenv.short_traceback())
def init(*nodes):
clientenv.init()
success = not nodes
for idx, node in enumerate(nodes):
client_name = '_c%d' % idx
try:
clientenv.namespace[client_name] = Client(node, name=client_name)
success = True
except Exception as e:
print(repr(e))
return success
def interact(usage_tail=''):
empty = '_c0' not in clientenv.namespace
print(USAGE.format(
client_name='cli' if empty else '_c0',
client_assign="\ncli = Client('localhost:5000')\n" if empty else '',
tail=usage_tail))
Console()

View File

@ -126,13 +126,15 @@ class Config(dict):
self.modules.append(mod)
def process_file(config_text):
def process_file(filename):
with open(filename, 'rb') as f:
config_text = f.read()
node = NodeCollector()
mods = Collector(Mod)
ns = {'Node': node.add, 'Mod': mods.add, 'Param': Param, 'Command': Param, 'Group': Group}
# pylint: disable=exec-used
exec(config_text, ns)
exec(compile(config_text, filename, 'exec'), ns)
return Config(node, mods)
@ -175,9 +177,7 @@ def load_config(cfgfiles, log):
for cfgfile in cfgfiles.split(','):
filename = to_config_path(cfgfile, log)
log.debug('Parsing config file %s...', filename)
with open(filename, 'rb') as f:
config_text = f.read()
cfg = process_file(config_text)
cfg = process_file(filename)
if config:
config.merge_modules(cfg)
else:

View File

@ -31,11 +31,11 @@ from frappy.datatypes import ArrayOf, BLOBType, BoolType, EnumType, \
from frappy.lib.enum import Enum
from frappy.modules import Attached, Communicator, \
Done, Drivable, Feature, Module, Readable, Writable, HasAccessibles
from frappy.params import Command, Parameter
from frappy.params import Command, Parameter, Limit
from frappy.properties import Property
from frappy.proxy import Proxy, SecNode, proxy_class
from frappy.io import HasIO, StringIO, BytesIO, HasIodev # TODO: remove HasIodev (legacy stuff)
from frappy.persistent import PersistentMixin, PersistentParam
from frappy.persistent import PersistentMixin, PersistentParam, PersistentLimit
from frappy.rwhandler import ReadHandler, WriteHandler, CommonReadHandler, \
CommonWriteHandler, nopoll

View File

@ -62,6 +62,7 @@ class DataType(HasProperties):
IS_COMMAND = False
unit = ''
default = None
client = False # used on the client side
def __call__(self, value):
"""convert given value to our datatype and validate
@ -273,9 +274,8 @@ class FloatRange(HasUnit, DataType):
def compatible(self, other):
if not isinstance(other, (FloatRange, ScaledInteger)):
raise WrongTypeError('incompatible datatypes')
# avoid infinity
other.validate(max(sys.float_info.min, self.min))
other.validate(min(sys.float_info.max, self.max))
other.validate(self.min)
other.validate(self.max)
class IntRange(DataType):
@ -826,6 +826,7 @@ class ArrayOf(DataType):
def export_value(self, value):
"""returns a python object fit for serialisation"""
self.check_type(value)
return [self.members.export_value(elem) for elem in value]
def import_value(self, value):
@ -893,11 +894,11 @@ class TupleOf(DataType):
def check_type(self, value):
try:
if len(value) != len(self.members):
raise WrongTypeError(
f'tuple needs {len(self.members)} elements')
if len(value) == len(self.members):
return
except TypeError:
raise WrongTypeError(f'{type(value).__name__} can not be converted to TupleOf DataType!') from None
raise WrongTypeError(f'tuple needs {len(self.members)} elements')
def __call__(self, value):
"""accepts any sequence, converts to tuple"""
@ -920,6 +921,7 @@ class TupleOf(DataType):
def export_value(self, value):
"""returns a python object fit for serialisation"""
self.check_type(value)
return [sub.export_value(elem) for sub, elem in zip(self.members, value)]
def import_value(self, value):
@ -990,54 +992,59 @@ class StructOf(DataType):
return res
def __repr__(self):
opt = f', optional={self.optional!r}' if self.optional else ''
opt = f', optional={self.optional!r}' if set(self.optional) == set(self.members) else ''
return 'StructOf(%s%s)' % (', '.join(
['%s=%s' % (n, repr(st)) for n, st in list(self.members.items())]), opt)
def __call__(self, value):
"""accepts any mapping, returns an immutable dict"""
self.check_type(value)
try:
if set(dict(value)) != set(self.members):
raise WrongTypeError('member names do not match') from None
except TypeError:
raise WrongTypeError(f'{type(value).__name__} can not be converted a StructOf') from None
try:
return ImmutableDict((str(k), self.members[k](v))
for k, v in list(value.items()))
result = {}
for key, val in value.items():
if val is not None: # goodie: allow None instead of missing key
result[key] = self.members[key](val)
return ImmutableDict(result)
except Exception as e:
errcls = RangeError if isinstance(e, RangeError) else WrongTypeError
raise errcls('can not convert some struct element') from e
raise errcls('can not convert struct element %s' % key) from e
def validate(self, value, previous=None):
self.check_type(value, True)
try:
result = dict(previous or {})
for key, val in value.items():
if val is not None: # goodie: allow None instead of missing key
result[key] = self.members[key].validate(val)
return ImmutableDict(result)
except Exception as e:
errcls = RangeError if isinstance(e, RangeError) else WrongTypeError
raise errcls('struct element %s is invalid' % key) from e
def check_type(self, value, allow_optional=False):
try:
superfluous = set(dict(value)) - set(self.members)
except TypeError:
raise WrongTypeError(f'{type(value).__name__} can not be converted a StructOf') from None
if superfluous - set(self.optional):
raise WrongTypeError(f"struct contains superfluous members: {', '.join(superfluous)}")
missing = set(self.members) - set(value) - set(self.optional)
missing = set(self.members) - set(value)
if self.client or allow_optional: # on the client side, allow optional elements always
missing -= set(self.optional)
if missing:
raise WrongTypeError(f"missing struct elements: {', '.join(missing)}")
try:
if previous is None:
return ImmutableDict((str(k), self.members[k].validate(v))
for k, v in list(value.items()))
result = dict(previous)
result.update(((k, self.members[k].validate(v, previous[k])) for k, v in value.items()))
return ImmutableDict(result)
except Exception as e:
errcls = RangeError if isinstance(e, RangeError) else WrongTypeError
raise errcls('some struct elements are invalid') from e
def export_value(self, value):
"""returns a python object fit for serialisation"""
self(value) # check validity
self.check_type(value)
return dict((str(k), self.members[k].export_value(v))
for k, v in list(value.items()))
def import_value(self, value):
"""returns a python object from serialisation"""
return self({str(k): self.members[k].import_value(v) for k, v in value.items()})
self.check_type(value, True)
return {str(k): self.members[k].import_value(v)
for k, v in value.items()}
def from_string(self, text):
value, rem = Parser.parse(text)
@ -1344,7 +1351,7 @@ DATATYPES = {
}
# important for getting the right datatype from formerly jsonified descr.
# used on the client side for getting the right datatype from formerly jsonified descr.
def get_datatype(json, pname=''):
"""returns a DataType object from description
@ -1366,6 +1373,8 @@ def get_datatype(json, pname=''):
raise WrongTypeError(f'a data descriptor must be a dict containing a "type" key, not {json!r}') from None
try:
return DATATYPES[base](pname=pname, **kwargs)
datatype = DATATYPES[base](pname=pname, **kwargs)
datatype.client = True
return datatype
except Exception as e:
raise WrongTypeError(f'invalid data descriptor: {json!r} ({str(e)})') from None

View File

@ -22,116 +22,14 @@
"""Define Mixin Features for real Modules implemented in the server"""
from frappy.datatypes import ArrayOf, BoolType, EnumType, \
FloatRange, StringType, StructOf, TupleOf
from frappy.core import Command, Drivable, Feature, \
Parameter, Property, PersistentParam, Readable
from frappy.errors import RangeError, ConfigError
from frappy.lib import clamp
from frappy.datatypes import FloatRange
from frappy.core import Feature, PersistentParam
class HasSimpleOffset(Feature):
class HasOffset(Feature):
"""has a client side offset parameter
this is just a storage!
"""
featureName = 'HasOffset'
offset = PersistentParam('offset (physical value + offset = HW value)',
FloatRange(unit='deg'), readonly=False, default=0)
def write_offset(self, value):
self.offset = value
if isinstance(self, HasLimits):
self.read_limits()
if isinstance(self, Readable):
self.read_value()
if isinstance(self, Drivable):
self.read_target()
self.saveParameters()
return value
class HasTargetLimits(Feature):
"""user limits
implementation to be done in the subclass
according to standard
"""
target_limits = PersistentParam('user limits', readonly=False, default=(-9e99, 9e99),
datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg')))
_limits = None
def apply_offset(self, sign, *values):
if isinstance(self, HasOffset):
return tuple(v + sign * self.offset for v in values)
return values
def earlyInit(self):
super().earlyInit()
# make limits valid
_limits = self.apply_offset(1, *self.limits)
self._limits = tuple(clamp(self.abslimits[0], v, self.abslimits[1]) for v in _limits)
self.read_limits()
def checkProperties(self):
pname = 'target' if isinstance(self, Drivable) else 'value'
dt = self.parameters[pname].datatype
min_, max_ = self.abslimits
t_min, t_max = self.apply_offset(1, dt.min, dt.max)
if t_min > max_ or t_max < min_:
raise ConfigError(f'abslimits not within {pname} range')
self.abslimits = clamp(t_min, min_, t_max), clamp(t_min, max_, t_max)
super().checkProperties()
def read_limits(self):
return self.apply_offset(-1, *self._limits)
def write_limits(self, value):
min_, max_ = self.apply_offset(-1, *self.abslimits)
if not min_ <= value[0] <= value[1] <= max_:
if value[0] > value[1]:
raise RangeError(f'invalid interval: {value!r}')
raise RangeError(f'limits not within abs limits [{min_:g}, {max_:g}]')
self.limits = value
self.saveParameters()
return self.limits
def check_limits(self, value):
"""check if value is valid"""
min_, max_ = self.target_limits
if not min_ <= value <= max_:
raise RangeError(f'limits violation: {value:g} outside [{min_:g}, {max_:g}]')
# --- legacy mixins, not agreed as standard ---
class HasOffset(Feature):
"""has an offset parameter
implementation to be done in the subclass
"""
offset = Parameter('offset (physical value + offset = HW value)',
FloatRange(unit='$'), readonly=False, default=0)
class HasLimits(Feature):
"""user limits
implementation to be done in the subclass
for a drivable, abslimits is roughly the same as the target datatype limits,
except for the offset
"""
target_limits = Parameter('user limits for target', readonly=False, default=(-9e99, 9e99),
datatype=TupleOf(FloatRange(unit='$'), FloatRange(unit='$')))
def earlyInit(self):
super().earlyInit()
dt = self.parameters['target'].datatype
self.target_limits = dt.min, dt.max
def check_limits(self, value):
"""check if value is valid"""
min_, max_ = self.target_limits
if not min_ <= value <= max_:
raise RangeError('limits violation: %g outside [%g, %g]' % (value, min_, max_))

View File

@ -24,6 +24,8 @@ import configparser
from collections import OrderedDict
from configparser import NoOptionError
from frappy.config import load_config
from frappy.datatypes import StringType, EnumType
from frappy.gui.cfg_editor.tree_widget_item import TreeWidgetItem
from frappy.gui.cfg_editor.utils import get_all_children_with_names, \
get_all_items, get_interface_class_from_name, get_module_class_from_name, \
@ -41,7 +43,7 @@ SECTIONS = {NODE: 'description',
MODULE: 'class'}
def write_config(file_name, tree_widget):
def write_legacy_config(file_name, tree_widget):
itms = get_all_items(tree_widget)
itm_lines = OrderedDict()
value_str = '%s = %s'
@ -79,8 +81,7 @@ def write_config(file_name, tree_widget):
with open(file_name, 'w', encoding='utf-8') as configfile:
configfile.write('\n'.join(itm_lines.values()))
def read_config(file_path):
def read_legacy_config(file_path):
# TODO datatype of params and properties
node = TreeWidgetItem(NODE)
ifs = TreeWidgetItem(name='Interfaces')
@ -147,6 +148,119 @@ def read_config(file_path):
node = get_comments(node, ifs, mods, file_path)
return node, ifs, mods
def fmt_value(param, dt):
if isinstance(dt, StringType):
return repr(param)
if isinstance(dt, EnumType):
try:
return int(param)
except ValueError:
return repr(param)
return param
def fmt_param(param, pnp):
props = pnp[param.name]
if isinstance(props, list):
dt = props[0].datatype
else:
dt = props.datatype
if param.childCount() > 1 or (param.childCount() == 1 and param.child(0).name != 'value'):
values = []
main_value = param.get_value()
if main_value:
values.append(fmt_value(main_value, dt))
for i in range(param.childCount()):
prop = param.child(i)
propdt = props[1][prop.name].datatype
propv = fmt_value(prop.get_value(), propdt)
values.append(f'{prop.name} = {propv}')
values = f'Param({", ".join(values)})'
else:
values = fmt_value(param.get_value(), dt)
return f' {param.name} = {values},'
def write_config(file_name, tree_widget):
"""stopgap python config writing. assumes no comments are in the cfg."""
itms = get_all_items(tree_widget)
for itm in itms:
if itm.kind == 'comment':
print('comments are broken right now. not writing a file. exiting...')
return
lines = []
root = tree_widget.topLevelItem(0)
eq_id = root.name
description = root.get_value()
iface = root.child(0).child(0).name
lines.append(f'Node(\'{eq_id}\',\n {repr(description)},\n \'{iface}\',')
for i in range(2, root.childCount()):
lines.append(fmt_param(root.child(i), {}))
lines.append(')')
mods = root.child(1)
for i in range(mods.childCount()):
lines.append('\n')
mod = mods.child(i)
params_and_props = {}
params_and_props.update(mod.properties)
params_and_props.update(mod.parameters)
descr = None
for i in range(mod.childCount()):
if mod.child(i).name == 'description':
descr = mod.child(i)
break
lines.append(f'Mod(\'{mod.name}\',\n \'{mod.get_value()}\',\n \'{descr.get_value()}\',')
for j in range(mod.childCount()):
if j == i:
continue
lines.append(fmt_param(mod.child(j), params_and_props))
lines.append(')')
with open(file_name, 'w', encoding='utf-8') as configfile:
configfile.write('\n'.join(lines))
def read_config(file_path, log):
config = load_config(file_path, log)
node = TreeWidgetItem(NODE)
ifs = TreeWidgetItem(name='Interfaces')
mods = TreeWidgetItem(name='Modules')
node.addChild(ifs)
node.addChild(mods)
nodecfg = config.pop('node', {})
node.set_name(nodecfg['equipment_id'])
node.set_value(nodecfg['description'])
node.parameters = get_params(NODE)
node.properties = get_props(NODE)
iface = TreeWidgetItem(INTERFACE, nodecfg['interface'])
#act_class = get_interface_class_from_name(section_value)
#act_item.set_class_object(act_class)
ifs.addChild(iface)
for name, modcfg in config.items():
act_item = TreeWidgetItem(MODULE, name)
mods.addChild(act_item)
cls = modcfg.pop('cls')
act_item.set_value(cls)
act_class = get_module_class_from_name(cls)
act_item.set_class_object(act_class)
act_item.parameters = get_params(act_class)
act_item.properties = get_props(act_class)
for param, options in modcfg.items():
if not isinstance(options, dict):
prop = TreeWidgetItem(PROPERTY, param, str(options))
act_item.addChild(prop)
else:
param = TreeWidgetItem(PARAMETER, param)
param.set_value('')
act_item.addChild(param)
for k, v in options.items():
if k == 'value':
param.set_value(str(v))
else:
param.addChild(TreeWidgetItem(PROPERTY, k, str(v)))
#node = get_comments(node, ifs, mods, file_path)
return node, ifs, mods
def get_value(config, section, option):
value = config.get(section, option)

View File

@ -39,9 +39,10 @@ COMMENT = 'comment'
class MainWindow(QMainWindow):
def __init__(self, file_path=None, parent=None):
def __init__(self, file_path=None, log=None, parent=None):
super().__init__(parent)
loadUi(self, 'mainwindow.ui')
self.log = log
self.tabWidget.currentChanged.connect(self.tab_relevant_btns_disable)
if file_path is None:
self.tab_relevant_btns_disable(-1)
@ -179,7 +180,7 @@ class MainWindow(QMainWindow):
QMessageBox.StandardButton.Save)
def new_node(self, name, file_path=None):
node = NodeDisplay(file_path)
node = NodeDisplay(file_path, self.log)
if node.created:
node.tree_widget.currentItemChanged.connect(self.disable_btns)
self.tabWidget.setCurrentIndex(self.tabWidget.addTab(node, name))

View File

@ -26,10 +26,12 @@ from frappy.gui.cfg_editor.utils import loadUi
class NodeDisplay(QWidget):
def __init__(self, file_path=None, parent=None):
def __init__(self, file_path=None, log=None, parent=None):
super().__init__(parent)
loadUi(self, 'node_display.ui')
self.log = log
self.saved = bool(file_path)
self.tree_widget.log = log
self.created = self.tree_widget.set_file(file_path)
self.tree_widget.save_status_changed.connect(self.change_save_status)
self.tree_widget.currentItemChanged.connect(self.set_scroll_area)

View File

@ -99,8 +99,8 @@ def get_file_paths(widget, open_file=True):
dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
dialog.setFileMode(QFileDialog.FileMode.AnyFile)
dialog.setWindowTitle(title)
dialog.setNameFilter('*.cfg')
dialog.setDefaultSuffix('.cfg')
dialog.setNameFilter('*.py')
dialog.setDefaultSuffix('.py')
dialog.exec()
return dialog.selectedFiles()

View File

@ -102,7 +102,7 @@ class TreeWidget(QTreeWidget):
self.file_path = file_path
if self.file_path:
if os.path.isfile(file_path):
self.set_tree(read_config(self.file_path))
self.set_tree(read_config(self.file_path, self.log))
self.emit_save_status_changed(True)
return True
self.file_path = None
@ -277,7 +277,7 @@ class TreeWidget(QTreeWidget):
file_name = self.file_path
if not self.file_path or save_as:
file_name = get_file_paths(self, False)[-1]
if file_name[-4:] == '.cfg':
if file_name[-3:] == '.py':
self.file_path = file_name
write_config(self.file_path, self)
self.emit_save_status_changed(True)

View File

@ -228,7 +228,7 @@ def get_widget(datatype, readonly=False, parent=None):
TupleOf: TupleWidget,
StructOf: StructWidget,
ArrayOf: ArrayWidget,
}.get(datatype.__class__)(datatype, readonly, parent)
}.get(datatype.__class__, TextWidget)(datatype, readonly, parent)
# TODO: handle NoneOr

View File

@ -52,21 +52,25 @@ class HasIO(Module):
ioClass = None
def __init__(self, name, logger, opts, srv):
io = opts.get('io')
super().__init__(name, logger, opts, srv)
if self.uri:
# automatic creation of io device
opts = {'uri': self.uri, 'description': f'communication device for {name}',
'export': False}
'visibility': 'expert'}
ioname = self.ioDict.get(self.uri)
if not ioname:
ioname = io or name + '_io'
ioname = opts.get('io') or f'{name}_io'
io = self.ioClass(ioname, srv.log.getChild(ioname), opts, srv) # pylint: disable=not-callable
io.callingModule = []
srv.modules[ioname] = io
self.ioDict[self.uri] = ioname
self.io = ioname
elif not io:
raise ConfigError(f"Module {name} needs a value for either 'uri' or 'io'")
def initModule(self):
if not self.io:
# self.io was not assigned
raise ConfigError(f"Module {self.name} needs a value for either 'uri' or 'io'")
super().initModule()
def communicate(self, *args):
return self.io.communicate(*args)
@ -149,7 +153,7 @@ class IOBase(Communicator):
self.is_connected is changed only by self.connectStart or self.closeConnection
"""
if self.is_connected:
return True
return True # no need for intermediate updates
try:
self.connectStart()
if self._last_error:

View File

@ -21,9 +21,9 @@
# *****************************************************************************
"""Define helpers"""
import re
import importlib
import linecache
import re
import socket
import sys
import threading
@ -295,50 +295,68 @@ def formatException(cut=0, exc_info=None, verbose=False):
return ''.join(res)
HOSTNAMEPAT = re.compile(r'[a-z0-9_.-]+$', re.IGNORECASE) # roughly checking for a valid hostname or ip address
HOSTNAMEPART = re.compile(r'^(?!-)[a-z0-9-]{1,63}(?<!-)$', re.IGNORECASE)
def validate_hostname(host):
"""checks if the rules for valid hostnames are adhered to"""
if len(host) > 255:
return False
for part in host.split('.'):
if not HOSTNAMEPART.match(part):
return False
return True
def parseHostPort(host, defaultport):
"""Parse host[:port] string and tuples
def validate_ipv4(addr):
"""check if v4 address is valid."""
try:
socket.inet_aton(addr)
except OSError:
return False
return True
Specify 'host[:port]' or a (host, port) tuple for the mandatory argument.
If the port specification is missing, the value of the defaultport is used.
raises TypeError in case host is neither a string nor an iterable
raises ValueError in other cases of invalid arguments
def validate_ipv6(addr):
"""check if v6 address is valid."""
try:
socket.inet_pton(socket.AF_INET6, addr)
except OSError:
return False
return True
def parse_ipv6_host_and_port(addr, defaultport=10767):
""" Parses IPv6 addresses with optional port. See parse_host_port for valid formats"""
if ']' in addr:
host, port = addr.rsplit(':', 1)
return host[1:-1], int(port)
if '.' in addr:
host, port = addr.rsplit('.', 1)
return host, int(port)
return (host, defaultport)
def parse_host_port(host, defaultport=10767):
"""Parses hostnames and IP (4/6) addressses.
The accepted formats are:
- a standard hostname
- base IPv6 or 4 addresses
- 'hostname:port'
- IPv4 addresses in the form of 'IPv4:port'
- IPv6 addresses in the forms '[IPv6]:port' or 'IPv6.port'
"""
if isinstance(host, str):
host, sep, port = host.partition(':')
if sep:
port = int(port)
else:
colons = host.count(':')
if colons == 0: # hostname/ipv4 wihtout port
port = defaultport
else:
host, port = host
if not HOSTNAMEPAT.match(host):
raise ValueError(f'illegal host name {host!r}')
if 0 < port < 65536:
return host, port
raise ValueError(f'illegal port number: {port!r}')
def tcpSocket(host, defaultport, timeout=None):
"""Helper for opening a TCP client socket to a remote server.
Specify 'host[:port]' or a (host, port) tuple for the mandatory argument.
If the port specification is missing, the value of the defaultport is used.
If timeout is set to a number, the timout of the connection is set to this
number, else the socket stays in blocking mode.
"""
host, port = parseHostPort(host, defaultport)
# open socket and set options
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
if timeout:
s.settimeout(timeout)
# connect
s.connect((host, int(port)))
return s
elif colons == 1: # hostname or ipv4 with port
host, port = host.split(':')
port = int(port)
else: # ipv6
host, port = parse_ipv6_host_and_port(host, defaultport)
if (validate_ipv4(host) or validate_hostname(host) or validate_ipv6(host)) \
and 0 < port < 65536:
return (host, port)
raise ValueError(f'invalid host {host!r} or port {port}')
# keep a reference to socket to avoid (interpreter) shut-down problems
@ -378,3 +396,13 @@ class UniqueObject:
def __repr__(self):
return self.name
def merge_status(*args):
"""merge status
the status with biggest code wins
texts matching maximal code are joined with ', '
"""
maxcode = max(a[0] for a in args)
return maxcode, ', '.join([a[1] for a in args if a[0] == maxcode and a[1]])

View File

@ -35,7 +35,7 @@ import time
import re
from frappy.errors import CommunicationFailedError, ConfigError
from frappy.lib import closeSocket, parseHostPort, tcpSocket
from frappy.lib import closeSocket, parse_host_port
try:
from serial import Serial
@ -60,7 +60,7 @@ class AsynConn:
if not iocls:
# try tcp, if scheme not given
try:
parseHostPort(uri, 1) # check hostname only
parse_host_port(uri, 1) # check hostname only
except ValueError:
if 'COM' in uri:
raise ValueError("the correct uri for a COM port is: "
@ -175,7 +175,9 @@ class AsynTcp(AsynConn):
if uri.startswith('tcp://'):
uri = uri[6:]
try:
self.connection = tcpSocket(uri, self.default_settings.get('port'), self.timeout)
host, port = parse_host_port(uri, self.default_settings.get('port'))
self.connection = socket.create_connection((host, port), timeout=self.timeout)
except (ConnectionRefusedError, socket.gaierror, socket.timeout) as e:
# indicate that retrying might make sense
raise CommunicationFailedError(str(e)) from None

View File

@ -212,7 +212,7 @@ class EnumMember:
return self.value.__index__()
def __format__(self, format_spec):
if format_spec.endswith('d'):
if format_spec.endswith(('d', 'g')):
return format(self.value, format_spec)
return super().__format__(format_spec)

View File

@ -21,16 +21,17 @@
# *****************************************************************************
from frappy.datatypes import BoolType, EnumType, Enum
from frappy.core import Parameter, Writable, Attached
from frappy.core import Parameter, Attached
class HasControlledBy(Writable):
class HasControlledBy:
"""mixin for modules with controlled_by
in the :meth:`write_target` the hardware action to switch to own control should be done
and in addition self.self_controlled() should be called
"""
controlled_by = Parameter('source of target value', EnumType(members={'self': 0}), default=0)
target = Parameter() # make sure target is a parameter
inputCallbacks = ()
def register_input(self, name, deactivate_control):
@ -57,7 +58,7 @@ class HasControlledBy(Writable):
deactivate_control(self.name)
class HasOutputModule(Writable):
class HasOutputModule:
"""mixin for modules having an output module
in the :meth:`write_target` the hardware action to switch to own control should be done
@ -66,6 +67,7 @@ class HasOutputModule(Writable):
# mandatory=False: it should be possible to configure a module with fixed control
output_module = Attached(HasControlledBy, mandatory=False)
control_active = Parameter('control mode', BoolType(), default=False)
target = Parameter() # make sure target is a parameter
def initModule(self):
super().initModule()
@ -85,8 +87,8 @@ class HasOutputModule(Writable):
out.controlled_by = self.name
self.control_active = True
def deactivate_control(self, switched_by):
def deactivate_control(self, source):
"""called when an other module takes over control"""
if self.control_active:
self.control_active = False
self.log.warning(f'switched to manual mode by {switched_by}')
self.log.warning(f'switched to manual mode by {source}')

View File

@ -34,7 +34,7 @@ from frappy.errors import BadValueError, CommunicationFailedError, ConfigError,
ProgrammingError, SECoPError, secop_error, RangeError
from frappy.lib import formatException, mkthread, UniqueObject
from frappy.lib.enum import Enum
from frappy.params import Accessible, Command, Parameter
from frappy.params import Accessible, Command, Parameter, Limit
from frappy.properties import HasProperties, Property
from frappy.logging import RemoteLogHandler, HasComlog
@ -161,7 +161,7 @@ class HasAccessibles(HasProperties):
# find the base class, where the parameter <limname> is defined first.
# we have to check all bases, as they may not be treated yet when
# not inheriting from HasAccessibles
base = next(b for b in reversed(base.__mro__) if limname in b.__dict__)
base = next(b for b in reversed(cls.__mro__) if limname in b.__dict__)
if cname not in base.__dict__:
# there is no check method yet at this class
# add check function to the class where the limit was defined
@ -245,7 +245,6 @@ class Feature(HasAccessibles):
a mixin with Feature as a direct base class is recognized as a SECoP feature
and reported in the module property 'features'
"""
featureName = None
class PollInfo:
@ -377,10 +376,10 @@ class Module(HasAccessibles):
# b.__name__ for b in mycls.__mro__ if b.__module__.startswith('frappy.modules')]
# list of only the 'highest' secop module class
self.interface_classes = [
b.__name__ for b in mycls.__mro__ if issubclass(Drivable, b)][0:1]
b.__name__ for b in mycls.__mro__ if b in SECoP_BASE_CLASSES][:1]
# handle Features
self.features = [b.featureName or b.__name__ for b in mycls.__mro__ if Feature in b.__bases__]
self.features = [b.__name__ for b in mycls.__mro__ if Feature in b.__bases__]
# handle accessibles
# 1) make local copies of parameter objects
@ -431,28 +430,19 @@ class Module(HasAccessibles):
self.valueCallbacks[pname] = []
self.errorCallbacks[pname] = []
if isinstance(pobj, Limit):
basepname = pname.rpartition('_')[0]
baseparam = self.parameters.get(basepname)
if not baseparam:
errors.append(f'limit {pname!r} is given, but not {basepname!r}')
continue
if baseparam.datatype is None:
continue # an error will be reported on baseparam
pobj.set_datatype(baseparam.datatype)
if not pobj.hasDatatype():
head, _, postfix = pname.rpartition('_')
if postfix not in ('min', 'max', 'limits'):
errors.append(f'{pname} needs a datatype')
continue
# when datatype is not given, properties are set automagically
pobj.setProperty('readonly', False)
baseparam = self.parameters.get(head)
if not baseparam:
errors.append(f'parameter {pname!r} is given, but not {head!r}')
continue
dt = baseparam.datatype
if dt is None:
continue # an error will be reported on baseparam
if postfix == 'limits':
pobj.setProperty('datatype', TupleOf(dt, dt))
pobj.setProperty('default', (dt.min, dt.max))
else:
pobj.setProperty('datatype', dt)
pobj.setProperty('default', getattr(dt, postfix))
if not pobj.description:
pobj.setProperty('description', f'limit for {pname}')
if pobj.value is None:
if pobj.needscfg:
@ -805,11 +795,11 @@ class Module(HasAccessibles):
raise ValueError('remote handler not found')
self.remoteLogHandler.set_conn_level(self, conn, level)
def checkLimits(self, value, parametername='target'):
def checkLimits(self, value, pname='target'):
"""check for limits
:param value: the value to be checked for <parametername>_min <= value <= <parametername>_max
:param parametername: parameter name, default is 'target'
:param value: the value to be checked for <pname>_min <= value <= <pname>_max
:param pname: parameter name, default is 'target'
raises RangeError in case the value is not valid
@ -818,14 +808,20 @@ class Module(HasAccessibles):
when no automatic super call is desired.
"""
try:
min_, max_ = getattr(self, parametername + '_limits')
except AttributeError:
min_ = getattr(self, parametername + '_min', float('-inf'))
max_ = getattr(self, parametername + '_max', float('inf'))
min_, max_ = getattr(self, pname + '_limits')
if not min_ <= value <= max_:
raise RangeError(f'{pname} outside {pname}_limits')
return
except AttributeError:
pass
min_ = getattr(self, pname + '_min', float('-inf'))
max_ = getattr(self, pname + '_max', float('inf'))
if min_ > max_:
raise RangeError(f'invalid limits: [{min_:g}, {max_:g}]')
raise RangeError(f'limits violation: {value:g} outside [{min_:g}, {max_:g}]')
raise RangeError(f'invalid limits: {pname}_min > {pname}_max')
if value < min_:
raise RangeError(f'{pname} below {pname}_min')
if value > max_:
raise RangeError(f'{pname} above {pname}_max')
class Readable(Module):
@ -838,7 +834,7 @@ class Readable(Module):
value = Parameter('current value of the module', FloatRange())
status = Parameter('current status of the module', StatusType(Status),
default=(StatusType.IDLE, ''))
pollinterval = Parameter('default poll interval', FloatRange(0.1, 120),
pollinterval = Parameter('default poll interval', FloatRange(0.1, 120, unit='s'),
default=5, readonly=False, export=True)
def doPoll(self):
@ -901,6 +897,7 @@ class Communicator(HasComlog, Module):
"""
raise NotImplementedError()
SECoP_BASE_CLASSES = {Readable, Writable, Drivable, Communicator}
class Attached(Property):
"""a special property, defining an attached module

View File

@ -492,8 +492,6 @@ class Command(Accessible):
# pylint: disable=unnecessary-dunder-call
func = self.__get__(module_obj)
if self.argument:
# validate
argument = self.argument(argument)
if isinstance(self.argument, TupleOf):
res = func(*argument)
elif isinstance(self.argument, StructOf):
@ -516,6 +514,35 @@ class Command(Accessible):
return result[:-1] + f', {self.func!r})' if self.func else result
class Limit(Parameter):
"""a special limit parameter"""
POSTFIXES = {'min', 'max', 'limits'} # allowed postfixes
def __set_name__(self, owner, name):
super().__set_name__(owner, name)
head, _, postfix = name.rpartition('_')
if postfix not in self.POSTFIXES:
raise ProgrammingError(f'Limit name must end with one of {self.POSTFIXES}')
if 'readonly' not in self.propertyValues:
self.readonly = False
if not self.description:
self.description = f'limit for {head}'
if self.export.startswith('_') and PREDEFINED_ACCESSIBLES.get(head):
self.export = self.export[1:]
def set_datatype(self, datatype):
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)
else: # min, max
self.datatype = datatype
self.default = getattr(datatype, postfix)
# list of predefined accessibles with their type
PREDEFINED_ACCESSIBLES = {
'value': Parameter,

View File

@ -57,7 +57,7 @@ import json
from frappy.lib import generalConfig
from frappy.datatypes import EnumType
from frappy.params import Parameter, Property, Command
from frappy.params import Parameter, Property, Command, Limit
from frappy.modules import Module
@ -67,6 +67,10 @@ class PersistentParam(Parameter):
given = False
class PersistentLimit(Limit, Parameter):
pass
class PersistentMixin(Module):
persistentData = None # dict containing persistent data after startup

View File

@ -81,6 +81,7 @@ class Dispatcher:
def __init__(self, name, log, opts, srv):
self.log = log
self._modules = {}
self.equipment_id = opts.pop('equipment_id', name)
def announce_update(self, modulename, pname, pobj):
if pobj.readerror:

View File

@ -21,22 +21,22 @@
# *****************************************************************************
"""provides tcp interface to the SECoP Server"""
import errno
import os
import socket
import socketserver
import sys
import threading
import time
import errno
import os
from frappy.datatypes import BoolType, StringType
from frappy.errors import SECoPError
from frappy.lib import formatException, \
formatExtendedStack, formatExtendedTraceback
from frappy.lib import formatException, formatExtendedStack, \
formatExtendedTraceback
from frappy.properties import Property
from frappy.protocol.interface import decode_msg, encode_msg_frame, get_msg
from frappy.protocol.messages import ERRORPREFIX, \
HELPREPLY, HELPREQUEST, HelpMessage
from frappy.protocol.messages import ERRORPREFIX, HELPREPLY, HELPREQUEST, \
HelpMessage
DEF_PORT = 10767
MESSAGE_READ_SIZE = 1024
@ -57,7 +57,7 @@ class TCPRequestHandler(socketserver.BaseRequestHandler):
clientaddr = self.client_address
serverobj = self.server
self.log.info("handling new connection from %s:%d" % clientaddr)
self.log.info("handling new connection from %s", format_address(clientaddr))
data = b''
# notify dispatcher of us
@ -160,7 +160,7 @@ class TCPRequestHandler(socketserver.BaseRequestHandler):
def finish(self):
"""called when handle() terminates, i.e. the socket closed"""
self.log.info('closing connection from %s:%d' % self.client_address)
self.log.info('closing connection from %s', format_address(self.client_address))
# notify dispatcher
self.server.dispatcher.remove_connection(self)
# close socket
@ -171,8 +171,28 @@ class TCPRequestHandler(socketserver.BaseRequestHandler):
finally:
self.request.close()
class DualStackTCPServer(socketserver.ThreadingTCPServer):
"""Subclassed to provide IPv6 capabilities as socketserver only uses IPv4"""
def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True, enable_ipv6=False):
super().__init__(
server_address, RequestHandlerClass, bind_and_activate=False)
class TCPServer(socketserver.ThreadingTCPServer):
# override default socket
if enable_ipv6:
self.address_family = socket.AF_INET6
self.socket = socket.socket(self.address_family,
self.socket_type)
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
if bind_and_activate:
try:
self.server_bind()
self.server_activate()
except:
self.server_close()
raise
class TCPServer(DualStackTCPServer):
daemon_threads = True
# on windows, 'reuse_address' means that several servers might listen on
# the same port, on the other hand, a port is not blocked after closing
@ -191,13 +211,16 @@ class TCPServer(socketserver.ThreadingTCPServer):
self.name = name
self.log = logger
port = int(options.pop('uri').split('://', 1)[-1])
enable_ipv6 = options.pop('ipv6', False)
self.detailed_errors = options.pop('detailed_errors', False)
self.log.info("TCPServer %s binding to port %d", name, port)
for ntry in range(5):
try:
socketserver.ThreadingTCPServer.__init__(
self, ('0.0.0.0', port), TCPRequestHandler, bind_and_activate=True)
DualStackTCPServer.__init__(
self, ('', port), TCPRequestHandler,
bind_and_activate=True, enable_ipv6=enable_ipv6
)
break
except OSError as e:
if e.args[0] == errno.EADDRINUSE: # address already in use
@ -217,3 +240,11 @@ class TCPServer(socketserver.ThreadingTCPServer):
def __exit__(self, *args):
self.server_close()
def format_address(addr):
if len(addr) == 2:
return '%s:%d' % addr
address, port = addr[0:2]
if address.startswith('::ffff'):
return '%s:%d' % (address[7:], port)
return '[%s]:%d' % (address, port)

View File

@ -122,8 +122,7 @@ class Router(frappy.protocol.dispatcher.Dispatcher):
self.node_by_module[module] = node
self.nodes.append(node)
self.restart()
return frappy.client.UNREGISTER
return None
raise frappy.client.UnregisterCallback()
node.register_callback(None, nodeStateChange)
logger.warning('can not connect to node %r', node.nodename)

View File

@ -217,6 +217,7 @@ class CommonWriteHandler(WriteHandler):
raise ProgrammingError('a method wrapped with CommonWriteHandler must not return any value')
# remove pname from writeDict. this was not removed in WriteParameters, as it was not missing
module.writeDict.pop(pname, None)
return getattr(module, pname)
return method

View File

@ -125,6 +125,9 @@ class Server:
def unknown_options(self, cls, options):
return f"{cls.__name__} class don't know how to handle option(s): {', '.join(options)}"
def restart_hook(self):
pass
def run(self):
while self._restart:
self._restart = False

View File

@ -183,12 +183,13 @@ class HasStates:
override for code to be executed after stopping
"""
def start_machine(self, statefunc, fast_poll=True, **kwds):
def start_machine(self, statefunc, fast_poll=True, status=None, **kwds):
"""start or restart the state machine
:param statefunc: the initial state to be called
:param fast_poll: flag to indicate that polling has to switched to fast
:param cleanup: a cleanup function
:param status: override automatic immediate status before first state
:param kwds: attributes to be added to the state machine on start
If the state machine is already running, the following happens:
@ -202,9 +203,12 @@ class HasStates:
4) the state machine continues at the given statefunc
"""
sm = self._state_machine
if status is None:
sm.status = self.get_status(statefunc, BUSY)
if sm.statefunc:
sm.status = sm.status[0], 'restarting'
else:
sm.status = status
sm.start(statefunc, cleanup=kwds.pop('cleanup', self.on_cleanup), **kwds)
self.read_status()
if fast_poll:

219
frappy_psi/SR_7270.py Normal file
View File

@ -0,0 +1,219 @@
#!/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
# 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:
# Daniel Margineda <daniel.margineda@psi.ch>
# *****************************************************************************
"""Signal Recovery SR7270: lockin amplifier for AC susceptibility"""
from frappy.core import Readable, Parameter, Command, FloatRange, TupleOf, \
HasIO, StringIO, Attached, IntRange, BoolType, EnumType
class SR7270(StringIO):
end_of_line = b'\x00'
def communicate(self, command): # remove dash from terminator
reply = super().communicate(command)
status = self._conn.readbytes(2, 0.1) # get the 2 status bytes
return reply + ';%d;%d' % tuple(status)
class XY(HasIO, Readable):
x = Attached()
y = Attached()
freq_arg = Attached()
amp_arg = Attached()
tc_arg = Attached()
phase_arg = Attached()
dac_arg = Attached()
# parameters required an initial value but initwrite write the default value for polled parameters
value = Parameter('X, Y', datatype=TupleOf(FloatRange(unit='V'), FloatRange(unit='V')))
# separate module (Writable)
freq = Parameter('exc_freq_int',
FloatRange(0.001, 250e3, unit='Hz'),
readonly=False, default=1000) # initwrite=True,
# separate module (Writable)
amp = Parameter('exc_volt_int',
FloatRange(0.00, 5, unit='Vrms'),
readonly=False, default=0.1) # initwrite=True,
# unify the following
range = Parameter('sensitivity value', FloatRange(0.00, 1, unit='V'), default=1)
irange = Parameter('sensitivity index', IntRange(0, 27), readonly=False, default=25)
autorange = Parameter('autorange_on', EnumType('autorange', off=0, soft=1, hard=2),
readonly=False, default=0) # , initwrite=True
# unify the following
tc = Parameter('time constant value', FloatRange(10e-6, 100, unit='s'), default=0.1)
itc = Parameter('time constant index', IntRange(0, 30), readonly=False, default=14)
nm = Parameter('noise mode', BoolType(), readonly=False, default=0)
phase = Parameter('Reference phase control', FloatRange(-360, 360, unit='deg'),
readonly=False, default=0)
# convert to enum
vmode = Parameter('Voltage input configuration', IntRange(0, 3), readonly=False, default=3)
# dac = Parameter('output DAC channel value', datatype=TupleOf(IntRange(1, 4), FloatRange(0.0, 5000, unit='mV')),
# readonly=False, initwrite=True, default=(3,0))
# dac = Parameter('output DAC channel value', FloatRange(-10000, 10000, unit='mV'),
# readonly=False, initwrite=True, default=0)
ioClass = SR7270
def comm(self, command):
reply, status, overload = self.communicate(command).split(';')
if overload != '0':
self.status = self.Status.WARN, 'overload %s' % overload
else:
self.status = self.Status.IDLE, ''
return reply
def read_value(self):
reply = self.comm('XY.').split(',')
x = float(reply[0])
y = float(reply[1])
if self.autorange == 1: # soft
if max(abs(x), abs(y)) >= 0.9*self.range and self.irange < 27:
self.write_irange(self.irange+1)
elif max(abs(x), abs(y)) <= 0.3*self.range and self.irange > 1:
self.write_irange(self.irange-1)
self._x.value = x # to update X,Y classes which will be the collected data.
self._y.value = y
return x, y
def read_freq(self):
reply = self.comm('OF.')
return reply
def write_freq(self, value):
self.comm('OF. %g' % value)
return value
def write_autorange(self, value):
if value == 2: # hard
self.comm('AS') # put hardware autorange on
self.comm('AUTOMATIC. 1')
else:
self.comm('AUTOMATIC. 0')
return value
def read_autorange(self):
reply = self.comm('AUTOMATIC')
# determine hardware autorange
if reply == 1: # "hardware auto range is on"
return 2 # hard
if self.autorange == 0: # soft
return self.autorange() # read autorange
return reply # off
# oscillator amplitude module
def read_amp(self):
reply = self.comm('OA.')
return reply
def write_amp(self, value):
self.comm('OA. %g' % value)
return value
# external output DAC
#def read_dac(self):
# # reply = self.comm('DAC %g' % channel) # failed to add the DAC channel you want to control
# reply = self.comm('DAC 3') # stack to channel 3
# return reply
#def write_dac(self, value):
# # self.comm('DAC %g %g' % channel % value)
# self.comm('DAC 3 %g' % value)
# return value
# sensitivity module
def read_range(self):
reply = self.comm('SEN.')
return reply
def write_irange(self, value):
self.comm('SEN %g' % value)
self.read_range()
return value
def read_irange(self):
reply = self.comm('SEN')
return reply
# time constant module/ noisemode off or 0 allows to use all the time constant range
def read_nm(self):
reply = self.comm('NOISEMODE')
return reply
def write_nm(self, value):
self.comm('NOISEMODE %d' % int(value))
self.read_nm()
return value
def read_tc(self):
reply = self.comm('TC.')
return reply
def write_itc(self, value):
self.comm('TC %g' % value)
self.read_tc()
return value
def read_itc(self):
reply = self.comm('TC')
return reply
# phase and autophase
def read_phase(self):
reply = self.comm('REFP.')
return reply
def write_phase(self, value):
self.comm('REFP %d' % round(1000*value, 0))
self.read_phase()
return value
@Command()
def aphase(self):
"""auto phase"""
self.read_phase()
reply = self.comm('AQN')
self.read_phase()
# voltage input configuration 0:grounded,1=A,2=B,3=A-B
# def read_vmode(self):
# reply = self.comm('VMODE')
# return reply
def write_vmode(self, value):
self.comm('VMODE %d' % value)
# self.read_vmode()
return value
class Comp(Readable):
enablePoll = False
value = Parameter(datatype=FloatRange(unit='V'))
class arg(Readable):
enablePoll = False
value = Parameter(datatype=FloatRange(unit=''))

294
frappy_psi/adq_mr.py Normal file
View File

@ -0,0 +1,294 @@
# -*- coding: utf-8 -*-
"""
Created on Tue Nov 26 15:42:43 2019
@author: tartarotti_d-adm
"""
import numpy as np
import ctypes as ct
import time
from numpy import sqrt, arctan2, sin, cos
#from pylab import *
from scipy import signal
#ADQAPI = ct.cdll.LoadLibrary("ADQAPI.dll")
ADQAPI = ct.cdll.LoadLibrary("libadq.so.0")
#For different trigger modes
SW_TRIG = 1
EXT_TRIG_1 = 2 #This external trigger does not work if the level of the trigger is very close to 0.5V. Now we have it close to 3V, and it works
EXT_TRIG_2 = 7
EXT_TRIG_3 = 8
LVL_TRIG = 3
INT_TRIG = 4
LVL_FALLING = 0
LVL_RISING = 1
#samples_per_record=16384
ADQ_TRANSFER_MODE_NORMAL = 0x00
ADQ_CHANNELS_MASK = 0x3
#f_LO = 40
def butter_lowpass(cutoff, sr, order=5):
nyq = 0.5 * sr
normal_cutoff = cutoff / nyq
b, a = signal.butter(order, normal_cutoff, btype = 'low', analog = False)
return b, a
class Adq(object):
max_number_of_channels = 2
samp_freq = 2
#ndecimate = 50 # decimation ratio (2GHz / 40 MHz)
ndecimate = 50
def __init__(self, number_of_records, samples_per_record, bw_cutoff):
self.number_of_records = number_of_records
self.samples_per_record = samples_per_record
self.bw_cutoff = bw_cutoff
ADQAPI.ADQAPI_GetRevision()
# Manually set return type from some ADQAPI functions
ADQAPI.CreateADQControlUnit.restype = ct.c_void_p
ADQAPI.ADQ_GetRevision.restype = ct.c_void_p
ADQAPI.ADQ_GetPtrStream.restype = ct.POINTER(ct.c_int16)
ADQAPI.ADQControlUnit_FindDevices.argtypes = [ct.c_void_p]
# Create ADQControlUnit
self.adq_cu = ct.c_void_p(ADQAPI.CreateADQControlUnit())
ADQAPI.ADQControlUnit_EnableErrorTrace(self.adq_cu, 3, '.')
self.adq_num = 1
# Find ADQ devices
ADQAPI.ADQControlUnit_FindDevices(self.adq_cu)
n_of_ADQ = ADQAPI.ADQControlUnit_NofADQ(self.adq_cu)
if n_of_ADQ != 1:
raise ValueError('number of ADQs must be 1, not %d' % n_of_ADQ)
rev = ADQAPI.ADQ_GetRevision(self.adq_cu, self.adq_num)
revision = ct.cast(rev,ct.POINTER(ct.c_int))
print('\nConnected to ADQ #1')
# Print revision information
print('FPGA Revision: {}'.format(revision[0]))
if (revision[1]):
print('Local copy')
else :
print('SVN Managed')
if (revision[2]):
print('Mixed Revision')
else :
print('SVN Updated')
print('')
ADQ_CLOCK_INT_INTREF = 0 #internal clock source
ADQ_CLOCK_EXT_REF = 1 #internal clock source, external reference
ADQ_CLOCK_EXT_CLOCK = 2 #External clock source
ADQAPI.ADQ_SetClockSource(self.adq_cu, self.adq_num, ADQ_CLOCK_EXT_REF);
##########################
# Test pattern
#ADQAPI.ADQ_SetTestPatternMode(self.adq_cu, self.adq_num, 4)
##########################
# Sample skip
#ADQAPI.ADQ_SetSampleSkip(self.adq_cu, self.adq_num, 1)
##########################
# Set trig mode
self.trigger = EXT_TRIG_1
#trigger = LVL_TRIG
success = ADQAPI.ADQ_SetTriggerMode(self.adq_cu, self.adq_num, self.trigger)
if (success == 0):
print('ADQ_SetTriggerMode failed.')
if (self.trigger == LVL_TRIG):
success = ADQAPI.ADQ_SetLvlTrigLevel(self.adq_cu, self.adq_num, -100)
if (success == 0):
print('ADQ_SetLvlTrigLevel failed.')
success = ADQAPI.ADQ_SetTrigLevelResetValue(self.adq_cu, self.adq_num, 1000)
if (success == 0):
print('ADQ_SetTrigLevelResetValue failed.')
success = ADQAPI.ADQ_SetLvlTrigChannel(self.adq_cu, self.adq_num, 1)
if (success == 0):
print('ADQ_SetLvlTrigChannel failed.')
success = ADQAPI.ADQ_SetLvlTrigEdge(self.adq_cu, self.adq_num, LVL_RISING)
if (success == 0):
print('ADQ_SetLvlTrigEdge failed.')
elif (self.trigger == EXT_TRIG_1) :
success = ADQAPI.ADQ_SetExternTrigEdge(self.adq_cu, self.adq_num,2)
if (success == 0):
print('ADQ_SetLvlTrigEdge failed.')
# success = ADQAPI.ADQ_SetTriggerThresholdVoltage(self.adq_cu, self.adq_num, trigger, ct.c_double(0.2))
# if (success == 0):
# print('SetTriggerThresholdVoltage failed.')
print("CHANNEL:"+str(ct.c_int(ADQAPI.ADQ_GetLvlTrigChannel(self.adq_cu, self.adq_num))))
self.setup_target_buffers()
def setup_target_buffers(self):
# Setup target buffers for data
self.target_buffers=(ct.POINTER(ct.c_int16 * self.samples_per_record * self.number_of_records)
* self.max_number_of_channels)()
for bufp in self.target_buffers:
bufp.contents = (ct.c_int16 * self.samples_per_record * self.number_of_records)()
def deletecu(self):
# Only disarm trigger after data is collected
ADQAPI.ADQ_DisarmTrigger(self.adq_cu, self.adq_num)
ADQAPI.ADQ_MultiRecordClose(self.adq_cu, self.adq_num);
# Delete ADQControlunit
ADQAPI.DeleteADQControlUnit(self.adq_cu)
def start(self):
"""start datat acquisition"""
# samples_per_records = samples_per_record/number_of_records
# Change number of pulses to be acquired acording to how many records are taken
# Start acquisition
ADQAPI.ADQ_MultiRecordSetup(self.adq_cu, self.adq_num,
self.number_of_records,
self.samples_per_record)
ADQAPI.ADQ_DisarmTrigger(self.adq_cu, self.adq_num)
ADQAPI.ADQ_ArmTrigger(self.adq_cu, self.adq_num)
def getdata(self):
"""wait for aquisition to be finished and get data"""
#start = time.time()
while(ADQAPI.ADQ_GetAcquiredAll(self.adq_cu,self.adq_num) == 0):
time.sleep(0.001)
#if (self.trigger == SW_TRIG):
# ADQAPI.ADQ_SWTrig(self.adq_cu, self.adq_num)
#mid = time.time()
status = ADQAPI.ADQ_GetData(self.adq_cu, self.adq_num, self.target_buffers,
self.samples_per_record * self.number_of_records, 2,
0, self.number_of_records, ADQ_CHANNELS_MASK,
0, self.samples_per_record, ADQ_TRANSFER_MODE_NORMAL);
#print(time.time()-mid,mid-start)
if not status:
raise ValueError('no succesS from ADQ_GetDATA')
# Now this is an array with all records, but the time is artificial
data = []
for ch in range(2):
onedim = np.frombuffer(self.target_buffers[ch].contents, dtype=np.int16)
data.append(onedim.reshape(self.number_of_records, self.samples_per_record) / float(2**14)) # 14 bits ADC
return data
def acquire(self):
self.start()
return self.getdata()
'''
def average(self, data):
#Average over records
return [data[ch].sum(axis=0) / self.number_of_records for ch in range(2)]
def iq(self, channel, f_LO):
newx = np.linspace(0, self.samples_per_record /2, self.samples_per_record)
s0 = channel /((2**16)/2)*0.5*np.exp(1j*2*np.pi*f_LO/(1e3)*newx)
I0 = s0.real
Q0 = s0.imag
return I0, Q0
def fitting(self, data, f_LO, ti, tf):
# As long as data[0] is the pulse
si = 2*ti #Those are for fitting the pulse
sf = 2*tf
phase = np.zeros(self.number_of_records)
amplitude = np.zeros(self.number_of_records)
offset = np.zeros(self.number_of_records)
for i in range(self.number_of_records):
phase[i], amplitude[i] = sineW(data[0][i][si:sf],f_LO*1e-9,ti,tf)
offset[i] = np.average(data[0][i][si:sf])
return phase, amplitude, offset
def waveIQ(self, channel,ti,f_LO):
#channel is not the sample data
t = np.linspace(0, self.samples_per_record /2, self.samples_per_record + 1)[:-1]
si = 2*ti # Again that is where the wave pulse starts
cwi = np.zeros((self.number_of_records,self.samples_per_record))
cwq = np.zeros((self.number_of_records,self.samples_per_record))
iq = np.zeros((self.number_of_records,self.samples_per_record))
q = np.zeros((self.number_of_records,self.samples_per_record))
for i in range(self.number_of_records):
cwi[i] = np.zeros(self.samples_per_record)
cwq[i] = np.zeros(self.samples_per_record)
cwi[i] = amplitude[i]*sin(t[si:]*f_LO*1e-9*2*np.pi+phase[i]*np.pi/180)+bias[i]
cwq[i] = amplitude[i]*sin(t[si:]*f_LO*1e-9*(2*np.pi+(phase[i]+90)*np.pi/180))+bias[i]
iq[i] = channel[i]*cwi[i]
q[i] = channel[i]*cwq[i]
return iq,q
'''
def sinW(self,sig,freq,ti,tf):
# sig: signal array
# freq
# ti, tf: initial and end time
si = int(ti * self.samp_freq)
nperiods = freq * (tf - ti)
n = int(round(max(2, int(nperiods)) / nperiods * (tf-ti) * self.samp_freq))
self.nperiods = n
t = np.arange(si, len(sig)) / self.samp_freq
t = t[:n]
self.pulselen = n / self.samp_freq
sig = sig[si:si+n]
a = 2*np.sum(sig*np.cos(2*np.pi*freq*t))/len(sig)
b = 2*np.sum(sig*np.sin(2*np.pi*freq*t))/len(sig)
return a, b
def mix(self, sigin, sigout, freq, ti, tf):
# sigin, sigout: signal array, incomping, output
# freq
# ti, tf: initial and end time if sigin
a, b = self.sinW(sigin, freq, ti, tf)
phase = arctan2(a,b) * 180 / np.pi
amp = sqrt(a**2 + b**2)
a, b = a/amp, b/amp
#si = int(ti * self.samp_freq)
t = np.arange(len(sigout)) / self.samp_freq
wave1 = sigout * (a * cos(2*np.pi*freq*t) + b * sin(2*np.pi*freq*t))
wave2 = sigout * (a * sin(2*np.pi*freq*t) - b * cos(2*np.pi*freq*t))
return wave1, wave2
def averageiq(self, data, freq, ti, tf):
'''Average over records'''
iorq = np.array([self.mix(data[0][i], data[1][i], freq, ti, tf) for i in range(self.number_of_records)])
# iorq = np.array([self.mix(data[0][:], data[1][:], freq, ti, tf)])
return iorq.sum(axis=0) / self.number_of_records
def filtro(self, iorq, cutoff):
b, a = butter_lowpass(cutoff, self.samp_freq*1e9)
#ifi = np.array(signal.filtfilt(b,a,iorq[0]))
#qf = np.array(signal.filtfilt(b,a,iorq[1]))
iqf = [signal.filtfilt(b,a,iorq[i]) for i in np.arange(len(iorq))]
return iqf
def box(self, iorq, ti, tf):
si = int(self.samp_freq * ti)
sf = int(self.samp_freq * tf)
bxa = [sum(iorq[i][si:sf])/(sf-si) for i in np.arange(len(iorq))]
return bxa
def gates_and_curves(self, data, freq, pulse, roi):
"""return iq values of rois and prepare plottable curves for iq"""
times = []
times.append(('aviq', time.time()))
iq = self.averageiq(data,freq*1e-9,*pulse)
times.append(('filtro', time.time()))
iqf = self.filtro(iq,self.bw_cutoff)
m = len(iqf[0]) // self.ndecimate
times.append(('iqdec', time.time()))
iqd = np.average(np.resize(iqf, (2, m, self.ndecimate)), axis=2)
t_axis = np.arange(m) * self.ndecimate / self.samp_freq
pulsig = np.abs(data[0][0])
times.append(('pulsig', time.time()))
pulsig = np.average(np.resize(pulsig, (m, self.ndecimate)), axis=1)
self.curves = (t_axis, iqd[0], iqd[1], pulsig)
#print(times)
return [self.box(iqf,*r) for r in roi]

264
frappy_psi/attocube.py Normal file
View File

@ -0,0 +1,264 @@
# *****************************************************************************
# 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>
# *****************************************************************************
import sys
import time
from frappy.core import Drivable, Parameter, Command, Property, ERROR, WARN, BUSY, IDLE, Done, nopoll
from frappy.features import HasTargetLimits, HasSimpleOffset
from frappy.datatypes import IntRange, FloatRange, StringType, BoolType
from frappy.errors import ConfigError, BadValueError
sys.path.append('/home/l_samenv/Documents/anc350/Linux64/userlib/lib')
from PyANC350v4 import Positioner
DIRECTION_NAME = {1: 'forward', -1: 'backward'}
class FreezeStatus:
"""freeze status for some time
hardware quite often does not treat status correctly: on a target change it
may take some time to return the 'busy' status correctly.
in classes with this mixin, within :meth:`write_target` call
self.freeze_status(0.5, BUSY, 'changed target')
a wrapper around read_status will take care that the status will be the given value,
for at least the given delay. This does NOT cover the case when self.status is set
directly from an other method.
"""
__freeze_status_until = 0
def __init_subclass__(cls):
def wrapped(self, inner=cls.read_status):
if time.time() < self.__freeze_status_until:
return Done
return inner(self)
cls.read_status = wrapped
super().__init_subclass__()
def freeze_status(self, delay, code=BUSY, text='changed target'):
"""freezze status to the given value for the given delay"""
self.__freeze_status_until = time.time() + delay
self.status = code, text
class Axis(HasTargetLimits, FreezeStatus, Drivable):
axis = Property('axis number', IntRange(0, 2), 0)
value = Parameter('axis position', FloatRange(unit='deg'))
frequency = Parameter('frequency', FloatRange(1, unit='Hz'), readonly=False)
amplitude = Parameter('amplitude', FloatRange(0, unit='V'), readonly=False)
gear = Parameter('gear factor', FloatRange(), readonly=False, default=1, initwrite=True)
tolerance = Parameter('positioning tolerance', FloatRange(0, unit='$'), readonly=False, default=0.01)
output = Parameter('enable output', BoolType(), readonly=False)
info = Parameter('axis info', StringType())
statusbits = Parameter('status bits', StringType())
_hw = Positioner()
_scale = 1 # scale for custom units
_move_steps = 0 # number of steps to move (used by move command)
SCALES = {'deg': 1, 'm': 1, 'mm': 1000, 'um': 1000000, 'µm': 1000000}
_direction = 1 # move direction
_idle_status = IDLE, ''
_error_state = '' # empty string: no error
_history = None
_check_sensor = False
_try_count = 0
def __init__(self, name, logger, opts, srv):
unit = opts.pop('unit', 'deg')
opts['value.unit'] = unit
try:
self._scale = self.SCALES[unit] * opts.get('gear', 1)
except KeyError as e:
raise ConfigError('unsupported unit: %s' % unit)
super().__init__(name, logger, opts, srv)
def write_gear(self, value):
self._scale = self.SCALES[self.parameters['value'].datatype.unit] * self.gear
return value
def startModule(self, start_events):
super().startModule(start_events)
start_events.queue(self.read_info)
def check_value(self, value):
"""check if value allows moving in current direction"""
if self._direction > 0:
if value > self.target_limits[1]:
raise BadValueError('above upper limit')
elif value < self.target_limits[0]:
raise BadValueError('below lower limit')
def read_value(self):
pos = self._hw.getPosition(self.axis) * self._scale
if self.isBusy():
try:
self.check_value(pos)
except BadValueError as e:
self._stop()
self._idle_status = ERROR, str(e)
return pos
def read_frequency(self):
return self._hw.getFrequency(self.axis)
def write_frequency(self, value):
self._hw.setFrequency(self.axis, value)
return self._hw.getFrequency(self.axis)
def read_amplitude(self):
return self._hw.getAmplitude(self.axis)
def write_amplitude(self, value):
self._hw.setAmplitude(self.axis, value)
return self._hw.getAmplitude(self.axis)
def write_tolerance(self, value):
self._hw.setTargetRange(self.axis, value / self._scale)
return value
def write_output(self, value):
self._hw.setAxisOutput(self.axis, enable=value, autoDisable=0)
return value
def read_status(self):
statusbits = self._hw.getAxisStatus(self.axis)
sensor, self.output, moving, attarget, eot_fwd, eot_bwd, sensor_error = statusbits
self.statusbits = ''.join((k for k, v in zip('SOMTFBE', statusbits) if v))
if self._move_steps:
if not (eot_fwd or eot_bwd):
return BUSY, 'moving by steps'
if not sensor:
self._error_state = 'no sensor connected'
elif sensor_error:
self._error_state = 'sensor error'
elif eot_fwd:
self._error_state = 'end of travel forward'
elif eot_bwd:
self._error_state = 'end of travel backward'
else:
if self._error_state and not DIRECTION_NAME[self._direction] in self._error_state:
self._error_state = ''
status_text = 'moving' if self._try_count == 0 else 'moving (retry %d)' % self._try_count
if moving and self._history is not None: # history None: moving by steps
self._history.append(self.value)
if len(self._history) < 5:
return BUSY, status_text
beg = self._history.pop(0)
if abs(beg - self.target) < self.tolerance:
# reset normal tolerance
self._stop()
self._idle_status = IDLE, 'in tolerance'
return self._idle_status
# self._hw.setTargetRange(self.axis, self.tolerance / self._scale)
if (self.value - beg) * self._direction > 0:
return BUSY, status_text
self._try_count += 1
if self._try_count < 10:
self.log.warn('no progress retry %d', self._try_count)
return BUSY, status_text
self._idle_status = WARN, 'no progress'
if self._error_state:
self._try_count += 1
if self._try_count < 10 and self._history is not None:
self.log.warn('end of travel retry %d', self._try_count)
self.write_target(self.target)
return Done
self._idle_status = WARN, self._error_state
if self.status[0] != IDLE:
self._stop()
return self._idle_status
def write_target(self, value):
if value == self.read_value():
return value
self.check_limits(value)
self._try_count = 0
self._direction = 1 if value > self.value else -1
# if self._error_state and DIRECTION_NAME[-self._direction] not in self._error_state:
# raise BadValueError('can not move (%s)' % self._error_state)
self._move_steps = 0
self.write_output(1)
# try first with 50 % of tolerance
self._hw.setTargetRange(self.axis, self.tolerance * 0.5 / self._scale)
for itry in range(5):
try:
self._hw.setTargetPosition(self.axis, value / self._scale)
self._hw.startAutoMove(self.axis, enable=1, relative=0)
except Exception as e:
if itry == 4:
raise
self.log.warn('%r', e)
self._history = [self.value]
self._idle_status = IDLE, ''
self.freeze_status(1, BUSY, 'changed target')
self.setFastPoll(True, 1)
return value
def doPoll(self):
if self._move_steps == 0:
super().doPoll()
return
self._hw.startSingleStep(self.axis, self._direction < 0)
self._move_steps -= self._direction
if self._move_steps % int(self.frequency) == 0: # poll value and status every second
super().doPoll()
@nopoll
def read_info(self):
"""read info from controller"""
cap = self._hw.measureCapacitance(self.axis) * 1e9
axistype = ['linear', 'gonio', 'rotator'][self._hw.getActuatorType(self.axis)]
return '%s %s %.3gnF' % (self._hw.getActuatorName(self.axis), axistype, cap)
def _stop(self):
self._move_steps = 0
self._history = None
for _ in range(5):
try:
self._hw.startAutoMove(self.axis, enable=0, relative=0)
break
except Exception as e:
if itry == 4:
raise
self.log.warn('%r', e)
self._hw.setTargetRange(self.axis, self.tolerance / self._scale)
self.setFastPoll(False)
@Command()
def stop(self):
self._idle_status = IDLE, 'stopped' if self.isBusy() else ''
self._stop()
self.status = self._idle_status
@Command(IntRange())
def move(self, value):
"""relative move by number of steps"""
self._direction = 1 if value > 0 else -1
self.check_value(self.value)
self._history = None
if DIRECTION_NAME[self._direction] in self._error_state:
raise BadValueError('can not move (%s)' % self._error_state)
self._move_steps = value
self._idle_status = IDLE, ''
self.read_status()
self.setFastPoll(True, 1/self.frequency)

View File

@ -65,19 +65,22 @@ class ChannelSwitcher(Drivable):
FloatRange(0, None), readonly=False, default=2)
fast_poll = 0.1
_channels = None # dict <channel no> of <module object>
channels = None # dict <channel no> of <module object>
_start_measure = 0
_last_measure = 0
_start_switch = 0
_time_tol = 0.5
_first_channel = None
def earlyInit(self):
super().earlyInit()
self._channels = {}
self.channels = {}
def register_channel(self, mod):
"""register module"""
self._channels[mod.channel] = mod
if not self.channels:
self._first_channel = mod
self.channels[mod.channel] = mod
def set_active_channel(self, chan):
"""tell the HW the active channel
@ -91,7 +94,7 @@ class ChannelSwitcher(Drivable):
def next_channel(self, channelno):
next_channel = channelno
first_channel = None
for ch, mod in self._channels.items():
for ch, mod in self.channels.items():
if mod.enabled:
if first_channel is None:
first_channel = ch
@ -107,7 +110,7 @@ class ChannelSwitcher(Drivable):
def read_status(self):
now = time.monotonic()
if self.status[0] == 'BUSY':
chan = self._channels[self.target]
chan = self.channels[self.target]
if chan.is_switching(now, self._start_switch, self.switch_delay):
return self.status
self.setFastPoll(False)
@ -119,7 +122,7 @@ class ChannelSwitcher(Drivable):
if self.measure_delay > self._time_tol:
return self.status
else:
chan = self._channels[self.value]
chan = self.channels.get(self.value, self._first_channel)
self.read_value() # this might modify autoscan or deadline!
if chan.enabled:
if self.target != self.value: # may happen after startup
@ -144,11 +147,11 @@ class ChannelSwitcher(Drivable):
return value
def write_target(self, channel):
if channel not in self._channels:
if channel not in self.channels:
raise ValueError(f'{channel!r} is no valid channel')
if channel == self.target and self._channels[channel].enabled:
if channel == self.target and self.channels[channel].enabled:
return channel
chan = self._channels[channel]
chan = self.channels[channel]
chan.enabled = True
self.set_active_channel(chan)
self._start_switch = time.monotonic()

View File

@ -21,11 +21,11 @@
# *****************************************************************************
from frappy.core import Parameter, FloatRange, BUSY, IDLE, WARN
from frappy.states import HasStates
from frappy.lib.statemachine import StateMachine, Retry, Stop
from frappy.lib import merge_status
class HasConvergence(HasStates):
class HasConvergence:
"""mixin for convergence checks
Implementation based on tolerance, settling time and timeout.
@ -33,6 +33,8 @@ class HasConvergence(HasStates):
fly. However, the full history is not considered, which means for example
that the spent time inside tolerance stored already is not altered when
changing tolerance.
does not inherit from HasStates (own state machine!)
"""
tolerance = Parameter('absolute tolerance', FloatRange(0, unit='$'), readonly=False, default=0)
settling_time = Parameter(
@ -51,118 +53,131 @@ class HasConvergence(HasStates):
As soon as the value is the first time within tolerance, the timeout criterium is changed:
then the timeout event happens after this time + <settling_time> + <timeout>.
''', FloatRange(0, unit='sec'), readonly=False, default=3600)
status = Parameter('status determined from convergence checks', default=(IDLE, ''))
convergence_state = None
status = Parameter() # make sure status is a parameter
convergence_state = None # the state machine
def earlyInit(self):
super().earlyInit()
self.convergence_state = StateMachine(threaded=False, logger=self.log,
cleanup=self.cleanup, spent_inside=0)
self.convergence_state = StateMachine(
threaded=False, logger=self.log, cleanup=self.convergence_cleanup,
status=(IDLE, ''), spent_inside=0, stop_status=(IDLE, 'stopped'))
def cleanup(self, state):
def convergence_cleanup(self, state):
state.default_cleanup(state)
if state.stopped:
if state.stopped is Stop: # and not Restart
self.status = WARN, 'stopped'
self.__set_status(WARN, 'stopped')
else:
self.status = WARN, repr(state.last_error)
self.__set_status(WARN, repr(state.last_error))
def doPoll(self):
super().doPoll()
state = self.convergence_state
state.cycle()
def get_min_slope(self, dif):
def __set_status(self, *status):
if status != self.convergence_state.status:
self.convergence_state.status = status
self.read_status()
def read_status(self):
return merge_status(super().read_status(), self.convergence_state.status)
#self.log.warn('inner %r conv %r merged %r', super().read_status(), self.convergence_state.status, merged)
#return merged
def convergence_min_slope(self, dif):
"""calculate minimum expected slope"""
slope = getattr(self, 'workingramp', 0) or getattr(self, 'ramp', 0)
if slope or not self.timeout:
return slope
return dif / self.timeout # assume exponential decay of dif, with time constant <tolerance>
def get_dif_tol(self):
value = self.read_value()
def convergence_dif(self):
"""get difference target - value and tolerance"""
tol = self.tolerance
if not tol:
tol = 0.01 * max(abs(self.target), abs(value))
dif = abs(self.target - value)
tol = 0.01 * max(abs(self.target), abs(self.value))
dif = abs(self.target - self.value)
return dif, tol
def start_state(self):
def convergence_start(self):
"""to be called from write_target"""
self.convergence_state.start(self.state_approach)
self.__set_status(BUSY, 'changed_target')
self.convergence_state.start(self.convergence_approach)
def state_approach(self, state):
def convergence_approach(self, state):
"""approaching, checking progress (busy)"""
state.spent_inside = 0
dif, tol = self.get_dif_tol()
dif, tol = self.convergence_dif()
if dif < tol:
state.timeout_base = state.now
return self.state_inside
return self.convergence_inside
if not self.timeout:
return Retry
if state.init:
state.timeout_base = state.now
state.dif_crit = dif # criterium for resetting timeout base
self.status = BUSY, 'approaching'
state.dif_crit -= self.get_min_slope(dif) * state.delta()
self.__set_status(BUSY, 'approaching')
state.dif_crit -= self.convergence_min_slope(dif) * state.delta()
if dif < state.dif_crit: # progress is good: reset timeout base
state.timeout_base = state.now
elif state.now > state.timeout_base + self.timeout:
self.status = WARN, 'convergence timeout'
return self.state_instable
self.__set_status(WARN, 'convergence timeout')
return self.convergence_instable
return Retry
def state_inside(self, state):
def convergence_inside(self, state):
"""inside tolerance, still busy"""
dif, tol = self.get_dif_tol()
dif, tol = self.convergence_dif()
if dif > tol:
return self.state_outside
return self.convergence_outside
state.spent_inside += state.delta()
if state.spent_inside > self.settling_time:
self.status = IDLE, 'reached target'
return self.state_stable
self.__set_status(IDLE, 'reached target')
return self.convergence_stable
if state.init:
self.status = BUSY, 'inside tolerance'
self.__set_status(BUSY, 'inside tolerance')
return Retry
def state_outside(self, state):
def convergence_outside(self, state):
"""temporarely outside tolerance, busy"""
dif, tol = self.get_dif_tol()
dif, tol = self.convergence_dif()
if dif < tol:
return self.state_inside
return self.convergence_inside
if state.now > state.timeout_base + self.settling_time + self.timeout:
self.status = WARN, 'settling timeout'
return self.state_instable
self.__set_status(WARN, 'settling timeout')
return self.convergence_instable
if state.init:
self.status = BUSY, 'outside tolerance'
self.__set_status(BUSY, 'outside tolerance')
# do not reset the settling time on occasional outliers, count backwards instead
state.spent_inside = max(0.0, state.spent_inside - state.delta())
return Retry
def state_stable(self, state):
def convergence_stable(self, state):
"""stable, after settling_time spent within tolerance, idle"""
dif, tol = self.get_dif_tol()
dif, tol = self.convergence_dif()
if dif <= tol:
return Retry
self.status = WARN, 'instable'
self.__set_status(WARN, 'instable')
state.spent_inside = max(self.settling_time, state.spent_inside)
return self.state_instable
return self.convergence_instable
def state_instable(self, state):
def convergence_instable(self, state):
"""went outside tolerance from stable, warning"""
dif, tol = self.get_dif_tol()
dif, tol = self.convergence_dif()
if dif <= tol:
state.spent_inside += state.delta()
if state.spent_inside > self.settling_time:
self.status = IDLE, 'stable' # = recovered from instable
return self.state_stable
self.__set_status(IDLE, 'stable') # = recovered from instable
return self.convergence_stable
else:
state.spent_inside = max(0, state.spent_inside - state.delta())
return Retry
def state_interrupt(self, state):
def convergence_interrupt(self, state):
"""stopping"""
self.status = IDLE, 'stopped' # stop called
return self.state_instable
self.__set_status(state.stop_status) # stop called
return self.convergence_instable
def stop(self):
"""set to idle when busy
@ -170,4 +185,14 @@ class HasConvergence(HasStates):
does not stop control!
"""
if self.isBusy():
self.convergence_state.start(self.state_interrupt)
self.convergence_state.start(self.convergence_interrupt)
def write_settling_time(self, value):
if self.pollInfo:
self.pollInfo.trigger(True)
return value
def write_tolerance(self, value):
if self.pollInfo:
self.pollInfo.trigger(True)
return value

584
frappy_psi/cryoltd.py Normal file
View File

@ -0,0 +1,584 @@
# -*- 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:
# Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
"""flame magnet using cryogenic limited software suite
Remarks: for reading, we use the GetAll command, which is quite fast
(3 ms response time). However, as in many such sloppy programmed software
the timing is badly handled, as after changing parameters, the readback values
are not yet up to date. We use the array block_until for this purpose: any value
changed from the client is fixed for at least 10 seconds.
"""
import re
import time
from frappy.core import HasIO, StringIO, Readable, Drivable, Parameter, Command, \
Module, Property, Attached, Enum, IDLE, BUSY, ERROR, Done
from frappy.errors import ConfigError, BadValueError, HardwareError
from frappy.datatypes import FloatRange, StringType, EnumType, StructOf
from frappy.states import HasStates, status_code, Retry
from frappy.features import HasTargetLimits
import frappy_psi.magfield as magfield
# floating point value followed with unit
VALUE_UNIT = re.compile(r'([-0-9.E]*\d|inf)([A-Za-z/%]*)$')
def as_float(value):
"""converts string (with unit) to float"""
return float(VALUE_UNIT.match(value).group(1))
BOOL_MAP = {'TRUE': True, 'FALSE': False}
def as_bool(value):
return BOOL_MAP[value]
def as_string(value):
return value
class Mapped:
def __init__(self, **kwds):
self.mapping = kwds
self.mapping.update({v: k for k, v in kwds.items()})
def __call__(self, value):
return self.mapping[value]
class IO(StringIO):
identification = [('Get:Remote:Remote_Ready', 'Remote_Ready RECEIVED: TRUE')]
end_of_line = (b'\n', b'\r\n')
timeout = 15
class Main(HasIO, Module):
pollinterval = Parameter('main poll interval', FloatRange(0.001), default=1)
export = False
params_map = None
triggers = None
initpolls = None
ioClass = IO
def register_module(self, obj, **params):
"""register a channel
:param obj: the remote object
:param **params:
pname=<Key in GetAll> or pname=(<Key in Getall>, convert function)
the convert function is as_float by default
"""
if self.params_map is None:
self.params_map = {}
self.triggers = set()
obj.block_until = {}
if hasattr(obj, 'trigger_update'):
self.triggers.add(obj.trigger_update)
for pname, value in params.items():
if isinstance(value, str):
key, cvt = value, as_float
else:
key, cvt = value
self.params_map[key.replace('<CH>', obj.channel)] = obj, pname, cvt
def doPoll(self):
# format of reply:
# "key1:value1;key2:value2;"
reply = self.communicate('GetAll').split('RECEIVED ', 1)[1].split(';')
missing = None, None, None
for line in reply:
try:
key, value = line.split(':', 1)
except ValueError: # missing ':'
if line:
pass
# ignore multiline values
# if needed, we may collect here and treat with a special key
continue
obj, pname, cvt = self.params_map.get(key, missing)
if obj:
if not hasattr(obj, pname):
raise ConfigError('%s has no attribute %s' % (obj.name, pname))
if time.time() > obj.block_until.get(pname, 0):
setattr(obj, pname, cvt(value))
for trigger in self.triggers: # do trigger after updates
trigger()
def communicate(self, cmd):
return self.io.communicate(cmd)
class Channel:
main = Attached(Main, default='main')
channel = Property('channel name', StringType())
pollinterval = Parameter(export=False)
block_until = None
def sendcmd(self, cmd):
cmd = cmd.replace('<CH>', self.channel)
reply = self.main.communicate(cmd)
if not reply.startswith(cmd):
self.log.warn('reply %r does not match command %r', reply, cmd)
def block(self, pname, value=None, delay=10):
self.block_until[pname] = time.time() + delay
if value is not None:
setattr(self, pname, value)
def doPoll(self):
"""poll is done by main module"""
class Temperature(Channel, Readable):
channel = Property('channel name as in reply to GetAll', StringType())
value = Parameter('T sensor value', FloatRange(0, unit='K'))
description = '' # by default take description from channel name
KEYS = {
'1st Stage A',
'2nd Stage A',
'1st Stage B',
'2nd Stage B',
'Inner Magnet A (Top)',
'Inner Magnet A (Bottom)',
'Z Shim Former',
'XY Shim Former',
'XY Vector Former',
'Radiation Shield',
'Persistent Joints',
'Outer Magnet A',
'Inner Magnet B (Top)',
'Inner Magnet B (Bottom)',
'Z Shim Former B',
'Outer Magnet B',
'Bore Radiation Shield',
'XYZ Shim Plate',
'Z Shim Switch',
'Main Coil Switch',
}
def initModule(self):
super().initModule()
if not self.description:
self.description = self.channel
if self.channel not in self.KEYS:
raise ConfigError('channel (%s) must be one of %r' % (self.channel, self.KEYS))
self.main.register_module(self, value=self.channel)
CsMode = Enum(
PERSISTENT=1,
SEMIPERSISTENT=2,
DRIVEN=0,
)
PersistencyMode = Enum(
DISABLED=0,
PERSISTENT=30,
SEMIPERSISTENT=31,
DRIVEN=50,
)
class BaseMagfield(HasStates, HasTargetLimits, Channel):
_status_text = ''
_ready_text = ''
_error_text = ''
_last_error = ''
_rate_units = ''
hw_units = Property('hardware units: A or T', EnumType(A=0, T=1))
A_to_G = Property('A_to_G = "Gauss Value / Amps Value"', FloatRange(0))
tolerance = Parameter('tolerance', FloatRange(0), readonly=False, default=1)
def earlyInit(self):
super().earlyInit()
dt = self.parameters['target'].datatype
if dt.min < 1e-99: # make limits symmetric
dt.min = - dt.max
min_, max_ = self.target_limits
self.target_limits = [max(min_, dt.min), min(max_, dt.max)]
dt = self.parameters['ramp'].datatype
if self.ramp == 0: # unconfigured: take max.
self.ramp = dt.max
if not isinstance(self, magfield.Magfield):
# add unneeded attributes, as the appear in GetAll
self.switch_heater = None
self.current = None
def doPoll(self):
super().doPoll() # does not call cycle_machine
self.cycle_machine()
def to_gauss(self, value):
value, unit = VALUE_UNIT.match(value).groups()
if unit == 'T':
return float(value) * 10000
if unit == 'A':
return float(value) * self.A_to_G
raise HardwareError('received unknown unit: %s' % unit)
def to_gauss_min(self, value):
value, unit = VALUE_UNIT.match(value).groups()
if unit == 'A/s':
return float(value) * self.A_to_G * 60.0
if unit == 'T/s':
return float(value) * 10000 * 60
if unit == 'T/min':
return float(value) * 10000
if unit == 'T/h':
return float(value) * 10000 / 60.0
raise HardwareError('received unknown unit: %s' % unit)
def initModule(self):
super().initModule()
self.main.register_module(
self,
value=('<CH>_Field', self.to_gauss),
_status_text=('<CH>_Status', str),
_ready_text=('<CH>_Ready', str),
_error_text=('<CH>_Error', self.cvt_error),
_rate_units=('<CH>_Rate Units', str),
current=('<CH>_PSU Output', self.to_gauss),
voltage='<CH>_Voltage',
workingramp=('<CH>_Ramp Rate', self.to_gauss_min),
setpoint=('<CH>_Setpoint', self.to_gauss),
switch_heater=('<CH>_Heater', self.cvt_switch_heater),
cs_mode=('<CH>_Persistent Mode', self.cvt_cs_mode),
approach_mode=('<CH>_Approach', self.cvt_approach_mode),
)
def cvt_error(self, text):
if text != self._last_error:
self._last_error = text
self.log.error(text)
return text
return self._error_text
def trigger_update(self):
# called after treating result of GetAll message
if self._error_text:
if self.status[0] == ERROR:
errtxt = self._error_text
else:
errtxt = '%s while %s' % (self._error_text, self.status[1])
# self.stop_machine((ERROR, errtxt))
value = Parameter('magnetic field in the coil', FloatRange(unit='G'))
setpoint = Parameter('setpoint', FloatRange(unit='G'), default=0)
ramp = Parameter()
target = Parameter()
def write_ramp(self, ramp):
if self._rate_units != 'A/s':
self.sendcmd('Set:<CH>:ChangeRateUnits A/s')
self.sendcmd('Set:<CH>:SetRate %g' % (ramp / self.A_to_G / 60.0))
return ramp
def write_target(self, target):
self.reset_error()
super().write_target(target)
return Done
def start_sweep(self, target):
if self.approach_mode == self.approach_mode.OVERSHOOT:
o = self.overshoot['o']
if (target - self.value) * o < 0:
self.write_overshoot(dict(self.overshoot, o=-o))
self.write_ramp(self.ramp)
if self.hw_units == 'A':
self.sendcmd('Set:<CH>:Sweep %gA' % (target / self.A_to_G))
else:
self.sendcmd('Set:<CH>:Sweep %gT' % (target / 10000))
self.block('_ready_text', 'FALSE')
def start_field_change(self, sm):
if self._ready_text == 'FALSE':
self.sendcmd('Set:<CH>:Abort')
return self.wait_ready
return super().start_field_change
def wait_ready(self, sm):
if self._ready_text == 'FALSE':
return Retry
return super().start_field_change
def start_ramp_to_target(self, sm):
self.start_sweep(sm.target)
return self.ramp_to_target # -> stabilize_field
def stabilize_field(self, sm):
if self._ready_text == 'FALSE':
# wait for overshoot/degauss/cycle
sm.stabilize_start = sm.now
return Retry
if sm.now - sm.stabilize_start < self.wait_stable_field:
return Retry
return self.end_stablilize
def end_stablilize(self, sm):
return self.final_status()
cs_mode = Parameter('persistent mode', EnumType(CsMode), readonly=False, default=0)
@staticmethod
def cvt_cs_mode(text):
text = text.lower()
if 'off' in text:
if '0' in text:
return CsMode.PERSISTENT
return CsMode.SEMIPERSISTENT
return CsMode.DRIVEN
def write_cs_mode(self, value):
self.sendcmd('Set:<CH>:SetPM %d' % int(value))
self.block('cs_mode')
return value
ApproachMode = Enum(
DIRECT=0,
OVERSHOOT=1,
CYCLING=2,
DEGAUSSING=3,
)
approach_mode = Parameter('approach mode', EnumType(ApproachMode), readonly=False,
group='approach_settings', default=0)
def write_approach_mode(self, value):
self.sendcmd('Set:<CH>:SetApproach %d' % value)
self.block('approach_mode')
return value
@classmethod
def cvt_approach_mode(cls, text):
return cls.ApproachMode(text.upper())
overshoot = Parameter('overshoot [%] and hold time [s]',
StructOf(o=FloatRange(-100, 100, unit='%'), t=FloatRange(0, unit='s')),
readonly=False, default=dict(o=0, t=0),
group='approach_settings')
def write_overshoot(self, value):
self.sendcmd('Set:<CH>:SetOvershoot %g,%g' % (value['o'], value['t']))
return value
cycle = Parameter('start value, damping factor, final value, hold time',
StructOf(s=FloatRange(-100, 100, unit='%'),
d=FloatRange(0, 100, unit='%'),
a=FloatRange(0, 100, unit='G'),
t=FloatRange(0, unit='s')),
readonly=False, default=dict(s=0, d=0, a=0, t=0),
group='approach_settings')
def write_cycle(self, value):
self.sendcmd('Set:<CH>:SetCycling %g,%g,%g,%g' %
(value['s'], value['d'], value['a'] * 1e-4, value['t']))
return value
degauss = Parameter('start value [G], damping factor [%], accuracy [G], hold time [s]',
StructOf(s=FloatRange(-10000, 10000, unit='G'),
d=FloatRange(0, 100, unit='%'),
f=FloatRange(0, 10000, unit='G'),
t=FloatRange(0, unit='s')),
readonly=False, default=dict(s=0, d=0, f=0, t=0),
group='approach_settings')
def write_degauss(self, value):
self.sendcmd('Set:<CH>:SetDegaussing %g,%g,%g,%g' %
(value['s'] * 1e-4, value['d'], value['f'] * 1e-4, value['t']))
return value
voltage = Parameter(
'voltage over leads', FloatRange(unit='V'))
@staticmethod
def cvt_switch_heater(text):
return 'ON' in text
@Command()
def stop(self):
self.sendcmd('Set:<CH>:Abort')
@Command()
def reset_quench(self):
"""reset quench condition"""
self.sendcmd('Set:<CH>:ResetQuench')
@Command()
def reset_error(self):
"""reset error"""
self._error_text = ''
class MainField(BaseMagfield, magfield.Magfield):
checked_modules = None
def earlyInit(self):
super().earlyInit()
self.checked_modules = []
def check_limits(self, value):
super().check_limits(value)
self.check_combined(None, 0, value)
# TODO: turn into a property
constraint = Parameter('product check', FloatRange(unit='G^2'), default=80000)
def check_combined(self, obj, value, main_target):
sumvalue2 = sum(((value ** 2 if o == obj else o.value ** 2)
for o in self.checked_modules))
if sumvalue2 * max(self.value ** 2, main_target ** 2) > self.constraint ** 2:
raise BadValueError('outside constraint (B * Bxyz > %g G^2' % self.constraint)
def check_limits(self, value):
super().check_limits(value)
self.check_combined(None, 0, value)
mode = Parameter(datatype=EnumType(PersistencyMode))
def write_mode(self, mode):
self.reset_error()
super().write_mode(mode) # updates mode
return Done
@status_code('PREPARING')
def start_ramp_to_field(self, sm):
self.start_sweep(self.value)
return self.ramp_to_field # -> stabilize_current -> start_switch_on
@status_code('PREPARING')
def start_switch_on(self, sm):
self.write_cs_mode(CsMode.DRIVEN)
# self.block('switch_heater', 1, 60)
self.start_sweep(self.value)
self.block('_ready_text', 'FALSE')
return self.wait_for_switch_on # -> start_ramp_to_target -> ramp_to_target -> stabilize_field
@status_code('PREPARING')
def wait_for_switch_on(self, sm):
if self._ready_text == 'FALSE':
return Retry
self.last_target(sm.target)
return self.start_ramp_to_target
def end_stablilize(self, sm):
return self.start_switch_off
@status_code('FINALIZING')
def start_switch_off(self, sm):
if self.mode == PersistencyMode.DRIVEN:
return self.final_status(IDLE, 'driven')
if self.mode == PersistencyMode.SEMIPERSISTENT:
self.write_cs_mode(CsMode.SEMIPERSISTENT)
else: # PERSISTENT or DISABLED
self.write_cs_mode(CsMode.PERSISTENT)
self.start_sweep(sm.target)
self.block('_ready_text', 'FALSE')
self.block('switch_heater', 1)
return self.wait_for_switch_off # -> start_ramp_to_zero
@status_code('PREPARING')
def wait_for_switch_off(self, sm):
if self.switch_heater:
return Retry
self.last_target(sm.target)
if self.mode == PersistencyMode.SEMIPERSISTENT:
return self.final_status(IDLE, 'semipersistent')
return self.ramp_to_zero
@status_code('FINALIZING')
def ramp_to_zero(self, sm):
if self._ready_text == 'FALSE':
return Retry
if self.mode == PersistencyMode.DISABLED:
return self.final_status(PersistencyMode.DISABLED, 'disabled')
return self.final_status(IDLE, 'persistent')
class ComponentField(BaseMagfield, magfield.SimpleMagfield):
check_against = Attached(MainField)
# status = Parameter(datatype=EnumType(Drivable.Status, RAMPING=370, STABILIZING=380, FINALIZING=390))
def initModule(self):
super().initModule()
self.check_against.checked_modules.append(self)
def check_limits(self, value):
super().check_limits(value)
self.check_against.check_combined(self, value, 0)
class Compressor(Channel, Drivable):
def initModule(self):
super().initModule()
self.main.register_module(
self,
value=('Compressor<CH>_Status', self.cvt_value),
_ready_text=('Compressor<CH>_Ready', str),
_error_text=('Compressor<CH>_Error', str),
run_time='Compressor<CH>_RunTime',
)
# TODO: what is Compressor_Error? (without A or B)
value = Parameter('compressor switch', EnumType(OFF=0, ON=1))
run_time = Parameter('run time', FloatRange(0, unit='h'))
_status_text = ''
_ready_text = ''
_error_text = ''
def cvt_value(self, text):
self._status_text = text
value = text == 'Running'
if time.time() > self.block_until.get('target', 0):
self.target = value
return value
def read_value(self):
return self._status_text == 'Running'
def read_status(self):
if self.target != self.value:
return BUSY, 'switching %s' % self.target.name
# TODO: find out possible status texts
if self._ready_text == 'TRUE':
return IDLE, 'ready'
if self._error_text:
return ERROR, self._error_text
return IDLE, self._status_text
target = Parameter('compressor switch', EnumType(OFF=0, ON=1))
def write_target(self, value):
if value:
self.sendcmd('Set:Compressor:Start <CH>')
else:
self.sendcmd('Set:Compressor:Stop <CH>')
self.block('target')
self.read_status()
return value
@Command()
def reset_error(self):
"""reset error"""
self.sendcmd('Set:Compressor:Reset <CH>')
self._error_text = ''

144
frappy_psi/cryotel.py Normal file
View File

@ -0,0 +1,144 @@
# -*- 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:
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""driver for cryotel stirling cryocooler"""
from frappy.core import Command, EnumType, FloatRange, HasIO, Parameter, Drivable, StringIO, StringType
from frappy.errors import CommunicationFailedError, HardwareError
class CryotelIO(StringIO):
end_of_line = ('\n', '\r')
# eol_write will be given explicitly
@Command(StringType(), result=StringType())
def communicate(self, command):
"""send a command and receive a reply
we receive an echo line first.
in case the command does not contain '=', the effective reply is in a second line
"""
with self._lock:
echo = super().communicate(command)
if echo.strip() != command.strip():
raise CommunicationFailedError('missing echo')
if '=' in command:
reply = echo
else:
reply = self._conn.readline().decode()
return reply
class Cryo(HasIO, Drivable):
value = Parameter('current temperature', FloatRange(unit='deg'))
target = Parameter('target temperature', FloatRange(unit='deg'), readonly=False)
mode = Parameter('control mode', EnumType('mode', off=0, power=1, temperature=2), readonly=False)
power = Parameter('power', FloatRange(unit='W'))
setpower = Parameter('requested power', FloatRange(unit='W'), default=0)
cool_power = Parameter('power for cooling', FloatRange(unit='W'), default=240, readonly=False)
hold_power = Parameter('power for holding T', FloatRange(unit='W'), default=120, readonly=False)
cool_threshold = Parameter('switch to cool_power once above this value', FloatRange(unit='K'), default=100, readonly=False)
hold_threshold = Parameter('switch to hold_power once below this value', FloatRange(unit='K'), default=95, readonly=False)
ioClass = CryotelIO
cnt_inside = 0
def get(self, cmd):
return float(self.communicate(cmd))
def set(self, cmd, value, check=False):
setcmd = '%s=%.2f' % (cmd, value)
self.communicate(setcmd)
reply = float(self.communicate(cmd))
if check:
if value != reply:
raise HardwareError('illegal reply from %s: %g' % (cmd, reply))
return reply
def read_value(self):
temp = self.get('TC')
status = self.status
if self.mode == 1: # P reg
setpower = self.setpower
if temp < self.hold_threshold:
setpower = self.hold_power
status = self.Status.IDLE, 'holding'
elif temp > self.cool_threshold:
setpower = self.cool_power
status = self.Status.BUSY, 'cooling'
if abs(setpower - self.setpower) > 0.009:
self.setpower = self.set('SET PWOUT', setpower)
elif self.mode == 2: # T reg
if self.status[0] == 'BUSY':
if abs(temp - self.target) < 1:
self.cnt_inside += 1
if self.cnt_inside >= 10:
status = self.Status.IDLE, ''
else:
status = self.Status.BUSY, 'settling'
else:
status = self.Status.BUSY, 'outside tolerance'
else:
status = self.Status.IDLE, 'off'
if status != self.status:
self.status = status
return temp
def read_target(self):
return self.get('SET TTARGET')
def write_target(self, value):
if abs(value - self.target) > 0.009:
self.status = self.Status.BUSY, 'changed target'
self.cnt_inside = 0
return self.set('SET TTARGET', value)
def read_power(self):
return self.get('P')
def read_setpower(self):
return self.get('SET PWOUT')
def read_mode(self):
is_stopped = self.get('SET SSTOP')
if is_stopped:
return 0 # off
pidmode = self.get('SET PID')
if pidmode == 2:
return 2 # T reg
return 1 # P reg
def write_mode(self, value):
if value == 0:
self.set('SET SSTOP', 1, check=True)
self.status = self.Status.IDLE, 'off'
return value
is_stopped = self.get('SET SSTOP')
if is_stopped:
self.set('SET SSTOP', 0, check=True)
if value == 1:
self.set('SET PID', 0, check=True)
self.read_value()
else:
self.set('SET PID', 2, check=True)
self.write_target(self.target)
return value

62
frappy_psi/dg645.py Normal file
View File

@ -0,0 +1,62 @@
#!/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
# 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>
# *****************************************************************************
"""Delay generator stanford 645"""
from frappy.core import FloatRange, HasIO, Module, Parameter, StringIO
class DG645(StringIO):
end_of_line = '\n'
class Delay(HasIO, Module):
on1 = Parameter('on delay 1', FloatRange(unit='sec'), readonly=False, default=0)
off1 = Parameter('off delay 1', FloatRange(unit='sec'), readonly=False, default=60e-9)
on2 = Parameter('on delay 2', FloatRange(unit='sec'), readonly=False, default=0)
off2 = Parameter('off delay 2', FloatRange(unit='sec'), readonly=False, default=150e-9)
ioClass = DG645
def read_on1(self):
return float(self.communicate('DLAY?2').split(',')[1])
def read_off1(self):
return float(self.communicate('DLAY?3').split(',')[1])
def read_on2(self):
return float(self.communicate('DLAY?4').split(',')[1])
def read_off2(self):
return float(self.communicate('DLAY?5').split(',')[1])
def write_on1(self, value):
return float(self.communicate('DLAY 2,0,%g;DLAY?2' % value).split(',')[1])
def write_off1(self, value):
result = self.communicate('DLAY 3,0,%g;DLAY?3' % value)
return float(result.split(',')[1])
def write_on2(self, value):
return float(self.communicate('DLAY 4,0,%g;DLAY?4' % value).split(',')[1])
def write_off2(self, value):
return float(self.communicate('DLAY 5,0,%g;DLAY?5' % value).split(',')[1])

97
frappy_psi/dilsc.py Normal file
View File

@ -0,0 +1,97 @@
# -*- 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:
# Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
"""vector field"""
from frappy.core import Drivable, Done, BUSY, IDLE, WARN, ERROR
from frappy.errors import BadValueError
from frappy_psi.vector import Vector
DECREASE = 1
INCREASE = 2
class VectorField(Vector, Drivable):
_state = None
def doPoll(self):
"""periodically called method"""
try:
if self._starting:
# first decrease components
driving = False
for target, component in zip(self.target, self.components):
if target * component.value < 0:
# change sign: drive to zero first
target = 0
if abs(target) < abs(component.target):
if target != component.target:
component.write_target(target)
if component.isDriving():
driving = True
if driving:
return
# now we can go to the final targets
for target, component in zip(self.target, self.components):
component.write_target(target)
self._starting = False
else:
for component in self.components:
if component.isDriving():
return
self.setFastPoll(False)
finally:
super().doPoll()
def merge_status(self):
names = [c.name for c in self.components if c.status[0] >= ERROR]
if names:
return ERROR, 'error in %s' % ', '.join(names)
names = [c.name for c in self.components if c.isDriving()]
if self._state:
# self.log.info('merge %r', [c.status for c in self.components])
if names:
direction = 'down ' if self._state == DECREASE else ''
return BUSY, 'ramping %s%s' % (direction, ', '.join(names))
if self.status[0] == BUSY:
return self.status
return BUSY, 'driving'
if names:
return WARN, 'moving %s directly' % ', '.join(names)
names = [c.name for c in self.components if c.status[0] >= WARN]
if names:
return WARN, 'warnings in %s' % ', '.join(names)
return IDLE, ''
def write_target(self, value):
"""initiate target change"""
# first make sure target is valid
for target, component in zip(self.target, self.components):
# check against limits if individual components
component.check_limits(target)
if sum(v * v for v in value) > 1:
raise BadValueError('norm of vector too high')
self.log.info('decrease')
self.setFastPoll(True)
self.target = value
self._state = DECREASE
self.doPoll()
self.log.info('done write_target %r', value)
return Done

125
frappy_psi/dpm.py Normal file
View File

@ -0,0 +1,125 @@
# -*- 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:
# M. Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""transducer DPM3 read out"""
from frappy.core import Readable, Parameter, FloatRange, StringIO,\
HasIO, IntRange, Done
class DPM3IO(StringIO):
end_of_line = '\r'
timeout = 3
identification = [('*1R135', '01')]
def hex2float(hexvalue, digits):
value = int(hexvalue, 16)
if value >= 0x800000:
value -= 0x1000000
return value / (10 ** digits)
def float2hex(value, digits):
intvalue = int(round(value * 10 ** digits,0))
if intvalue < 0:
intvalue += 0x1000000
return '%06X' % intvalue
class DPM3(HasIO, Readable):
OFFSET = 0x8f
SCALE = 0x8c
MAGNITUDE = {'1': 1, '2': 10, '3': 100, '4': 1e3, '5': 1e4, '6': 1e5,
'9': -1, 'A': -10, 'B': -100, 'C': -1e3, 'D': -1e4, 'E': -1e5}
ioClass = DPM3IO
value = Parameter(datatype=FloatRange(unit='N'))
digits = Parameter('number of digits for value', IntRange(0, 5), initwrite=True, readonly=False)
# Note: we have to treat the units properly.
# We got an output of 150 for 10N. The maximal force we want to deal with is 100N,
# thus a maximal output of 1500. 10=150/f
offset = Parameter('', FloatRange(-1e5, 1e5), readonly=False)
scale_factor = Parameter('', FloatRange(-1e5, 1e5, unit='input_units/N'), readonly=False)
def query(self, adr, value=None):
if value is not None:
if adr == self.SCALE:
absval = abs(value)
for nibble, mag in self.MAGNITUDE.items():
if 10000 <= round(value * mag, 0) <= 99999:
break
else:
# no suitable range found
if absval >= 99999.5: # overrange
raise ValueError('%s is out of range' % value)
# underrange: take lowest
nibble = '9' if value < 0 else '1'
mag = self.MAGNITUDE[nibble]
hex_val = nibble + '%05X' % int(round(value * mag, 0))
if hex_val[1:] == '00000':
raise ValueError('scale factor can not be 0', value)
else:
hex_val = float2hex(value, self.digits)
cmd = '*1F3%02X%s\r' % (adr, hex_val)
else:
cmd = ""
cmd = cmd + '*1G3%02X' % adr
hexvalue = self.communicate(cmd)
if adr == self.SCALE:
mag = self.MAGNITUDE[hexvalue[0:1]]
value = int(hexvalue[1:], 16)
return value/mag
else:
return hex2float(hexvalue, self.digits)
def write_digits(self, value):
# value defines the number of digits
back_value = self.communicate('*1F135%02X\r*1G135' % (value + 1))
self.digits = int(back_value, 16) - 1
# recalculate proper scale and offset
self.write_scale_factor(self.scale_factor)
self.write_offset(self.offset)
return Done
def read_digits(self):
back_value = self.communicate('*1G135')
return int(back_value,16) - 1
def read_value(self):
return float(self.communicate('*1B1'))
def read_offset(self):
reply = self.query(self.OFFSET)
return reply
def write_offset(self, value):
return self.query(self.OFFSET, value)
def read_scale_factor(self):
reply = self.query(self.SCALE)
return float(reply) / 10 ** self.digits
def write_scale_factor(self, value):
reply = self.query(self.SCALE, value * 10 ** self.digits)
return float(reply) / 10 ** self.digits

View File

@ -31,7 +31,7 @@ def make_cvt_list(dt, tail=''):
tail is a postfix to be appended in case of tuples and structs
"""
if isinstance(dt, (EnumType, IntRange, BoolType)):
return[(int, tail, {type: 'NUM'})]
return[(int, tail, {'type': 'NUM'})]
if isinstance(dt, (FloatRange, ScaledInteger)):
return [(dt.import_value, tail,
{'type': 'NUM', 'unit': dt.unit, 'period': 5} if dt.unit else {})]

340
frappy_psi/ips_mercury.py Normal file
View File

@ -0,0 +1,340 @@
#!/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
# 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>
# *****************************************************************************
"""oxford instruments mercury IPS power supply"""
import time
from frappy.core import Parameter, EnumType, FloatRange, BoolType, IntRange, Property, Module
from frappy.lib.enum import Enum
from frappy.errors import BadValueError, HardwareError
from frappy_psi.magfield import Magfield, SimpleMagfield, Status
from frappy_psi.mercury import MercuryChannel, off_on, Mapped
from frappy.states import Retry
Action = Enum(hold=0, run_to_set=1, run_to_zero=2, clamped=3)
hold_rtoz_rtos_clmp = Mapped(HOLD=Action.hold, RTOS=Action.run_to_set,
RTOZ=Action.run_to_zero, CLMP=Action.clamped)
CURRENT_CHECK_SIZE = 2
class SimpleField(MercuryChannel, SimpleMagfield):
nunits = Property('number of IPS subunits', IntRange(1, 6), default=1)
action = Parameter('action', EnumType(Action), readonly=False)
setpoint = Parameter('field setpoint', FloatRange(unit='T'), default=0)
voltage = Parameter('leads voltage', FloatRange(unit='V'), default=0)
atob = Parameter('field to amp', FloatRange(0, unit='A/T'), default=0)
working_ramp = Parameter('effective ramp', FloatRange(0, unit='T/min'), default=0)
kind = 'PSU'
slave_currents = None
classdict = {}
def __new__(cls, name, logger, cfgdict, srv): # pylint: disable=arguments-differ
nunits = cfgdict.get('nunits', 1)
if isinstance(nunits, dict):
nunits = nunits['value']
if nunits == 1:
return Module.__new__(cls, name, logger, cfgdict, srv)
classname = cls.__name__ + str(nunits)
newclass = cls.classdict.get(classname)
if not newclass:
# create individual current and voltage parameters dynamically
attrs = {}
for i in range(1, nunits + 1):
attrs['I%d' % i] = Parameter('slave %s current' % i, FloatRange(unit='A'), default=0)
attrs['V%d' % i] = Parameter('slave %s voltage' % i, FloatRange(unit='V'), default=0)
newclass = type(classname, (cls,), attrs)
cls.classdict[classname] = newclass
return Module.__new__(newclass, name, logger, cfgdict, srv)
def initModule(self):
super().initModule()
try:
self.write_action(Action.hold)
except Exception as e:
self.log.error('can not set to hold %r', e)
def read_value(self):
return self.query('DEV::PSU:SIG:FLD')
def read_ramp(self):
return self.query('DEV::PSU:SIG:RFST')
def write_ramp(self, value):
return self.change('DEV::PSU:SIG:RFST', value)
def read_action(self):
return self.query('DEV::PSU:ACTN', hold_rtoz_rtos_clmp)
def write_action(self, value):
return self.change('DEV::PSU:ACTN', value, hold_rtoz_rtos_clmp)
def read_atob(self):
return self.query('DEV::PSU:ATOB')
def read_voltage(self):
return self.query('DEV::PSU:SIG:VOLT')
def read_working_ramp(self):
return self.query('DEV::PSU:SIG:RFLD')
def read_setpoint(self):
return self.query('DEV::PSU:SIG:FSET')
def set_and_go(self, value):
self.setpoint = self.change('DEV::PSU:SIG:FSET', value)
assert self.write_action(Action.hold) == Action.hold
assert self.write_action(Action.run_to_set) == Action.run_to_set
def start_ramp_to_target(self, sm):
# if self.action != Action.hold:
# assert self.write_action(Action.hold) == Action.hold
# return Retry
self.set_and_go(sm.target)
sm.try_cnt = 5
return self.ramp_to_target
def ramp_to_target(self, sm):
try:
return super().ramp_to_target(sm)
except HardwareError:
sm.try_cnt -= 1
if sm.try_cnt < 0:
raise
self.set_and_go(sm.target)
return Retry
def final_status(self, *args, **kwds):
self.write_action(Action.hold)
return super().final_status(*args, **kwds)
def on_restart(self, sm):
self.write_action(Action.hold)
return super().on_restart(sm)
class Field(SimpleField, Magfield):
persistent_field = Parameter(
'persistent field at last switch off', FloatRange(unit='$'), readonly=False)
wait_switch_on = Parameter(
'wait time to ensure switch is on', FloatRange(0, unit='s'), readonly=True, default=60)
wait_switch_off = Parameter(
'wait time to ensure switch is off', FloatRange(0, unit='s'), readonly=True, default=60)
forced_persistent_field = Parameter(
'manual indication that persistent field is bad', BoolType(), readonly=False, default=False)
_field_mismatch = None
__persistent_field = None # internal value of persistent field
__switch_fixed_until = 0
def doPoll(self):
super().doPoll()
self.read_current()
def startModule(self, start_events):
# on restart, assume switch is changed long time ago, if not, the mercury
# will complain and this will be handled in start_ramp_to_field
self.switch_on_time = 0
self.switch_off_time = 0
self.switch_heater = self.query('DEV::PSU:SIG:SWHT', off_on)
super().startModule(start_events)
def read_value(self):
current = self.query('DEV::PSU:SIG:FLD')
if self.switch_heater == self.switch_heater.on:
self.__persistent_field = current
self.forced_persistent_field = False
return current
pf = self.query('DEV::PSU:SIG:PFLD')
if self.__persistent_field is None:
self.__persistent_field = pf
self._field_mismatch = False
else:
self._field_mismatch = abs(self.__persistent_field - pf) > self.tolerance * 10
self.persistent_field = self.__persistent_field
return self.__persistent_field
def _check_adr(self, adr):
"""avoid complains about bad slot"""
if adr.startswith('DEV:PSU.M'):
return
super()._check_adr(adr)
def read_current(self):
if self.slave_currents is None:
self.slave_currents = [[] for _ in range(self.nunits + 1)]
if self.nunits > 1:
for i in range(1, self.nunits + 1):
curri = self.query(f'DEV:PSU.M{i}:PSU:SIG:CURR')
volti = self.query(f'DEV:PSU.M{i}:PSU:SIG:VOLT')
setattr(self, f'I{i}', curri)
setattr(self, f'V{i}', volti)
self.slave_currents[i].append(curri)
current = self.query('DEV::PSU:SIG:CURR')
self.slave_currents[0].append(current)
min_ = min(self.slave_currents[0]) / self.nunits
max_ = max(self.slave_currents[0]) / self.nunits
# keep one element more for the total current (first and last measurement is a total)
self.slave_currents[0] = self.slave_currents[0][-CURRENT_CHECK_SIZE-1:]
for i in range(1, self.nunits + 1):
min_i = min(self.slave_currents[i])
max_i = max(self.slave_currents[i])
if len(self.slave_currents[i]) > CURRENT_CHECK_SIZE:
self.slave_currents[i] = self.slave_currents[i][-CURRENT_CHECK_SIZE:]
if min_i - 0.1 > max_ or min_ > max_i + 0.1: # use an arbitrary 0.1 A tolerance
self.log.warning('individual currents mismatch %r', self.slave_currents)
else:
current = self.query('DEV::PSU:SIG:CURR')
if self.atob:
return current / self.atob
return 0
def write_persistent_field(self, value):
if self.forced_persistent_field or abs(self.__persistent_field - value) <= self.tolerance * 10:
self._field_mismatch = False
self.__persistent_field = value
return value
raise BadValueError('changing persistent field needs forced_persistent_field=True')
def write_target(self, target):
if self._field_mismatch:
self.forced_persistent_field = True
raise BadValueError('persistent field does not match - set persistent field to guessed value first')
return super().write_target(target)
def read_switch_heater(self):
value = self.query('DEV::PSU:SIG:SWHT', off_on)
now = time.time()
if value != self.switch_heater:
if now < self.__switch_fixed_until:
self.log.debug('correct fixed switch time')
# probably switch heater was changed, but IPS reply is not yet updated
if self.switch_heater:
self.switch_on_time = time.time()
else:
self.switch_off_time = time.time()
return self.switch_heater
return value
def read_wait_switch_on(self):
return self.query('DEV::PSU:SWONT') * 0.001
def read_wait_switch_off(self):
return self.query('DEV::PSU:SWOFT') * 0.001
def write_switch_heater(self, value):
if value == self.read_switch_heater():
self.log.info('switch heater already %r', value)
# we do not want to restart the timer
return value
self.__switch_fixed_until = time.time() + 10
self.log.debug('switch time fixed for 10 sec')
result = self.change('DEV::PSU:SIG:SWHT', value, off_on, n_retry=0) # no readback check
return result
def start_ramp_to_field(self, sm):
if abs(self.current - self.__persistent_field) <= self.tolerance:
self.log.info('leads %g are already at %g', self.current, self.__persistent_field)
return self.ramp_to_field
try:
self.set_and_go(self.__persistent_field)
except (HardwareError, AssertionError) as e:
if self.switch_heater:
self.log.warn('switch is already on!')
return self.ramp_to_field
self.log.warn('wait first for switch off current=%g pf=%g %r', self.current, self.__persistent_field, e)
sm.after_wait = self.ramp_to_field
return self.wait_for_switch
return self.ramp_to_field
def start_ramp_to_target(self, sm):
sm.try_cnt = 5
try:
self.set_and_go(sm.target)
except (HardwareError, AssertionError) as e:
self.log.warn('switch not yet ready %r', e)
self.status = Status.PREPARING, 'wait for switch on'
sm.after_wait = self.ramp_to_target
return self.wait_for_switch
return self.ramp_to_target
def ramp_to_field(self, sm):
try:
return super().ramp_to_field(sm)
except HardwareError:
sm.try_cnt -= 1
if sm.try_cnt < 0:
raise
self.set_and_go(self.__persistent_field)
return Retry
def wait_for_switch(self, sm):
if not sm.delta(10):
return Retry
try:
self.log.warn('try again')
# try again
self.set_and_go(self.__persistent_field)
except (HardwareError, AssertionError):
return Retry
return sm.after_wait
def wait_for_switch_on(self, sm):
self.read_switch_heater() # trigger switch_on/off_time
if self.switch_heater == self.switch_heater.off:
if sm.init: # avoid too many states chained
return Retry
self.log.warning('switch turned off manually?')
return self.start_switch_on
return super().wait_for_switch_on(sm)
def wait_for_switch_off(self, sm):
self.read_switch_heater()
if self.switch_heater == self.switch_heater.on:
if sm.init: # avoid too many states chained
return Retry
self.log.warning('switch turned on manually?')
return self.start_switch_off
return super().wait_for_switch_off(sm)
def start_ramp_to_zero(self, sm):
pf = self.query('DEV::PSU:SIG:PFLD')
if abs(pf - self.value) > self.tolerance * 10:
self.log.warning('persistent field %g does not match %g after switch off', pf, self.value)
try:
assert self.write_action(Action.hold) == Action.hold
assert self.write_action(Action.run_to_zero) == Action.run_to_zero
except (HardwareError, AssertionError) as e:
self.log.warn('switch not yet ready %r', e)
self.status = Status.PREPARING, 'wait for switch off'
sm.after_wait = self.ramp_to_zero
return self.wait_for_switch
return self.ramp_to_zero
def ramp_to_zero(self, sm):
try:
return super().ramp_to_zero(sm)
except HardwareError:
sm.try_cnt -= 1
if sm.try_cnt < 0:
raise
assert self.write_action(Action.hold) == Action.hold
assert self.write_action(Action.run_to_zero) == Action.run_to_zero
return Retry

102
frappy_psi/iqplot.py Normal file
View File

@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
"""
Created on Tue Feb 4 11:07:56 2020
@author: tartarotti_d-adm
"""
import numpy as np
import matplotlib.pyplot as plt
def rect(x1, x2, y1, y2):
return np.array([[x1,x2,x2,x1,x1],[y1,y1,y2,y2,y1]])
NAN = float('nan')
def rects(intervals, y12):
result = [rect(*intervals[0], *y12)]
for x12 in intervals[1:]:
result.append([[NAN],[NAN]])
result.append(rect(*x12, *y12))
return np.concatenate(result, axis=1)
class Plot:
def __init__(self, maxy):
self.lines = {}
self.yaxis = ((-2 * maxy, maxy), (-maxy, 2 * maxy))
self.first = True
self.fig = None
def set_line(self, iax, name, data, fmt, **kwds):
"""
plot or update a line
when called with self.first = True: plot the line
when called with self.first = False: update the line
iax: 0: left, 1: right yaxis
name: the name of the line. used also as label for legend, if not starting with underscore
data: data[0] and data[1] are used for x/y data respectively
fmt, other keywords: forwarded to <axis>.plot
"""
# ax: 0: left, 1: right
if self.first:
if name.startswith('_'):
label = '_nolegend_'
else:
label = name
self.lines[name], = self.ax[iax].plot(data[0], data[1], fmt, label=label, **kwds)
else:
self.lines[name].set_data(data[0:2])
def close(self):
if self.fig:
plt.close(self.fig)
self.fig = None
self.first = True
def plot(self, curves, rois=None, average=None):
boxes = rects(rois[1:], self.yaxis[0])
pbox = rect(*rois[0], *self.yaxis[1])
rbox = rect(*rois[1], *self.yaxis[0])
pshift = self.yaxis[0][1] * 0.5
pulse = curves[3] - pshift
# normalized to 0.8 * pshift:
#pulse = (curves[3] / np.max(curves[3]))* pshift * 0.8 - pshift
try:
if self.first:
plt.ion()
self.fig, axleft = plt.subplots(figsize=(15,7))
plt.title("I/Q", fontsize=14)
axleft.set_xlim(0, curves[0][-1])
self.ax = [axleft, axleft.twinx()]
self.ax[0].axhline(y=0, color='#cccccc') # show x-axis line
self.ax[1].axhline(y=0, color='#cccccc')
self.ax[0].set_ylim(*self.yaxis[0])
self.ax[1].set_ylim(*self.yaxis[1])
self.set_line(0, "I", curves, 'b-') # using curves [0] and [1]
self.set_line(0, "_Iaverage", average, 'b.')
self.set_line(0, "Ampl", (curves[0],np.sqrt(curves[1]**2+curves[2]**2)), '#808080')
self.set_line(1, "Q", (curves[0], curves[2]), 'g-')
self.set_line(1, "_Qaverage", (average[0], average[2]), 'g.')
self.set_line(0, "pulse", (curves[0], pulse), 'c-')
self.set_line(0, "roi's", boxes, 'm-')
self.set_line(1, "pulse reg", pbox, 'k-')
self.set_line(0, "ctrl reg", rbox, 'r-')
if self.first:
self.fig.legend(fontsize=12)
plt.tight_layout()
finally:
self.first = False
plt.draw()
self.fig.canvas.draw()
self.fig.canvas.flush_events()

105
frappy_psi/ls240.py Normal file
View File

@ -0,0 +1,105 @@
#!/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
# 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>
# *****************************************************************************
"""LakeShore 240 temperature monitor
experimental, does not really work
"""
import struct
from frappy.core import FloatRange, HasIodev, Readable, Parameter, BytesIO
from frappy.errors import CommunicationFailedError
SD1= 0x10
SD2= 0x68
FC = 0x49
ED = 0x16
STATUS_REQ = 0x49
def dehex(msg):
return bytes(int(b, 16) for b in msg.split())
def hexify(msg):
return ' '.join('%2.2X' % b for b in msg)
class Ls240(HasIodev, Readable):
value = Parameter('sensor reading', FloatRange(unit='Ohm'))
iodevClass = BytesIO
def request(self, replylen, data='', ext=None, dst_adr=3, src_adr=2):
data = dehex(data)
if ext is None:
ext = len(data) > 1
if ext:
dst_adr |= 0x80
src_adr |= 0x80
if len(data) > 1:
length = len(data) + 2
hdr = [SD2, length, length, SD2]
else:
hdr = [SD1]
mid = [dst_adr, src_adr] + list(data)
checksum = sum(mid) % 256
msg = bytes(hdr + mid + [checksum, ED])
for i in range(10):
try:
# print('>', hexify(msg))
reply = self._iodev.communicate(msg, replylen)
# print('<', hexify(reply))
except (TimeoutError, CommunicationFailedError):
continue
return reply
return None
def read_value(self):
# check connection
self.request(6, '49')
# get diag
# 3C: slave diag, (what means 3E?)
reply = self.request(17, '6D 3C 3E')
assert reply[13:15] == b'\x0f\x84' # LS240 ident
# set parameters
# 3D set param (what means 3E?)
# B0 FF FF: no watchdog, 00: min wait, 0F 84: ident, 01: group
assert b'\xe5' == self.request(1, '5D 3D 3E B0 FF FF 00 0F 84 01')
# set config
# 3E set config (what means 2nd 3E?)
# 93: input only, 4 == 3+1 bytes
assert b'\xe5' == self.request(1, '7D 3E 3E 93')
# get diag
# 3C: slave diag, (what means 3E?)
reply = self.request(17, '5D 3C 3E')
assert reply[13:15] == b'\x0f\x84' # LS240 ident
# get data
# do not know what 42 24 means
reply = self.request(13, '7D 42 24', ext=0)
print('DATA', reply)
value = struct.unpack('>f', reply[7:11])[0]
print('VALUE', value)
return value

45
frappy_psi/ls340res.py Normal file
View File

@ -0,0 +1,45 @@
#!/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
# 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>
# *****************************************************************************
"""very simple LakeShore Model 340 driver, resistivity only"""
import time
from frappy.datatypes import StringType, FloatRange
from frappy.modules import Parameter, Property, Readable
from frappy.io import HasIO, StringIO
class LscIO(StringIO):
identification = [('*IDN?', 'LSCI,MODEL340,.*')]
end_of_line = '\r'
wait_before = 0.05
class ResChannel(HasIO, Readable):
"""temperature channel on Lakeshore 340"""
ioClass = LscIO
value = Parameter(datatype=FloatRange(unit='Ohm'))
channel = Property('the channel A,B,C or D', StringType())
def read_value(self):
return float(self.communicate('SRDG?%s' % self.channel))

View File

@ -28,7 +28,6 @@ the hardware state.
"""
import time
from ast import literal_eval
import frappy.io
from frappy.datatypes import BoolType, EnumType, FloatRange, IntRange, StatusType
@ -48,7 +47,34 @@ class StringIO(frappy.io.StringIO):
wait_before = 0.05
class Switcher(HasIO, ChannelSwitcher):
def parse_result(reply):
result = []
for strval in reply.split(','):
try:
result.append(int(strval))
except ValueError:
try:
result.append(float(strval))
except ValueError:
result.append(strval)
if len(result) == 1:
return result[0]
return result
class LakeShoreIO(HasIO):
def set_param(self, cmd, *args):
head = ','.join([cmd] + [f'{a:g}' for a in args])
tail = cmd.replace(' ', '?')
reply = self.io.communicate(f'{head};{tail}')
return parse_result(reply)
def get_param(self, cmd):
reply = self.io.communicate(cmd)
return parse_result(reply)
class Switcher(LakeShoreIO, ChannelSwitcher):
value = Parameter(datatype=IntRange(1, 16))
target = Parameter(datatype=IntRange(1, 16))
use_common_delays = Parameter('use switch_delay and measure_delay instead of the channels pause and dwell',
@ -63,10 +89,10 @@ class Switcher(HasIO, ChannelSwitcher):
super().startModule(start_events)
# disable unused channels
for ch in range(1, 16):
if ch not in self._channels:
if ch not in self.channels:
self.communicate(f'INSET {ch},0,0,0,0,0;INSET?{ch}')
channelno, autoscan = literal_eval(self.communicate('SCAN?'))
if channelno in self._channels and self._channels[channelno].enabled:
channelno, autoscan = self.get_param('SCAN?')
if channelno in self.channels and self.channels[channelno].enabled:
if not autoscan:
return # nothing to do
else:
@ -74,7 +100,7 @@ class Switcher(HasIO, ChannelSwitcher):
if channelno is None:
self.status = 'ERROR', 'no enabled channel'
return
self.communicate(f'SCAN {channelno},0;SCAN?')
self.set_param(f'SCAN {channelno},0')
def doPoll(self):
"""poll buttons
@ -82,22 +108,22 @@ class Switcher(HasIO, ChannelSwitcher):
and check autorange during filter time
"""
super().doPoll()
self._channels[self.target]._read_value() # check range or read
channelno, autoscan = literal_eval(self.communicate('SCAN?'))
self.channels[self.target].get_value() # check range or read
channelno, autoscan = self.get_param('SCAN?')
if autoscan:
# pressed autoscan button: switch off HW autoscan and toggle soft autoscan
self.autoscan = not self.autoscan
self.communicate(f'SCAN {self.value},0;SCAN?')
if channelno != self.value:
# channel changed by keyboard, do not yet return new channel
# channel changed by keyboard
self.write_target(channelno)
chan = self._channels.get(channelno)
chan = self.channels.get(channelno)
if chan is None:
channelno = self.next_channel(channelno)
if channelno is None:
raise ValueError('no channels enabled')
self.write_target(channelno)
chan = self._channels.get(self.value)
chan = self.channels.get(self.value)
chan.read_autorange()
chan.fix_autorange() # check for toggled autorange button
return Done
@ -135,12 +161,12 @@ class Switcher(HasIO, ChannelSwitcher):
self.measure_delay = chan.dwell
def set_active_channel(self, chan):
self.communicate(f'SCAN {chan.channel},0;SCAN?')
self.set_param(f'SCAN {chan.channel},0')
chan._last_range_change = time.monotonic()
self.set_delays(chan)
class ResChannel(Channel):
class ResChannel(LakeShoreIO, Channel):
"""temperature channel on Lakeshore 370"""
RES_RANGE = {key: i+1 for i, key in list(
@ -179,28 +205,30 @@ class ResChannel(Channel):
rdgrng_params = 'range', 'iexc', 'vexc'
inset_params = 'enabled', 'pause', 'dwell'
def communicate(self, command):
return self.switcher.communicate(command)
def initModule(self):
# take io from switcher
# pylint: disable=unsupported-assignment-operation
self.attachedModules['io'] = self.switcher.io # pylint believes this is None
super().initModule()
def read_status(self):
if not self.enabled:
return [self.Status.DISABLED, 'disabled']
if not self.channel == self.switcher.value == self.switcher.target:
return Done
result = int(self.communicate(f'RDGST?{self.channel}'))
result &= 0x37 # mask T_OVER and T_UNDER (change this when implementing temperatures instead of resistivities)
result = self.get_param(f'RDGST?{self.channel}')
result &= 0x37 # mask T_OVER and T_UNDER (change this when implementing temperatures instead of resistances)
statustext = ' '.join(formatStatusBits(result, STATUS_BIT_LABELS))
if statustext:
return [self.Status.ERROR, statustext]
return [self.Status.IDLE, '']
def _read_value(self):
def get_value(self):
"""read value, without update"""
now = time.monotonic()
if now + 0.5 < max(self._last_range_change, self.switcher._start_switch) + self.pause:
return None
result = self.communicate(f'RDGR?{self.channel}')
result = float(result)
result = self.get_param(f'RDGR{self.channel}')
if self.autorange:
self.fix_autorange()
if now + 0.5 > self._last_range_change + self.pause:
@ -224,19 +252,20 @@ class ResChannel(Channel):
def read_value(self):
if self.channel == self.switcher.value == self.switcher.target:
return self._read_value()
return Done # return previous value
value = self._read_value()
if value is not None:
return value
return self.value # return previous value
def is_switching(self, now, last_switch, switch_delay):
last_switch = max(last_switch, self._last_range_change)
if now + 0.5 > last_switch + self.pause:
self._read_value() # adjust range only
self.get_value() # adjust range only
return super().is_switching(now, last_switch, switch_delay)
@CommonReadHandler(rdgrng_params)
def read_rdgrng(self):
iscur, exc, rng, autorange, excoff = literal_eval(
self.communicate(f'RDGRNG?{self.channel}'))
iscur, exc, rng, autorange, excoff = self.get_param(f'RDGRNG{self.channel}')
self._prev_rdgrng = iscur, exc
if autorange: # pressed autorange button
if not self._toggle_autorange:
@ -252,11 +281,11 @@ class ResChannel(Channel):
self.read_range() # make sure autorange is handled
if 'vexc' in change: # in case vext is changed, do not consider iexc
change['iexc'] = 0
if change['iexc'] != 0: # we need '!= 0' here, as bool(enum) is always True!
if change['iexc']:
iscur = 1
exc = change['iexc']
excoff = 0
elif change['vexc'] != 0: # we need '!= 0' here, as bool(enum) is always True!
elif change['vexc']:
iscur = 0
exc = change['vexc']
excoff = 0
@ -267,7 +296,7 @@ class ResChannel(Channel):
if self.autorange:
if rng < self.minrange:
rng = self.minrange
self.communicate(f'RDGRNG {self.channel},{iscur},{exc},{rng},0,{excoff};*OPC?')
self.set_param(f'RDGRNG {self.channel}', iscur, exc, rng, 0, excoff)
self.read_range()
def fix_autorange(self):
@ -281,19 +310,14 @@ class ResChannel(Channel):
@CommonReadHandler(inset_params)
def read_inset(self):
# ignore curve no and temperature coefficient
enabled, dwell, pause, _, _ = literal_eval(
self.communicate(f'INSET?{self.channel}'))
self.enabled = enabled
self.dwell = dwell
self.pause = pause
self.enabled, self.dwell, self.pause, _, _ = self.get_param(f'INSET?{self.channel}')
@CommonWriteHandler(inset_params)
def write_inset(self, change):
_, _, _, change['curve'], change['tempco'] = literal_eval(
self.communicate(f'INSET?{self.channel}'))
self.enabled, self.dwell, self.pause, _, _ = literal_eval(
self.communicate('INSET {channel},{enabled:d},{dwell:d},'
'{pause:d},{curve},{tempco};INSET?{channel}'.format_map(change)))
_, _, _, curve, tempco = self.get_param(f'INSET?{self.channel}')
self.enabled, self.dwell, self.pause, _, _ = self.set_param(
f'INSET {self.channel}', change['enabled'], change['dwell'], change['pause'],
curve, tempco)
if 'enabled' in change and change['enabled']:
# switch to enabled channel
self.switcher.write_target(self.channel)
@ -301,14 +325,13 @@ class ResChannel(Channel):
self.switcher.set_delays(self)
def read_filter(self):
on, settle, _ = literal_eval(self.communicate(f'FILTER?{self.channel}'))
on, settle, _ = on, settle, _ = self.get_param(f'FILTER?{self.channel}')
return settle if on else 0
def write_filter(self, value):
on = 1 if value else 0
value = max(1, value)
on, settle, _ = literal_eval(self.communicate(
f'FILTER {self.channel},{on},{value:g},80;FILTER?{self.channel}'))
on, settle, _ = self.set_param(f'FILTER?{self.channel}', on, value, self.channel)
if not on:
settle = 0
return settle

75
frappy_psi/ls370sim.py Normal file
View File

@ -0,0 +1,75 @@
#!/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
# 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>
# *****************************************************************************
"""a very simple simulator for a LakeShore Model 370"""
from frappy.modules import Communicator
class Ls370Sim(Communicator):
CHANNEL_COMMANDS = [
('RDGR?%d', '1.0'),
('RDGST?%d', '0'),
('RDGRNG?%d', '0,5,5,0,0'),
('INSET?%d', '1,5,5,0,0'),
('FILTER?%d', '1,5,80'),
]
OTHER_COMMANDS = [
('*IDN?', 'LSCI,MODEL370,370184,05302003'),
('SCAN?', '3,1'),
('*OPC?', '1'),
]
def earlyInit(self):
super().earlyInit()
self._data = dict(self.OTHER_COMMANDS)
for fmt, v in self.CHANNEL_COMMANDS:
for chan in range(1,17):
self._data[fmt % chan] = v
def communicate(self, command):
self.comLog('> %s' % command)
# simulation part, time independent
for channel in range(1,17):
_, _, _, _, excoff = self._data['RDGRNG?%d' % channel].split(',')
if excoff == '1':
self._data['RDGST?%d' % channel] = '6'
else:
self._data['RDGST?%d' % channel] = '0'
chunks = command.split(';')
reply = []
for chunk in chunks:
if '?' in chunk:
reply.append(self._data[chunk])
else:
for nqarg in (1,0):
if nqarg == 0:
qcmd, arg = chunk.split(' ', 1)
qcmd += '?'
else:
qcmd, arg = chunk.split(',', nqarg)
qcmd = qcmd.replace(' ', '?', 1)
if qcmd in self._data:
self._data[qcmd] = arg
break
reply = ';'.join(reply)
self.comLog('< %s' % reply)
return reply

329
frappy_psi/ls372.py Normal file
View File

@ -0,0 +1,329 @@
#!/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
# 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>
# *****************************************************************************
"""LakeShore Model 372 resistance channel
implements autoscan and autorange by software.
when the autoscan or autorange button is pressed, the state is toggled,
and the hardware mode switched off again.
At startup, the configurable default mode is set, independent of
the hardware state.
"""
import time
import frappy.io
from frappy.datatypes import BoolType, EnumType, FloatRange, IntRange
from frappy.lib import formatStatusBits
from frappy.core import Done, Drivable, Parameter, Property, CommonReadHandler, CommonWriteHandler
from frappy.io import HasIO
from frappy_psi.channelswitcher import Channel, ChannelSwitcher
Status = Drivable.Status
STATUS_BIT_LABELS = 'CS_OVL VCM_OVL VMIX_OVL VDIF_OVL R_OVER R_UNDER T_OVER T_UNDER'.split()
def parse1(string):
try:
return int(string)
except ValueError:
pass
try:
return float(string)
except ValueError:
return string
def parse(reply):
return [parse1(s) for s in reply.split(',')]
class StringIO(frappy.io.StringIO):
identification = [('*IDN?', 'LSCI,MODEL372,.*')]
wait_before = 0.05
class Switcher(HasIO, ChannelSwitcher):
value = Parameter(datatype=IntRange(1, 16))
target = Parameter(datatype=IntRange(1, 16))
use_common_delays = Parameter('use switch_delay and measure_delay instead of the channels pause and dwell',
BoolType(), readonly=False, default=False)
common_pause = Parameter('pause with common delays', FloatRange(3, 200, unit='s'), readonly=False, default=3)
ioClass = StringIO
fast_poll = 1
_measure_delay = None
_switch_delay = None
def startModule(self, start_events):
super().startModule(start_events)
# disable unused channels
for ch in range(1, 16):
if ch not in self._channels:
self.communicate('INSET %d,0,0,0,0,0;INSET?%d' % (ch, ch))
channelno, autoscan = parse(self.communicate('SCAN?'))
if channelno in self._channels and self._channels[channelno].enabled:
if not autoscan:
return # nothing to do
else:
channelno = self.next_channel(channelno)
if channelno is None:
self.status = 'ERROR', 'no enabled channel'
return
self.communicate('SCAN %d,0;SCAN?' % channelno)
def doPoll(self):
"""poll buttons
and check autorange during filter time
"""
super().doPoll()
self._channels[self.target]._read_value() # check range or read
channelno, autoscan = parse(self.communicate('SCAN?'))
if autoscan:
# pressed autoscan button: switch off HW autoscan and toggle soft autoscan
self.autoscan = not self.autoscan
self.communicate('SCAN %d,0;SCAN?' % self.value)
if channelno != self.value:
# channel changed by keyboard, do not yet return new channel
self.write_target(channelno)
chan = self._channels.get(channelno)
if chan is None:
channelno = self.next_channel(channelno)
if channelno is None:
raise ValueError('no channels enabled')
self.write_target(channelno)
chan = self._channels.get(self.value)
chan.read_autorange()
chan.fix_autorange() # check for toggled autorange button
return Done
def write_switch_delay(self, value):
self._switch_delay = value
return super().write_switch_delay(value)
def write_measure_delay(self, value):
self._measure_delay = value
return super().write_measure_delay(value)
def write_use_common_delays(self, value):
if value:
# use values from a previous change, instead of
# the values from the current channel
if self._measure_delay is not None:
self.measure_delay = self._measure_delay
if self._switch_delay is not None:
self.switch_delay = self._switch_delay
return value
def set_delays(self, chan):
if self.use_common_delays:
if chan.dwell != self.measure_delay:
chan.write_dwell(self.measure_delay)
if chan.pause != self.common_pause:
chan.write_pause(self.common_pause)
filter_ = max(0, self.switch_delay - self.common_pause)
if chan.filter != filter_:
chan.write_filter(filter_)
else:
# switch_delay and measure_delay is changing with channel
self.switch_delay = chan.pause + chan.filter
self.measure_delay = chan.dwell
def set_active_channel(self, chan):
self.communicate('SCAN %d,0;SCAN?' % chan.channel)
chan._last_range_change = time.monotonic()
self.set_delays(chan)
class ResChannel(Channel):
"""temperature channel on Lakeshore 372"""
RES_RANGE = {key: i+1 for i, key in list(
enumerate(mag % val for mag in ['%gmOhm', '%gOhm', '%gkOhm', '%gMOhm']
for val in [2, 6.32, 20, 63.2, 200, 632]))[:-2]}
CUR_RANGE = {key: i + 1 for i, key in list(
enumerate(mag % val for mag in ['%gpA', '%gnA', '%guA', '%gmA']
for val in [1, 3.16, 10, 31.6, 100, 316]))[:-2]}
VOLT_RANGE = {key: i + 1 for i, key in list(
enumerate(mag % val for mag in ['%guV', '%gmV']
for val in [2, 6.32, 20, 63.2, 200, 632]))}
RES_SCALE = [2 * 10 ** (0.5 * i) for i in range(-7, 16)] # RES_SCALE[0] is not used
MAX_RNG = len(RES_SCALE) - 1
channel = Property('the Lakeshore channel', datatype=IntRange(1, 16), export=False)
value = Parameter(datatype=FloatRange(unit='Ohm'))
pollinterval = Parameter(visibility=3, default=1)
range = Parameter('reading range', readonly=False,
datatype=EnumType(**RES_RANGE))
minrange = Parameter('minimum range for software autorange', readonly=False, default=1,
datatype=EnumType(**RES_RANGE))
autorange = Parameter('autorange', datatype=BoolType(),
readonly=False, default=1)
iexc = Parameter('current excitation', datatype=EnumType(off=0, **CUR_RANGE), readonly=False)
vexc = Parameter('voltage excitation', datatype=EnumType(off=0, **VOLT_RANGE), readonly=False)
enabled = Parameter('is this channel enabled?', datatype=BoolType(), readonly=False)
pause = Parameter('pause after channel change', datatype=FloatRange(3, 60, unit='s'), readonly=False)
dwell = Parameter('dwell time with autoscan', datatype=FloatRange(1, 200, unit='s'), readonly=False)
filter = Parameter('filter time', datatype=FloatRange(1, 200, unit='s'), readonly=False)
_toggle_autorange = 'init'
_prev_rdgrng = (1, 1) # last read values for icur and exc
_last_range_change = 0
rdgrng_params = 'range', 'iexc', 'vexc'
inset_params = 'enabled', 'pause', 'dwell'
def communicate(self, command):
return self.switcher.communicate(command)
def read_status(self):
if not self.enabled:
return [self.Status.DISABLED, 'disabled']
if not self.channel == self.switcher.value == self.switcher.target:
return Done
result = int(self.communicate('RDGST?%d' % self.channel))
result &= 0x37 # mask T_OVER and T_UNDER (change this when implementing temperatures instead of resistivities)
statustext = ' '.join(formatStatusBits(result, STATUS_BIT_LABELS))
if statustext:
return [self.Status.ERROR, statustext]
return [self.Status.IDLE, '']
def _read_value(self):
"""read value, without update"""
now = time.monotonic()
if now + 0.5 < max(self._last_range_change, self.switcher._start_switch) + self.pause:
return None
result = self.communicate('RDGR?%d' % self.channel)
result = float(result)
if self.autorange:
self.fix_autorange()
if now + 0.5 > self._last_range_change + self.pause:
rng = int(max(self.minrange, self.range)) # convert from enum to int
if self.status[1] == '':
if abs(result) > self.RES_SCALE[rng]:
if rng < 22:
rng += 1
else:
lim = 0.2
while rng > self.minrange and abs(result) < lim * self.RES_SCALE[rng]:
rng -= 1
lim -= 0.05 # not more than 4 steps at once
# effectively: <0.16 %: 4 steps, <1%: 3 steps, <5%: 2 steps, <20%: 1 step
elif rng < self.MAX_RNG:
rng = min(self.MAX_RNG, rng + 1)
if rng != self.range:
self.write_range(rng)
self._last_range_change = now
return result
def read_value(self):
if self.channel == self.switcher.value == self.switcher.target:
return self._read_value()
return Done # return previous value
def is_switching(self, now, last_switch, switch_delay):
last_switch = max(last_switch, self._last_range_change)
if now + 0.5 > last_switch + self.pause:
self._read_value() # adjust range only
return super().is_switching(now, last_switch, switch_delay)
@CommonReadHandler(rdgrng_params)
def read_rdgrng(self):
iscur, exc, rng, autorange, excoff = parse(
self.communicate('RDGRNG?%d' % self.channel))
self._prev_rdgrng = iscur, exc
if autorange: # pressed autorange button
if not self._toggle_autorange:
self._toggle_autorange = True
iexc = 0 if excoff or not iscur else exc
vexc = 0 if excoff or iscur else exc
if (rng, iexc, vexc) != (self.range, self.iexc, self.vexc):
self._last_range_change = time.monotonic()
self.range, self.iexc, self.vexc = rng, iexc, vexc
@CommonWriteHandler(rdgrng_params)
def write_rdgrng(self, change):
self.read_range() # make sure autorange is handled
if 'vexc' in change: # in case vext is changed, do not consider iexc
change['iexc'] = 0
if change['iexc'] != 0: # we need '!= 0' here, as bool(enum) is always True!
iscur = 1
exc = change['iexc']
excoff = 0
elif change['vexc'] != 0: # we need '!= 0' here, as bool(enum) is always True!
iscur = 0
exc = change['vexc']
excoff = 0
else:
iscur, exc = self._prev_rdgrng # set to last read values
excoff = 1
rng = change['range']
if self.autorange:
if rng < self.minrange:
rng = self.minrange
self.communicate('RDGRNG %d,%d,%d,%d,%d,%d;*OPC?' % (
self.channel, iscur, exc, rng, 0, excoff))
self.read_range()
def fix_autorange(self):
if self._toggle_autorange:
if self._toggle_autorange == 'init':
self.write_autorange(True)
else:
self.write_autorange(not self.autorange)
self._toggle_autorange = False
@CommonReadHandler(inset_params)
def read_inset(self):
# ignore curve no and temperature coefficient
enabled, dwell, pause, _, _ = parse(
self.communicate('INSET?%d' % self.channel))
self.enabled = enabled
self.dwell = dwell
self.pause = pause
@CommonWriteHandler(inset_params)
def write_inset(self, change):
_, _, _, curve, tempco = parse(
self.communicate('INSET?%d' % self.channel))
self.enabled, self.dwell, self.pause, _, _ = parse(
self.communicate('INSET %d,%d,%d,%d,%d,%d;INSET?%d' % (
self.channel, change['enabled'], change['dwell'], change['pause'], curve, tempco,
self.channel)))
if 'enabled' in change and change['enabled']:
# switch to enabled channel
self.switcher.write_target(self.channel)
elif self.switcher.target == self.channel:
self.switcher.set_delays(self)
def read_filter(self):
on, settle, _ = parse(self.communicate('FILTER?%d' % self.channel))
return settle if on else 0
def write_filter(self, value):
on = 1 if value else 0
value = max(1, value)
on, settle, _ = parse(self.communicate(
'FILTER %d,%d,%g,80;FILTER?%d' % (self.channel, on, value, self.channel)))
if not on:
settle = 0
return settle

400
frappy_psi/magfield.py Normal file
View File

@ -0,0 +1,400 @@
# -*- 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:
# Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
"""generic persistent magnet driver"""
import time
from frappy.core import Drivable, Parameter, BUSY, Limit
from frappy.datatypes import FloatRange, EnumType, TupleOf, StatusType
from frappy.errors import ConfigError, HardwareError, DisabledError
from frappy.lib.enum import Enum
from frappy.states import Retry, HasStates, status_code
UNLIMITED = FloatRange()
Mode = Enum(
DISABLED=0,
PERSISTENT=30,
DRIVEN=50,
)
Status = Enum(
Drivable.Status,
PREPARED=150,
PREPARING=340,
RAMPING=370,
STABILIZING=380,
FINALIZING=390,
)
OFF = 0
ON = 1
class SimpleMagfield(HasStates, Drivable):
value = Parameter('magnetic field', datatype=FloatRange(unit='T'))
target_min = Limit()
target_max = Limit()
ramp = Parameter(
'wanted ramp rate for field', FloatRange(unit='$/min'), readonly=False)
# export only when different from ramp:
workingramp = Parameter(
'effective ramp rate for field', FloatRange(unit='$/min'), export=False)
tolerance = Parameter(
'tolerance', FloatRange(0, unit='$'), readonly=False, default=0.0002)
trained = Parameter(
'trained field (positive)',
TupleOf(FloatRange(-99, 0, unit='$'), FloatRange(0, unit='$')),
readonly=False, default=(0, 0))
wait_stable_field = Parameter(
'wait time to ensure field is stable', FloatRange(0, unit='s'), readonly=False, default=31)
ramp_tmo = Parameter(
'timeout for field ramp progress',
FloatRange(0, unit='s'), readonly=False, default=30)
_last_target = None
_symmetric_limits = False
def earlyInit(self):
super().earlyInit()
# when limits are symmetric at init, we want auto symmetric limits
self._symmetric_limits = self.target_min == -self.target_max
def write_target_max(self, value):
if self._symmetric_limits:
self.target_min = -value
return value
def write_target_min(self, value):
"""when modified to other than a symmetric value, we assume the user does not want auto symmetric limits"""
self._symmetric_limits = value == -self.target_max
return value
def checkProperties(self):
dt = self.parameters['target'].datatype
max_ = dt.max
if max_ == UNLIMITED.max:
raise ConfigError('target.max not configured')
if dt.min == UNLIMITED.min: # not given: assume bipolar symmetric
dt.min = -max_
self.target_min = max(dt.min, self.target_min)
if 'target_max' in self.writeDict:
self.writeDict.setdefault('target_min', -self.writeDict['target_max'])
super().checkProperties()
def stop(self):
"""keep field at current value"""
# let the state machine do the needed steps to finish
self.write_target(self.value)
def last_target(self):
"""get best known last target
as long as the guessed last target is within tolerance
with repsect to the main value, it is used, as in general
it has better precision
"""
last = self._last_target
if last is None:
try:
last = self.setpoint # get read back from HW, if available
except Exception:
pass
if last is None or abs(last - self.value) > self.tolerance:
return self.value
return last
def write_target(self, target):
self.start_machine(self.start_field_change, target=target)
return target
def init_progress(self, sm, value):
sm.prev_point = sm.now, value
def get_progress(self, sm, value):
"""return the time passed for at least one tolerance step"""
t, v = sm.prev_point
dif = abs(v - value)
tdif = sm.now - t
if dif > self.tolerance:
sm.prev_point = sm.now, value
return tdif
@status_code(BUSY, 'start ramp to target')
def start_field_change(self, sm):
self.setFastPoll(True, 1.0)
return self.start_ramp_to_target
@status_code(BUSY, 'ramping field')
def ramp_to_target(self, sm):
if sm.init:
self.init_progress(sm, self.value)
# Remarks: assume there is a ramp limiting feature
if abs(self.value - sm.target) > self.tolerance:
if self.get_progress(sm, self.value) > self.ramp_tmo:
raise HardwareError('no progress')
sm.stabilize_start = None # force reset
return Retry
sm.stabilize_start = time.time()
return self.stabilize_field
@status_code(BUSY, 'stabilizing field')
def stabilize_field(self, sm):
if sm.now - sm.stabilize_start < self.wait_stable_field:
return Retry
return self.final_status()
def read_workingramp(self):
return self.ramp
class Magfield(SimpleMagfield):
status = Parameter(datatype=StatusType(Status))
mode = Parameter('persistent mode', EnumType(Mode), readonly=False, default=Mode.PERSISTENT)
switch_heater = Parameter('switch heater', EnumType(off=OFF, on=ON),
readonly=False, default=0)
current = Parameter(
'leads current (in units of field)', FloatRange(unit='$'))
# TODO: time_to_target
# profile = Parameter(
# 'ramp limit table', ArrayOf(TupleOf(FloatRange(unit='$'), FloatRange(unit='$/min'))),
# readonly=False)
# profile_training = Parameter(
# 'ramp limit table when in training',
# ArrayOf(TupleOf(FloatRange(unit='$'), FloatRange(unit='$/min'))), readonly=False)
# TODO: the following parameters should be changed into properties after tests
wait_switch_on = Parameter(
'wait time to ensure switch is on', FloatRange(0, unit='s'), readonly=False, default=61)
wait_switch_off = Parameter(
'wait time to ensure switch is off', FloatRange(0, unit='s'), readonly=False, default=61)
wait_stable_leads = Parameter(
'wait time to ensure current is stable', FloatRange(0, unit='s'), readonly=False, default=6)
persistent_limit = Parameter(
'above this limit, lead currents are not driven to 0',
FloatRange(0, unit='$'), readonly=False, default=99)
leads_ramp_tmo = Parameter(
'timeout for leads ramp progress',
FloatRange(0, unit='s'), readonly=False, default=30)
init_persistency = True
switch_on_time = None
switch_off_time = None
def doPoll(self):
if self.init_persistency:
if self.read_switch_heater() and self.mode != Mode.DRIVEN:
# switch heater is on on startup: got to persistent mode
# do this after some delay, so the user might continue
# driving without delay after a restart
self.start_machine(self.go_persistent_soon, mode=self.mode)
self.init_persistency = False
super().doPoll()
def initModule(self):
super().initModule()
self.registerCallbacks(self) # for update_switch_heater
def write_mode(self, value):
self.init_persistency = False
target = self.last_target()
func = self.start_field_change
if value == Mode.DISABLED:
target = 0
if abs(self.value) < self.tolerance:
func = self.start_switch_off
elif value == Mode.PERSISTENT:
func = self.start_switch_off
self.target = target
self.start_machine(func, target=target, mode=value)
return value
def write_target(self, target):
self.init_persistency = False
if self.mode == Mode.DISABLED:
if target == 0:
return 0
raise DisabledError('disabled')
self.start_machine(self.start_field_change, target=target, mode=self.mode)
return target
def on_error(self, sm): # sm is short for statemachine
if self.switch_heater == ON:
self.read_value()
if sm.mode != Mode.DRIVEN:
self.log.warning('turn switch heater off')
self.write_switch_heater(OFF)
return super().on_error(sm)
@status_code(Status.WARN)
def go_persistent_soon(self, sm):
if sm.delta(60):
self.target = sm.target = self.last_target()
return self.start_field_change
return Retry
@status_code(Status.PREPARING)
def start_field_change(self, sm):
self.setFastPoll(True, 1.0)
if (sm.target == self.last_target() and
abs(sm.target - self.value) <= self.tolerance and
abs(self.current - self.value) < self.tolerance and
(self.mode != Mode.DRIVEN or self.switch_heater == ON)): # short cut
return self.check_switch_off
if self.switch_heater == ON:
return self.start_switch_on
return self.start_ramp_to_field
@status_code(Status.PREPARING)
def start_ramp_to_field(self, sm):
"""start ramping current to persistent field
initiate ramp to persistent field (with corresponding ramp rate)
the implementation should return ramp_to_field
"""
raise NotImplementedError
@status_code(Status.PREPARING, 'ramp leads to match field')
def ramp_to_field(self, sm):
if sm.init:
sm.stabilize_start = 0 # in case current is already at field
self.init_progress(sm, self.current)
dif = abs(self.current - self.value)
if dif > self.tolerance:
tdif = self.get_progress(sm, self.current)
if tdif > self.leads_ramp_tmo:
raise HardwareError('no progress')
sm.stabilize_start = None # force reset
return Retry
if sm.stabilize_start is None:
sm.stabilize_start = sm.now
return self.stabilize_current
@status_code(Status.PREPARING)
def stabilize_current(self, sm):
if sm.now - sm.stabilize_start < self.wait_stable_leads:
return Retry
return self.start_switch_on
def update_switch_heater(self, value):
"""is called whenever switch heater was changed"""
if value == 0:
if self.switch_off_time is None:
self.log.info('restart switch_off_time')
self.switch_off_time = time.time()
self.switch_on_time = None
else:
if self.switch_on_time is None:
self.log.info('restart switch_on_time')
self.switch_on_time = time.time()
self.switch_off_time = None
@status_code(Status.PREPARING)
def start_switch_on(self, sm):
if (sm.target == self.last_target() and
abs(sm.target - self.value) <= self.tolerance): # short cut
return self.check_switch_off
if self.read_switch_heater() == OFF:
try:
self.write_switch_heater(ON)
except Exception as e:
self.log.warning('write_switch_heater %r', e)
return Retry
return self.wait_for_switch_on
@status_code(Status.PREPARING)
def wait_for_switch_on(self, sm):
if sm.now - self.switch_on_time < self.wait_switch_on:
if sm.delta(10):
self.log.info('waited for %g sec', sm.now - self.switch_on_time)
return Retry
self._last_target = sm.target
return self.start_ramp_to_target
@status_code(Status.RAMPING)
def start_ramp_to_target(self, sm):
"""start ramping current to target field
initiate ramp to target
the implementation should return ramp_to_target
"""
raise NotImplementedError
@status_code(Status.RAMPING)
def ramp_to_target(self, sm):
dif = abs(self.value - sm.target)
if sm.init:
sm.stabilize_start = 0 # in case current is already at target
self.init_progress(sm, self.value)
if dif > self.tolerance:
sm.stabilize_start = sm.now
tdif = self.get_progress(sm, self.value)
if tdif > self.workingramp / self.tolerance * 60 + self.ramp_tmo:
self.log.warn('no progress')
raise HardwareError('no progress')
sm.stabilize_start = None
return Retry
if sm.stabilize_start is None:
sm.stabilize_start = sm.now
return self.stabilize_field
@status_code(Status.STABILIZING)
def stabilize_field(self, sm):
if sm.now < sm.stabilize_start + self.wait_stable_field:
return Retry
return self.check_switch_off
def check_switch_off(self, sm):
if sm.mode == Mode.DRIVEN:
return self.final_status(Status.PREPARED, 'driven')
return self.start_switch_off
@status_code(Status.FINALIZING)
def start_switch_off(self, sm):
if self.switch_heater == ON:
self.write_switch_heater(OFF)
return self.wait_for_switch_off
@status_code(Status.FINALIZING)
def wait_for_switch_off(self, sm):
if sm.now - self.switch_off_time < self.wait_switch_off:
return Retry
if abs(self.value) > self.persistent_limit:
return self.final_status(Status.IDLE, 'leads current at field, switch off')
return self.start_ramp_to_zero
@status_code(Status.FINALIZING)
def start_ramp_to_zero(self, sm):
"""start ramping current to zero
initiate ramp to zero (with corresponding ramp rate)
the implementation should return ramp_to_zero
"""
raise NotImplementedError
@status_code(Status.FINALIZING)
def ramp_to_zero(self, sm):
"""ramp field to zero"""
if sm.init:
self.init_progress(sm, self.current)
if abs(self.current) > self.tolerance:
if self.get_progress(sm, self.value) > self.leads_ramp_tmo:
raise HardwareError('no progress')
return Retry
if sm.mode == Mode.DISABLED and abs(self.value) < self.tolerance:
return self.final_status(Status.DISABLED, 'disabled')
return self.final_status(Status.IDLE, 'persistent mode')

View File

@ -25,12 +25,13 @@ import math
import re
import time
from frappy.core import Drivable, HasIO, Writable, \
Parameter, Property, Readable, StringIO, Attached, IDLE, nopoll
from frappy.datatypes import EnumType, FloatRange, StringType, StructOf, BoolType
from frappy.errors import HardwareError
from frappy.core import Drivable, HasIO, Writable, StatusType, \
Parameter, Property, Readable, StringIO, Attached, IDLE, RAMPING, nopoll
from frappy.datatypes import EnumType, FloatRange, StringType, StructOf, BoolType, TupleOf
from frappy.errors import HardwareError, ProgrammingError, ConfigError, RangeError
from frappy_psi.convergence import HasConvergence
from frappy.lib.enum import Enum
from frappy.states import Retry, Finish
from frappy.mixins import HasOutputModule, HasControlledBy
VALUE_UNIT = re.compile(r'(.*\d|inf)([A-Za-z/%]*)$')
@ -38,6 +39,7 @@ SELF = 0
def as_float(value):
"""converts string (with unit) to float and float to string"""
if isinstance(value, str):
return float(VALUE_UNIT.match(value).group(1))
return f'{value:g}'
@ -61,48 +63,58 @@ fast_slow = Mapped(ON=0, OFF=1) # maps OIs slow=ON/fast=OFF to sample_rate.slow
class IO(StringIO):
identification = [('*IDN?', r'IDN:OXFORD INSTRUMENTS:MERCURY*')]
identification = [('*IDN?', r'IDN:OXFORD INSTRUMENTS:*')]
class MercuryChannel(HasIO):
slot = Property('''slot uids
slot = Property('comma separated slot id(s), e.g. DB6.T1', StringType())
kind = '' #: used slot kind(s)
slots = () #: dict[<kind>] of <slot>
example: DB6.T1,DB1.H1
slot ids for sensor (and control output)''',
StringType())
channel_name = Parameter('mercury nick name', StringType(), default='', update_unchanged='never')
channel_type = '' #: channel type(s) for sensor (and control) e.g. TEMP,HTR or PRES,AUX
def earlyInit(self):
super().earlyInit()
self.kinds = self.kind.split(',')
slots = self.slot.split(',')
if len(slots) != len(self.kinds):
raise ConfigError(f'slot needs {len(self.kinds)} comma separated names')
self.slots = dict(zip(self.kinds, slots))
self.setProperty('slot', slots[0])
def _complete_adr(self, adr):
"""complete address from channel_type and slot"""
head, sep, tail = adr.partition(':')
for i, (channel_type, slot) in enumerate(zip(self.channel_type.split(','), self.slot.split(','))):
if head == str(i):
return f'DEV:{slot}:{channel_type}{sep}{tail}'
if head == channel_type:
return f'DEV:{slot}:{head}{sep}{tail}'
"""insert slot to adr"""
spl = adr.split(':')
if spl[0] == 'DEV':
if spl[1] == '':
spl[1] = self.slots[spl[2]]
return ':'.join(spl)
elif spl[0] != 'SYS':
raise ProgrammingError('using old style adr?')
return adr
def multiquery(self, adr, names=(), convert=as_float):
def multiquery(self, adr, names=(), convert=as_float, debug=None):
"""get parameter(s) in mercury syntax
:param adr: the 'address part' of the SCPI command
the DEV:<slot> is added automatically, when adr starts with the channel type
in addition, when adr starts with '0:' or '1:', channel type and slot are added
READ: is added automatically, slot is inserted when adr starts with DEV::
:param names: the SCPI names of the parameter(s), for example ['TEMP']
:param convert: a converter function (converts replied string to value)
:return: the values as tuple
Example:
adr='AUX:SIG'
Example (kind=PRES,AUX slot=DB5.P1,DB3.G1):
adr='DEV::AUX:SIG'
names = ('PERC',)
self.channel_type='PRES,AUX' # adr starts with 'AUX'
self.slot='DB5.P1,DB3.G1' # -> take second slot
-> query command will be READ:DEV:DB3.G1:PRES:SIG:PERC
-> query command will be READ:DEV:DB3.G1:AUX:SIG:PERC
"""
# TODO: if the need arises: allow convert to be a list
adr = self._complete_adr(adr)
cmd = f"READ:{adr}:{':'.join(names)}"
msg = ''
for _ in range(3):
if msg:
self.log.warning('%s', msg)
reply = self.communicate(cmd)
if debug is not None:
debug.append(reply)
head = f'STAT:{adr}:'
try:
assert reply.startswith(head)
@ -111,98 +123,102 @@ class MercuryChannel(HasIO):
assert keys == tuple(names)
return tuple(convert(r) for r in result)
except (AssertionError, AttributeError, ValueError):
raise HardwareError(f'invalid reply {reply!r} to cmd {cmd!r}') from None
time.sleep(0.1) # in case this was the answer of a previous command
msg = f'invalid reply {reply!r} to cmd {cmd!r}'
raise HardwareError(msg) from None
def multichange(self, adr, values, convert=as_float):
def multichange(self, adr, values, convert=as_float, tolerance=0, n_retry=3, lazy=False):
"""set parameter(s) in mercury syntax
:param adr: as in see multiquery method
:param adr: as in multiquery method. SET: is added automatically
:param values: [(name1, value1), (name2, value2) ...]
:param convert: a converter function (converts given value to string and replied string to value)
:param tolerance: tolerance for readback check
:param n_retry: number of retries or 0 for no readback check
:param lazy: check direct reply only (no additional query)
:return: the values as tuple
Example:
adr='0:LOOP'
Example (kind=TEMP, slot=DB6.T1:
adr='DEV::TEMP:LOOP'
values = [('P', 5), ('I', 2), ('D', 0)]
self.channel_type='TEMP,HTR' # adr starts with 0: take TEMP
self.slot='DB6.T1,DB1.H1' # and take first slot
-> change command will be SET:DEV:DB6.T1:TEMP:LOOP:P:5:I:2:D:0
"""
# TODO: if the need arises: allow convert and or tolerance to be a list
adr = self._complete_adr(adr)
params = [f'{k}:{convert(v)}' for k, v in values]
cmd = f"SET:{adr}:{':'.join(params)}"
givenkeys = tuple(v[0] for v in values)
for _ in range(max(1, n_retry)): # try n_retry times or until readback result matches
reply = self.communicate(cmd)
head = f'STAT:SET:{adr}:'
try:
assert reply.startswith(head)
replyiter = iter(reply[len(head):].split(':'))
# reshuffle reply=(k1, r1, v1, k2, r2, v1) --> keys = (k1, k2), result = (r1, r2), valid = (v1, v2)
keys, result, valid = zip(*zip(replyiter, replyiter, replyiter))
assert keys == tuple(k for k, _ in values)
assert keys == givenkeys
assert any(v == 'VALID' for v in valid)
return tuple(convert(r) for r in result)
result = tuple(convert(r) for r in result)
except (AssertionError, AttributeError, ValueError) as e:
time.sleep(0.1) # in case of missed replies this might help to skip garbage
raise HardwareError(f'invalid reply {reply!r} to cmd {cmd!r}') from e
if n_retry == 0:
return [v for _, v in values]
if lazy:
debug = [reply]
readback = [v for _, v in values]
else:
debug = []
readback = list(self.multiquery(adr, givenkeys, convert, debug))
failed = False
for i, ((k, v), r, b) in enumerate(zip(values, result, readback)):
if convert is as_float:
tol = max(abs(r) * 1e-3, abs(b) * 1e-3, tolerance)
if abs(b - v) > tol or abs(r - v) > tol:
readback[i] = None
failed = True
elif b != v or r != v:
readback[i] = None
failed = True
if not failed:
return readback
self.log.warning('sent: %s', cmd)
self.log.warning('got: %s', debug[0])
return tuple(v[1] if b is None else b for b, v in zip(readback, values))
def query(self, adr, convert=as_float):
"""query a single parameter
'adr' and 'convert' areg
'adr' and 'convert' as in multiquery
"""
adr, _, name = adr.rpartition(':')
return self.multiquery(adr, [name], convert)[0]
def change(self, adr, value, convert=as_float):
def change(self, adr, value, convert=as_float, tolerance=0, n_retry=3, lazy=False):
adr, _, name = adr.rpartition(':')
return self.multichange(adr, [(name, value)], convert)[0]
def read_channel_name(self):
if self.channel_name:
return self.channel_name # channel name will not change
return self.query('0:NICK', as_string)
return self.multichange(adr, [(name, value)], convert, tolerance, n_retry, lazy)[0]
class TemperatureSensor(MercuryChannel, Readable):
channel_type = 'TEMP'
kind = 'TEMP'
value = Parameter(unit='K')
raw = Parameter('raw value', FloatRange(unit='Ohm'))
def read_value(self):
return self.query('TEMP:SIG:TEMP')
return self.query('DEV::TEMP:SIG:TEMP')
def read_raw(self):
return self.query('TEMP:SIG:RES')
return self.query('DEV::TEMP:SIG:RES')
class HasInput(MercuryChannel):
controlled_by = Parameter('source of target value', EnumType(members={'self': SELF}), default=0)
target = Parameter(readonly=False)
input_modules = ()
def add_input(self, modobj):
if not self.input_modules:
self.input_modules = []
self.input_modules.append(modobj)
prev_enum = self.parameters['controlled_by'].datatype._enum
# add enum member, using autoincrement feature of Enum
self.parameters['controlled_by'].datatype = EnumType(Enum(prev_enum, **{modobj.name: None}))
def write_controlled_by(self, value):
if self.controlled_by == value:
return value
self.controlled_by = value
if value == SELF:
self.log.warning('switch to manual mode')
for input_module in self.input_modules:
if input_module.control_active:
input_module.write_control_active(False)
return value
class HasInput(HasControlledBy, MercuryChannel):
pass
class Loop(HasConvergence, MercuryChannel, Drivable):
class Loop(HasOutputModule, MercuryChannel, Drivable):
"""common base class for loops"""
control_active = Parameter('control mode', BoolType())
output_module = Attached(HasInput, mandatory=False)
control_active = Parameter(readonly=False)
ctrlpars = Parameter(
'pid (proportional band, integral time, differential time',
StructOf(p=FloatRange(0, unit='$'), i=FloatRange(0, unit='min'), d=FloatRange(0, unit='min')),
@ -210,48 +226,49 @@ class Loop(HasConvergence, MercuryChannel, Drivable):
)
enable_pid_table = Parameter('', BoolType(), readonly=False)
def initModule(self):
super().initModule()
if self.output_module:
self.output_module.add_input(self)
def set_output(self, active):
def set_output(self, active, source='HW'):
if active:
if self.output_module and self.output_module.controlled_by != self.name:
self.output_module.controlled_by = self.name
self.activate_control()
else:
if self.output_module and self.output_module.controlled_by != SELF:
self.output_module.write_controlled_by(SELF)
status = IDLE, 'control inactive'
if self.status != status:
self.status = status
self.deactivate_control(source)
def set_target(self, target):
if self.control_active:
self.set_output(True)
else:
self.log.warning('switch loop control on')
self.write_control_active(True)
self.target = target
self.start_state()
def read_enable_pid_table(self):
return self.query('0:LOOP:PIDT', off_on)
return self.query(f'DEV::{self.kinds[0]}:LOOP:PIDT', off_on)
def write_enable_pid_table(self, value):
return self.change('0:LOOP:PIDT', value, off_on)
return self.change(f'DEV::{self.kinds[0]}:LOOP:PIDT', value, off_on)
def read_ctrlpars(self):
# read all in one go, in order to reduce comm. traffic
pid = self.multiquery('0:LOOP', ('P', 'I', 'D'))
pid = self.multiquery(f'DEV::{self.kinds[0]}:LOOP', ('P', 'I', 'D'))
return {k: float(v) for k, v in zip('pid', pid)}
def write_ctrlpars(self, value):
pid = self.multichange('0:LOOP', [(k, value[k.lower()]) for k in 'PID'])
pid = self.multichange(f'DEV::{self.kinds[0]}:LOOP', [(k, value[k.lower()]) for k in 'PID'])
return {k.lower(): v for k, v in zip('PID', pid)}
def read_status(self):
return IDLE, ''
class HeaterOutput(HasInput, MercuryChannel, Writable):
class ConvLoop(HasConvergence, Loop):
def deactivate_control(self, source):
if self.control_active:
super().deactivate_control(source)
self.convergence_state.start(self.inactive_state)
if self.pollInfo:
self.pollInfo.trigger(True)
def inactive_state(self, state):
self.convergence_state.status = IDLE, 'control inactive'
return Finish
class HeaterOutput(HasInput, Writable):
"""heater output
Remark:
@ -259,7 +276,7 @@ class HeaterOutput(HasInput, MercuryChannel, Writable):
resistivity. As the measured heater current is available, the resistivity
will be adjusted automatically, when true_power is True.
"""
channel_type = 'HTR'
kind = 'HTR'
value = Parameter('heater output', FloatRange(unit='W'), readonly=False)
status = Parameter(update_unchanged='never')
target = Parameter('heater output', FloatRange(0, 100, unit='$'), readonly=False, update_unchanged='never')
@ -272,23 +289,23 @@ class HeaterOutput(HasInput, MercuryChannel, Writable):
_volt_target = None
def read_limit(self):
return self.query('HTR:VLIM') ** 2 / self.resistivity
return self.query('DEV::HTR:VLIM') ** 2 / self.resistivity
def write_limit(self, value):
result = self.change('HTR:VLIM', math.sqrt(value * self.resistivity))
result = self.change('DEV::HTR:VLIM', math.sqrt(value * self.resistivity))
return result ** 2 / self.resistivity
def read_resistivity(self):
if self.true_power:
return self.resistivity
return max(10, self.query('HTR:RES'))
return max(10.0, self.query('DEV::HTR:RES'))
def write_resistivity(self, value):
self.resistivity = self.change('HTR:RES', max(10, value))
self.resistivity = self.change('DEV::HTR:RES', max(10.0, value))
if self._last_target is not None:
if not self.true_power:
self._volt_target = math.sqrt(self._last_target * self.resistivity)
self.change('HTR:SIG:VOLT', self._volt_target)
self.change('DEV::HTR:SIG:VOLT', self._volt_target, tolerance=2e-4)
return self.resistivity
def read_status(self):
@ -298,9 +315,9 @@ class HeaterOutput(HasInput, MercuryChannel, Writable):
if self._last_target is None: # on init
self.read_target()
if not self.true_power:
volt = self.query('HTR:SIG:VOLT')
return volt ** 2 / max(10, self.resistivity)
volt, current = self.multiquery('HTR:SIG', ('VOLT', 'CURR'))
volt = self.query('DEV::HTR:SIG:VOLT')
return volt ** 2 / max(10.0, self.resistivity)
volt, current = self.multiquery('DEV::HTR:SIG', ('VOLT', 'CURR'))
if volt > 0 and current > 0.0001 and self._last_target:
res = volt / current
tol = res * max(max(0.0003, abs(volt - self._volt_target)) / volt, 0.0001 / current, 0.0001)
@ -308,101 +325,135 @@ class HeaterOutput(HasInput, MercuryChannel, Writable):
self.write_resistivity(round(res, 1))
if self.controlled_by == 0:
self._volt_target = math.sqrt(self._last_target * self.resistivity)
self.change('HTR:SIG:VOLT', self._volt_target)
self.change('DEV::HTR:SIG:VOLT', self._volt_target, tolerance=2e-4)
return volt * current
def read_target(self):
if self.controlled_by != 0 and self.target:
return 0
if self._last_target is not None:
if self.controlled_by != 0 or self._last_target is not None:
# read back only when not yet initialized
return self.target
self._volt_target = self.query('HTR:SIG:VOLT')
self.resistivity = max(10, self.query('HTR:RES'))
self._last_target = self._volt_target ** 2 / max(10, self.resistivity)
self._volt_target = self.query('DEV::HTR:SIG:VOLT')
self.resistivity = max(10.0, self.query('DEV::HTR:RES'))
self._last_target = self._volt_target ** 2 / max(10.0, self.resistivity)
return self._last_target
def set_target(self, value):
def set_target(self, target):
"""set the target without switching to manual
might be used by a software loop
"""
self._volt_target = math.sqrt(value * self.resistivity)
self.change('HTR:SIG:VOLT', self._volt_target)
self._last_target = value
return value
self._volt_target = math.sqrt(target * self.resistivity)
self.change('DEV::HTR:SIG:VOLT', self._volt_target, tolerance=2e-4)
self._last_target = target
return target
def write_target(self, value):
self.write_controlled_by(SELF)
self.self_controlled()
return self.set_target(value)
class TemperatureLoop(TemperatureSensor, Loop, Drivable):
channel_type = 'TEMP,HTR'
output_module = Attached(HeaterOutput, mandatory=False)
ramp = Parameter('ramp rate', FloatRange(0, unit='K/min'), readonly=False)
class TemperatureLoop(TemperatureSensor, ConvLoop):
kind = 'TEMP'
output_module = Attached(HasInput, mandatory=False)
ramp = Parameter('ramp rate', FloatRange(0, unit='$/min'), readonly=False)
enable_ramp = Parameter('enable ramp rate', BoolType(), readonly=False)
setpoint = Parameter('working setpoint (differs from target when ramping)', FloatRange(0, unit='$'))
auto_flow = Parameter('enable auto flow', BoolType(), readonly=False)
status = Parameter(datatype=StatusType(Drivable, 'RAMPING')) # add ramping status
tolerance = Parameter(default=0.1)
_last_setpoint_change = None
__status = IDLE, ''
# overridden in subclass frappy_psi.triton.TemperatureLoop
ENABLE = 'TEMP:LOOP:ENAB'
ENABLE_RAMP = 'TEMP:LOOP:RENA'
RAMP_RATE = 'TEMP:LOOP:RSET'
def doPoll(self):
super().doPoll()
self.read_setpoint()
def read_control_active(self):
active = self.query('TEMP:LOOP:ENAB', off_on)
active = self.query(f'DEV::{self.ENABLE}', off_on)
self.set_output(active)
return active
def write_control_active(self, value):
self.set_output(value)
return self.change('TEMP:LOOP:ENAB', value, off_on)
if value:
raise RangeError('write to target to switch control on')
self.set_output(value, 'user')
return self.change(f'DEV::{self.ENABLE}', value, off_on)
@nopoll # polled by read_setpoint
def read_target(self):
if self.read_enable_ramp():
return self.target
self.setpoint = self.query('TEMP:LOOP:TSET')
self.setpoint = self.query('DEV::TEMP:LOOP:TSET')
return self.setpoint
def read_setpoint(self):
setpoint = self.query('TEMP:LOOP:TSET')
setpoint = self.query('DEV::TEMP:LOOP:TSET')
if self.enable_ramp:
if setpoint == self.setpoint:
if setpoint == self.target:
self.__ramping = False
elif setpoint == self.setpoint:
# update target when working setpoint does no longer change
if setpoint != self.target and self._last_setpoint_change is not None:
if self._last_setpoint_change is not None:
unchanged_since = time.time() - self._last_setpoint_change
if unchanged_since > max(12.0, 0.06 / max(1e-4, self.ramp)):
self.__ramping = False
self.target = self.setpoint
return setpoint
self._last_setpoint_change = time.time()
else:
self.__ramping = False
self.target = setpoint
return setpoint
def set_target(self, target):
self.change(f'DEV::{self.ENABLE}', True, off_on)
super().set_target(target)
def deactivate_control(self, source):
if self.__ramping:
self.__ramping = False
# stop ramping setpoint
self.change('DEV::TEMP:LOOP:TSET', self.read_setpoint(), lazy=True)
super().deactivate_control(source)
def ramping_state(self, state):
self.read_setpoint()
if self.__ramping:
return Retry
return self.convergence_approach
def write_target(self, value):
target = self.change('TEMP:LOOP:TSET', value)
target = self.change('DEV::TEMP:LOOP:TSET', value, lazy=True)
if self.enable_ramp:
self._last_setpoint_change = None
self.__ramping = True
self.set_target(value)
self.convergence_state.status = RAMPING, 'ramping'
self.read_status()
self.convergence_state.start(self.ramping_state)
else:
self.set_target(target)
self.convergence_start()
self.read_status()
return self.target
def read_enable_ramp(self):
return self.query('TEMP:LOOP:RENA', off_on)
return self.query(f'DEV::{self.ENABLE_RAMP}', off_on)
def write_enable_ramp(self, value):
return self.change('TEMP:LOOP:RENA', value, off_on)
def read_auto_flow(self):
return self.query('TEMP:LOOP:FAUT', off_on)
def write_auto_flow(self, value):
return self.change('TEMP:LOOP:FAUT', value, off_on)
if self.enable_ramp < value: # ramp_enable was off: start from current value
self.change('DEV::TEMP:LOOP:TSET', self.value, lazy=True)
result = self.change(f'DEV::{self.ENABLE_RAMP}', value, off_on)
if self.isDriving() and value != self.enable_ramp:
self.enable_ramp = value
self.write_target(self.target)
return result
def read_ramp(self):
result = self.query('TEMP:LOOP:RSET')
result = self.query(f'DEV::{self.RAMP_RATE}')
return min(9e99, result)
def write_ramp(self, value):
@ -411,23 +462,23 @@ class TemperatureLoop(TemperatureSensor, Loop, Drivable):
self.write_enable_ramp(0)
return 0
if value >= 9e99:
self.change('TEMP:LOOP:RSET', 'inf', as_string)
self.change(f'DEV::{self.RAMP_RATE}', 'inf', as_string)
self.write_enable_ramp(0)
return 9e99
self.write_enable_ramp(1)
return self.change('TEMP:LOOP:RSET', max(1e-4, value))
return self.change(f'DEV::{self.RAMP_RATE}', max(1e-4, value))
class PressureSensor(MercuryChannel, Readable):
channel_type = 'PRES'
kind = 'PRES'
value = Parameter(unit='mbar')
def read_value(self):
return self.query('PRES:SIG:PRES')
return self.query('DEV::PRES:SIG:PRES')
class ValvePos(HasInput, MercuryChannel, Drivable):
channel_type = 'PRES,AUX'
class ValvePos(HasInput, Drivable):
kind = 'PRES,AUX'
value = Parameter('value pos', FloatRange(unit='%'), readonly=False)
target = Parameter('valve pos target', FloatRange(0, 100, unit='$'), readonly=False)
@ -435,7 +486,7 @@ class ValvePos(HasInput, MercuryChannel, Drivable):
self.read_status()
def read_value(self):
return self.query('AUX:SIG:PERC')
return self.query(f'DEV:{self.slots["AUX"]}:AUX:SIG:PERC')
def read_status(self):
self.read_value()
@ -444,33 +495,83 @@ class ValvePos(HasInput, MercuryChannel, Drivable):
return 'BUSY', 'moving'
def read_target(self):
return self.query('PRES:LOOP:FSET')
return self.query('DEV::PRES:LOOP:FSET')
def write_target(self, value):
self.write_controlled_by(SELF)
return self.change('PRES:LOOP:FSET', value)
self.self_controlled()
return self.change('DEV::PRES:LOOP:FSET', value)
class PressureLoop(PressureSensor, Loop, Drivable):
channel_type = 'PRES,AUX'
class PressureLoop(PressureSensor, HasControlledBy, ConvLoop):
kind = 'PRES'
output_module = Attached(ValvePos, mandatory=False)
tolerance = Parameter(default=0.1)
def read_control_active(self):
active = self.query('PRES:LOOP:FAUT', off_on)
active = self.query('DEV::PRES:LOOP:FAUT', off_on)
self.set_output(active)
return active
def write_control_active(self, value):
self.set_output(value)
return self.change('PRES:LOOP:FAUT', value, off_on)
self.set_output(value, 'user')
return self.change('DEV::PRES:LOOP:FAUT', value, off_on)
def read_target(self):
return self.query('PRES:LOOP:PRST')
return self.query('DEV::PRES:LOOP:PRST')
def set_target(self, target):
"""set the target without switching to manual
might be used by a software loop
"""
self.change('DEV::PRES:LOOP:PRST', target)
super().set_target(target)
def write_target(self, value):
target = self.change('PRES:LOOP:PRST', value)
self.set_target(target)
return self.target
self.self_controlled()
self.set_target(value)
return value
class HasAutoFlow:
needle_valve = Attached(PressureLoop, mandatory=False)
auto_flow = Parameter('enable auto flow', BoolType(), readonly=False, default=0)
flowpars = Parameter('Tdif(min, max), FlowSet(min, max)',
TupleOf(TupleOf(FloatRange(unit='K'), FloatRange(unit='K')),
TupleOf(FloatRange(unit='mbar'), FloatRange(unit='mbar'))),
readonly=False, default=((1, 5), (4, 20)))
def read_value(self):
value = super().read_value()
if self.auto_flow:
(dmin, dmax), (fmin, fmax) = self.flowpars
flowset = min(dmax - dmin, max(0, value - self.target - dmin)) / (dmax - dmin) * (fmax - fmin) + fmin
self.needle_valve.set_target(flowset)
return value
def initModule(self):
super().initModule()
if self.needle_valve:
self.needle_valve.register_input(self.name, self.auto_flow_off)
def write_auto_flow(self, value):
if self.needle_valve:
if value:
self.needle_valve.controlled_by = self.name
else:
if self.needle_valve.controlled_by != SELF:
self.needle_valve.controlled_by = SELF
self.needle_valve.write_target(self.flowpars[1][0]) # flow min
return value
def auto_flow_off(self):
if self.auto_flow:
self.log.warning('switch auto flow off')
self.write_auto_flow(False)
class TemperatureAutoFlow(HasAutoFlow, TemperatureLoop):
pass
class HeLevel(MercuryChannel, Readable):
@ -479,7 +580,8 @@ class HeLevel(MercuryChannel, Readable):
The Mercury system does not support automatic switching between fast
(when filling) and slow (when consuming). We have to handle this by software.
"""
channel_type = 'LVL'
kind = 'LVL'
value = Parameter(unit='%')
sample_rate = Parameter('_', EnumType(slow=0, fast=1), readonly=False)
hysteresis = Parameter('hysteresis for detection of increase', FloatRange(0, 100, unit='%'),
default=5, readonly=False)
@ -505,14 +607,14 @@ class HeLevel(MercuryChannel, Readable):
return sample_rate
def read_sample_rate(self):
return self.check_rate(self.query('LVL:HEL:PULS:SLOW', fast_slow))
return self.check_rate(self.query('DEV::LVL:HEL:PULS:SLOW', fast_slow))
def write_sample_rate(self, value):
self.check_rate(value)
return self.change('LVL:HEL:PULS:SLOW', value, fast_slow)
return self.change('DEV::LVL:HEL:PULS:SLOW', value, fast_slow)
def read_value(self):
level = self.query('LVL:SIG:HEL:LEV')
level = self.query('DEV::LVL:SIG:HEL:LEV')
# handle automatic switching depending on increase
now = time.time()
if self._last_increase: # fast mode
@ -532,10 +634,8 @@ class HeLevel(MercuryChannel, Readable):
class N2Level(MercuryChannel, Readable):
channel_type = 'LVL'
kind = 'LVL'
value = Parameter(unit='%')
def read_value(self):
return self.query('LVL:SIG:NIT:LEV')
# TODO: magnet power supply
return self.query('DEV::LVL:SIG:NIT:LEV')

288
frappy_psi/phytron.py Normal file
View File

@ -0,0 +1,288 @@
# -*- 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:
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""driver for phytron motors
limits switches are not yet implemented
"""
import time
from frappy.core import Done, Command, EnumType, FloatRange, IntRange, \
HasIO, Parameter, Property, Drivable, PersistentMixin, PersistentParam, \
StringIO, StringType, IDLE, BUSY, ERROR, Limit
from frappy.errors import CommunicationFailedError, HardwareError
from frappy.features import HasOffset
from frappy.states import HasStates, status_code, Retry
class PhytronIO(StringIO):
end_of_line = '\x03' # ETX
timeout = 0.5
identification = [('0IVR', 'MCC Minilog .*')]
def communicate(self, command):
ntry = 5
warn = None
for itry in range(ntry):
try:
_, _, reply = super().communicate('\x02' + command).partition('\x02')
if reply[0] == '\x06': # ACK
break
raise CommunicationFailedError(f'missing ACK {reply!r} (cmd: {command!r})')
except Exception as e:
if itry < ntry - 1:
warn = e
else:
raise
if warn:
self.log.warning('needed %d retries after %r', itry, warn)
return reply[1:]
class Motor(HasOffset, HasStates, PersistentMixin, HasIO, Drivable):
axis = Property('motor axis X or Y', StringType(), default='X')
address = Property('address', IntRange(0, 15), default=0)
circumference = Property('cirumference for rotations or zero for linear', FloatRange(0), default=360)
encoder_mode = Parameter('how to treat the encoder', EnumType('encoder', NO=0, READ=1, CHECK=2),
default=1, readonly=False)
value = PersistentParam('angle', FloatRange(unit='deg'))
status = PersistentParam()
target = Parameter('target angle', FloatRange(unit='deg'), readonly=False)
speed = Parameter('', FloatRange(0, 20, unit='deg/s'), readonly=False)
accel = Parameter('', FloatRange(2, 250, unit='deg/s/s'), readonly=False)
encoder_tolerance = Parameter('', FloatRange(unit='deg'), readonly=False, default=0.01)
sign = PersistentParam('', IntRange(-1,1), readonly=False, default=1)
encoder = Parameter('encoder reading', FloatRange(unit='deg'))
backlash = PersistentParam("""backlash compensation\n
offset for always approaching from the same side""",
FloatRange(unit='deg'), readonly=False, default=0)
target_min = Limit()
target_max = Limit()
alive_time = PersistentParam('alive time for detecting restarts',
FloatRange(), default=0, export=False)
ioClass = PhytronIO
_step_size = None # degree / step
_blocking_error = None # None or a string indicating the reason of an error needing reset
_running = False # status indicates motor is running
STATUS_MAP = {
'08': (IDLE, ''),
'01': (ERROR, 'power stage failure'),
'02': (ERROR, 'power too low'),
'04': (ERROR, 'power stage over temperature'),
'07': (ERROR, 'no power stage'),
'80': (ERROR, 'encoder failure'),
}
def get(self, cmd):
return self.communicate(f'{self.address:x}{self.axis}{cmd}')
def set(self, cmd, value):
# make sure e format is not used, max 8 characters
strvalue = f'{value:.6g}'
if 'e' in strvalue:
if abs(value) <= 1: # very small number
strvalue = f'{value:.7f}'
elif abs(value) < 99999999: # big number
strvalue = f'{value:.0f}'
else:
raise ValueError(f'number ({value}) must not have more than 8 digits')
self.communicate(f'{self.address:x}{self.axis}{cmd}{strvalue}')
def set_get(self, cmd, value, query):
self.set(cmd, value)
return self.get(query)
def read_alive_time(self):
now = time.time()
axisbit = 1 << int(self.axis == 'Y')
active_axes = int(self.get('P37R')) # adr 37 is a custom address with no internal meaning
if not axisbit & active_axes: # power cycle detected and this axis not yet active
self.set('P37S', axisbit | active_axes) # activate axis
if now < self.alive_time + 7 * 24 * 3600: # the device was running within last week
# inform the user about the loss of position by the need of doing reset_error
self._blocking_error = 'lost position'
else: # do reset silently
self.reset_error()
self.alive_time = now
self.saveParameters()
return now
def read_value(self):
return float(self.get('P20R')) * self.sign
def read_encoder(self):
if self.encoder_mode == 'NO':
return self.value
return float(self.get('P22R')) * self.sign
def write_sign(self, value):
self.sign = value
self.saveParameters()
return Done
def get_step_size(self):
self._step_size = float(self.get('P03R'))
def read_speed(self):
if self._step_size is None:
# avoid repeatedly reading step size, as this is polled and will not change
self.get_step_size()
return float(self.get('P14R')) * self._step_size
def write_speed(self, value):
self.get_step_size() # read step size anyway, it does not harm
return float(self.set_get('P14S', round(value / self._step_size), 'P14R')) * self._step_size
def read_accel(self):
if not self._step_size:
self.get_step_size()
return float(self.get('P15R')) * self._step_size
def write_accel(self, value):
self.get_step_size()
return float(self.set_get('P15S', round(value / self._step_size), 'P15R')) * self._step_size
def check_target(self, value):
self.checkLimits(value)
self.checkLimits(value + self.backlash)
def write_target(self, value):
self.read_alive_time()
if self._blocking_error:
self.status = ERROR, 'reset needed after ' + self._blocking_error
raise HardwareError(self.status[1])
self.saveParameters()
if self.backlash:
# drive first to target + backlash
# we do not optimize when already driving from the right side
self.set('A', self.sign * (value + self.backlash))
else:
self.set('A', self.sign * value)
self.start_machine(self.driving)
self._running = True
return value
def read_status(self):
sysstatus = self.communicate(f'{self.address:x}SE')
sysstatus = sysstatus[1:4] if self.axis == 'X' else sysstatus[5:8]
self._running = sysstatus[0] != '1'
status = self.STATUS_MAP.get(sysstatus[1:]) or (ERROR, f'unknown error {sysstatus[1:]}')
if status[0] == ERROR:
self._blocking_error = status[1]
return status
return super().read_status() # status from state machine
def check_moving(self):
prev_enc = self.encoder
if self.encoder_mode != 'NO':
enc = self.read_encoder()
else:
enc = self.value
if not self._running: # at target
return False
if self.encoder_mode != 'CHECK':
return True
e1, e2 = sorted((prev_enc, enc))
if e1 - self.encoder_tolerance <= self.value <= e2 + self.encoder_tolerance:
return True
self.log.error('encoder lag: %g not within %g..%g',
self.value, e1, e2)
self.get('S') # stop
self.saveParameters()
self._blocking_error = 'encoder lag error'
raise HardwareError(self._blocking_error)
@status_code(BUSY)
def driving(self, sm):
if self.check_moving():
return Retry
if self.backlash:
# drive to real target
self.set('A', self.sign * self.target)
return self.driving_to_final_position
return self.finishing
@status_code(BUSY)
def driving_to_final_position(self, sm):
if self.check_moving():
return Retry
return self.finishing
@status_code(BUSY)
def finishing(self, sm):
if sm.init:
sm.mismatch_count = 0
# finish
pos = self.read_value()
enc = self.read_encoder()
if (self.encoder_mode == 'CHECK' and
abs(enc - pos) > self.encoder_tolerance):
if sm.mismatch_count > 2:
self.log.error('encoder mismatch: abs(%g - %g) < %g',
enc, pos, self.encoder_tolerance)
self._blocking_error = 'encoder does not match pos'
raise HardwareError(self._blocking_error)
sm.mismatch_count += 1
return Retry
self.saveParameters()
return self.final_status(IDLE, '')
@status_code(BUSY)
def stopping(self, sm):
if self._running:
return Retry
return self.final_status(IDLE, 'stopped')
@Command
def stop(self):
self.get('S')
self.start_machine(self.stopping, status=(BUSY, 'stopping'))
@Command
def reset_error(self):
"""Reset error, set position to encoder"""
self.read_value()
if self._blocking_error:
newenc = enc = self.read_encoder()
pos = self.value
if abs(enc - pos) > self.encoder_tolerance or self.encoder_mode == 'NO':
if self.circumference:
# bring encoder value either within or as close as possible to the given range
if enc < self.target_min:
mid = self.target_min + 0.5 * min(self.target_max - self.target_min, self.circumference)
elif enc > self.target_max:
mid = self.target_max - 0.5 * min(self.target_max - self.target_min, self.circumference)
else:
mid = enc
newenc += round((mid - enc) / self.circumference) * self.circumference
if newenc != enc and self.encoder_mode != 'NO':
self.log.info(f'enc {enc} -> {newenc}')
self.set('P22S', newenc * self.sign)
if newenc != pos:
self.log.info(f'pos {pos} -> {newenc}')
self.set('P20S', newenc * self.sign) # set pos to encoder
self.read_value()
self.status = 'IDLE', 'after error reset'
self._blocking_error = None

View File

@ -188,7 +188,7 @@ class PpmsSim:
else:
self.status.pos = 5
self.st = sum([self.status[i] << (i * 4) for i in range(4)])
self.st = sum((self.status[i] << (i * 4) for i in range(4)))
self.r1 = self.t * 0.1
self.i1 = self.t % 10.0
self.r2 = 1000 / self.t

700
frappy_psi/sea.py Normal file
View File

@ -0,0 +1,700 @@
# -*- 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:
# Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
"""generic SEA driver
a object or subobject in sea may be assigned to a SECoP module
Examples:
SECoP SEA hipadaba path mod.obj mod.sub par.sub mod.path
-------------------------------------------------------------------------------
tt:maxwait tt /tt/maxwait tt maxwait /tt
tt:ramp tt set/ramp /tt/set/ramp tt set/ramp /tt
t1:raw tt t1/raw /tt/t1/raw tt t1 raw /tt/t1
rx:bla rx bla /some/rx_a/bla rx bla /some/rx_a
"""
import json
import threading
import time
import os
from os.path import expanduser, join, exists
from frappy.client import ProxyClient
from frappy.datatypes import ArrayOf, BoolType, \
EnumType, FloatRange, IntRange, StringType
from frappy.errors import ConfigError, HardwareError, secop_error, CommunicationFailedError
from frappy.lib import generalConfig, mkthread
from frappy.lib.asynconn import AsynConn, ConnectionClosed
from frappy.modules import Attached, Command, Done, Drivable, \
Module, Parameter, Property, Readable, Writable
from frappy.protocol.dispatcher import make_update
CFG_HEADER = """Node(
description = '''%(nodedescr)s''',
id = %(config)s.sea.psi.ch,
)
Mod(%(seaconn)r,
'frappy_psi.sea.SeaClient',
'%(service)s sea connection for %(config)s',
config = %(config)r,
service = %(service)r,
)
"""
CFG_MODULE = """Mod(%(module)r,
'frappy_psi.sea.%(modcls)s', '',
io = %(seaconn)r,
sea_object = %(module)r,
)
"""
SERVICE_NAMES = {
'config': 'main',
'stick': 'stick',
'addon': 'addons',
}
SEA_DIR = expanduser('~/sea')
seaconfdir = os.environ.get('FRAPPY_SEA_DIR')
if not exists(seaconfdir):
for confdir in generalConfig.confdir.split(os.pathsep):
seaconfdir = join(confdir, 'sea')
if exists(seaconfdir):
break
def get_sea_port(instance):
for filename in ('sea_%s.tcl' % instance, 'sea.tcl'):
try:
with open(join(SEA_DIR, filename), encoding='utf-8') as f:
for line in f:
linesplit = line.split()
if len(linesplit) == 3:
_, var, value = line.split()
if var == 'serverport':
return value
except FileNotFoundError:
pass
return None
class SeaClient(ProxyClient, Module):
"""connection to SEA"""
uri = Parameter('hostname:portnumber', datatype=StringType(), default='localhost:5000')
timeout = Parameter('timeout', datatype=FloatRange(0), default=10)
config = Property("""needed SEA configuration, space separated
Example: "ori4.config ori4.stick"
""", StringType(), default='')
service = Property("main/stick/addons", StringType(), default='')
visibility = 'expert'
default_json_file = {}
_connect_thread = None
_service_manager = None
_instance = None
def __init__(self, name, log, opts, srv):
nodename = srv.node_cfg.get('name') or srv.node_cfg.get('equipment_id')
instance = nodename.rsplit('_', 1)[0]
if 'uri' not in opts:
self._instance = instance
port = get_sea_port(instance)
if port is None:
raise ConfigError('missing sea port for %s' % instance)
opts['uri'] = {'value': 'tcp://localhost:%s' % port}
self.objects = set()
self.shutdown = False
self.path2param = {}
self._write_lock = threading.Lock()
config = opts.get('config')
if isinstance(config, dict):
config = config['value']
if config:
self.default_json_file[name] = config.split()[0] + '.json'
self.syncio = None
self.asynio = None
ProxyClient.__init__(self)
Module.__init__(self, name, log, opts, srv)
def register_obj(self, module, obj):
self.objects.add(obj)
for k, v in module.path2param.items():
self.path2param.setdefault(k, []).extend(v)
self.register_callback(module.name, module.updateEvent)
def startModule(self, start_events):
super().startModule(start_events)
self._connect_thread = mkthread(self._connect, start_events.get_trigger())
def _connect(self, started_callback):
if self._instance:
if not self._service_manager:
if self._service_manager is None:
try:
from servicemanager import SeaManager # pylint: disable=import-outside-toplevel
self._service_manager = SeaManager()
except ImportError:
self._service_manager = False
if self._service_manager:
self._service_manager.do_start(self._instance)
if '//' not in self.uri:
self.uri = 'tcp://' + self.uri
self.asynio = AsynConn(self.uri)
reply = self.asynio.readline()
if reply != b'OK':
raise CommunicationFailedError('reply %r should be "OK"' % reply)
for _ in range(2):
self.asynio.writeline(b'Spy 007')
reply = self.asynio.readline()
if reply == b'Login OK':
break
else:
raise CommunicationFailedError('reply %r should be "Login OK"' % reply)
self.request('frappy_config %s %s' % (self.service, self.config))
# frappy_async_client switches to the json protocol (better for updates)
self.asynio.writeline(b'frappy_async_client')
self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode())
self._connect_thread = None
mkthread(self._rxthread, started_callback)
def request(self, command, quiet=False):
"""send a request and wait for reply"""
with self._write_lock:
if not self.syncio or not self.syncio.connection:
if not self.asynio or not self.asynio.connection:
try:
self._connect_thread.join()
except AttributeError:
pass
self._connect(None)
self.syncio = AsynConn(self.uri)
assert self.syncio.readline() == b'OK'
self.syncio.writeline(b'seauser seaser')
assert self.syncio.readline() == b'Login OK'
self.log.info('connected to %s', self.uri)
self.syncio.flush_recv()
ft = 'fulltransAct' if quiet else 'fulltransact'
self.syncio.writeline(('%s %s' % (ft, command)).encode())
result = None
deadline = time.time() + 10
while time.time() < deadline:
try:
reply = self.syncio.readline()
if reply is None:
continue
except ConnectionClosed:
break
reply = reply.decode()
if reply.startswith('TRANSACTIONSTART'):
result = []
continue
if reply == 'TRANSACTIONFINISHED':
if result is None:
self.log.info('missing TRANSACTIONSTART on: %s', command)
return ''
if not result:
return ''
return '\n'.join(result)
if result is None:
self.log.info('swallow: %s', reply)
continue
if not result:
result = [reply.split('=', 1)[-1]]
else:
result.append(reply)
raise TimeoutError('no response within 10s')
def _rxthread(self, started_callback):
while not self.shutdown:
try:
reply = self.asynio.readline()
if reply is None:
continue
except ConnectionClosed:
break
try:
msg = json.loads(reply)
except Exception as e:
self.log.warn('bad reply %r %r', e, reply)
continue
if isinstance(msg, str):
if msg.startswith('_E '):
try:
_, path, readerror = msg.split(None, 2)
except ValueError:
continue
else:
continue
data = {'%s.geterror' % path: readerror.replace('ERROR: ', '')}
obj = None
flag = 'hdbevent'
else:
obj = msg['object']
flag = msg['flag']
data = msg['data']
if flag == 'finish' and obj == 'get_all_param':
# first updates have finished
if started_callback:
started_callback()
started_callback = None
continue
if flag != 'hdbevent':
if obj not in ('frappy_async_client', 'get_all_param'):
self.log.debug('skip %r', msg)
continue
if not data:
continue
if not isinstance(data, dict):
self.log.debug('what means %r', msg)
continue
now = time.time()
for path, value in data.items():
readerror = None
if path.endswith('.geterror'):
if value:
readerror = HardwareError(value)
path = path.rsplit('.', 1)[0]
value = None
mplist = self.path2param.get(path)
if mplist is None:
if path.startswith('/device'):
if path == '/device/changetime':
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()
elif path.startswith('/device/frappy_%s' % self.service) and value == '':
self.DISPATCHER.shutdown()
else:
for module, param in mplist:
oldv, oldt, oldr = self.cache.get((module, param), [None, None, None])
if value is None:
value = oldv
if value != oldv or str(readerror) != str(oldr) or abs(now - oldt) > 60:
# do not update unchanged values within 60 sec
self.updateValue(module, param, value, now, readerror)
@Command(StringType(), result=StringType())
def communicate(self, command):
"""send a command to SEA"""
reply = self.request(command)
return reply
@Command(StringType(), result=StringType())
def query(self, cmd, quiet=False):
"""a request checking for errors and accepting 0 or 1 line as result"""
errors = []
reply = None
for line in self.request(cmd, quiet).split('\n'):
if line.strip().startswith('ERROR:'):
errors.append(line[6:].strip())
elif reply is None:
reply = line.strip()
else:
self.log.info('SEA: superfluous reply %r to %r', reply, cmd)
if errors:
raise HardwareError('; '.join(errors))
return reply
class SeaConfigCreator(SeaClient):
def startModule(self, start_events):
"""save objects (and sub-objects) description and exit"""
self._connect(None)
reply = self.request('describe_all')
reply = ''.join('' if line.startswith('WARNING') else line for line in reply.split('\n'))
description, reply = json.loads(reply)
modules = {}
modcls = {}
for filename, obj, descr in reply:
if filename not in modules:
modules[filename] = {}
if descr['params'][0].get('cmd', '').startswith('run '):
modcls[obj] = 'SeaDrivable'
elif not descr['params'][0].get('readonly', True):
modcls[obj] = 'SeaWritable'
else:
modcls[obj] = 'SeaReadable'
modules.setdefault(filename, {})[obj] = descr
result = []
for filename, descr in modules.items():
stripped, _, ext = filename.rpartition('.')
service = SERVICE_NAMES[ext]
seaconn = 'sea_' + service
cfgfile = join(seaconfdir, stripped + '_cfg.py')
with open(cfgfile, 'w', encoding='utf-8') as fp:
fp.write(CFG_HEADER % {'config': filename, 'seaconn': seaconn, 'service': service,
'nodedescr': description.get(filename, filename)})
for obj in descr:
fp.write(CFG_MODULE % {'modcls': modcls[obj], 'module': obj, 'seaconn': seaconn})
content = json.dumps(descr).replace('}, {', '},\n{').replace('[{', '[\n{').replace('}]}, ', '}]},\n\n')
result.append('%s\n' % cfgfile)
with open(join(seaconfdir, filename + '.json'), 'w', encoding='utf-8') as fp:
fp.write(content + '\n')
result.append('%s: %s' % (filename, ','.join(n for n in descr)))
raise SystemExit('; '.join(result))
@Command(StringType(), result=StringType())
def query(self, cmd, quiet=False):
"""a request checking for errors and accepting 0 or 1 line as result"""
errors = []
reply = None
for line in self.request(cmd).split('\n'):
if line.strip().startswith('ERROR:'):
errors.append(line[6:].strip())
elif reply is None:
reply = line.strip()
else:
self.log.info('SEA: superfluous reply %r to %r', reply, cmd)
if errors:
raise HardwareError('; '.join(errors))
return reply
SEA_TO_SECOPTYPE = {
'float': FloatRange(),
'text': StringType(),
'int': IntRange(-1 << 63, 1 << 63 - 1),
'bool': BoolType(),
'none': None,
'floatvarar': ArrayOf(FloatRange(), 0, 400), # 400 is the current limit for proper notify events in SEA
}
def get_datatype(paramdesc):
typ = paramdesc['type']
result = SEA_TO_SECOPTYPE.get(typ, False)
if result is not False: # general case
return result
# special cases
if typ == 'enum':
return EnumType(paramdesc['enum'])
raise ValueError('unknown SEA type %r' % typ)
class SeaModule(Module):
io = Attached()
path2param = None
sea_object = None
hdbpath = None # hdbpath for main writable
# pylint: disable=too-many-statements,arguments-differ,too-many-branches
def __new__(cls, name, logger, cfgdict, srv):
if hasattr(srv, 'extra_sea_modules'):
extra_modules = srv.extra_sea_modules
else:
extra_modules = {}
srv.extra_sea_modules = extra_modules
for k, v in cfgdict.items():
try:
cfgdict[k] = v['value']
except (KeyError, TypeError):
pass
json_file = cfgdict.pop('json_file', None) or SeaClient.default_json_file[cfgdict['io']]
visibility_level = cfgdict.pop('visibility_level', 2)
single_module = cfgdict.pop('single_module', None)
if single_module:
sea_object, base, paramdesc = extra_modules[single_module]
params = [paramdesc]
paramdesc['key'] = 'value'
if issubclass(cls, SeaWritable):
if paramdesc.get('readonly', True):
raise ConfigError(f"{sea_object}/{paramdesc['path']} is not writable")
params.insert(0, paramdesc.copy()) # copy value
paramdesc['key'] = 'target'
paramdesc['readonly'] = False
extra_module_set = ()
if 'description' not in cfgdict:
cfgdict['description'] = f'{single_module}@{json_file}'
else:
sea_object = cfgdict.pop('sea_object')
rel_paths = cfgdict.pop('rel_paths', '.')
if 'description' not in cfgdict:
cfgdict['description'] = '%s@%s%s' % (
name, json_file, '' if rel_paths == '.' else f' (rel_paths={rel_paths})')
with open(join(seaconfdir, json_file), encoding='utf-8') as fp:
content = json.load(fp)
descr = content[sea_object]
if rel_paths == '*' or not rel_paths:
# take all
main = descr['params'][0]
if issubclass(cls, Readable):
# assert main['path'] == '' # TODO: check cases where this fails
main['key'] = 'value'
else:
descr['params'].pop(0)
else:
# filter by relative paths
# rel_paths = rel_paths.split()
result = []
is_running = None
for rpath in rel_paths:
include = True
for paramdesc in descr['params']:
path = paramdesc['path']
if path.endswith('is_running') and issubclass(cls, Drivable):
# take this independent of visibility
is_running = paramdesc
continue
if paramdesc.get('visibility', 1) > visibility_level:
continue
sub = path.split('/', 1)
if rpath == '.': # take all except subpaths with readonly node at top
if len(sub) == 1:
include = paramdesc.get('kids', 0) == 0 or not paramdesc.get('readonly', True)
if include or path == '':
result.append(paramdesc)
elif sub[0] == rpath:
result.append(paramdesc)
if is_running: # take this at end
result.append(is_running)
descr['params'] = result
rel0 = '' if rel_paths[0] == '.' else rel_paths[0]
if result[0]['path'] == rel0:
if issubclass(cls, Readable):
result[0]['key'] = 'value'
else:
result.pop(0)
else:
logger.error('%s: no value found', name)
base = descr['base']
params = descr['params']
if issubclass(cls, SeaWritable):
paramdesc = params[0]
assert paramdesc['key'] == 'value'
params.append(paramdesc.copy()) # copy value?
if paramdesc.get('readonly', True):
raise ConfigError(f"{sea_object}/{paramdesc['path']} is not writable")
paramdesc['key'] = 'target'
paramdesc['readonly'] = False
extra_module_set = cfgdict.pop('extra_modules', ())
if extra_module_set:
extra_module_set = set(extra_module_set.replace(',', ' ').split())
path2param = {}
attributes = {'sea_object': sea_object, 'path2param': path2param}
# some guesses about visibility (may be overriden in *_cfg.py):
if sea_object in ('table', 'cc'):
attributes['visibility'] = 2
elif base.count('/') > 1:
attributes['visibility'] = 2
for paramdesc in params:
path = paramdesc['path']
readonly = paramdesc.get('readonly', True)
dt = get_datatype(paramdesc)
kwds = {'description': paramdesc.get('description', path),
'datatype': dt,
'visibility': paramdesc.get('visibility', 1),
'needscfg': False, 'readonly': readonly}
if kwds['datatype'] is None:
kwds.update(visibility=3, default='', datatype=StringType())
pathlist = path.split('/') if path else []
key = paramdesc.get('key') # None, 'value' or 'target'
if key is None:
if len(pathlist) > 0:
if len(pathlist) == 1:
if issubclass(cls, Readable):
kwds['group'] = 'more'
else:
kwds['group'] = pathlist[-2]
# flatten path to parameter name
for i in reversed(range(len(pathlist))):
key = '_'.join(pathlist[i:])
if not key in cls.accessibles:
break
if key == 'is_running':
kwds['export'] = False
if key == 'target' and kwds.get('group') == 'more':
kwds.pop('group')
if key in cls.accessibles:
if key == 'target':
kwds['readonly'] = False
prev = cls.accessibles[key]
if key == 'status':
# special case: status from sea is a string, not the status tuple
pobj = prev.copy()
else:
pobj = Parameter(**kwds)
merged_properties = prev.propertyValues.copy()
pobj.updateProperties(merged_properties)
pobj.merge(merged_properties)
else:
pobj = Parameter(**kwds)
datatype = pobj.datatype
if issubclass(cls, SeaWritable) and key == 'target':
kwds['readonly'] = False
attributes['value'] = Parameter(**kwds)
hdbpath = '/'.join([base] + pathlist)
if key in extra_module_set:
extra_modules[name + '.' + key] = sea_object, base, paramdesc
continue # skip this parameter
path2param.setdefault(hdbpath, []).append((name, key))
attributes[key] = pobj
def rfunc(self, cmd=f'hval {base}/{path}'):
reply = self.io.query(cmd, True)
try:
reply = float(reply)
except ValueError:
pass
# an updateEvent will be handled before above returns
return reply
rfunc.poll = False
attributes['read_' + key] = rfunc
if not readonly:
def wfunc(self, value, datatype=datatype, command=paramdesc['cmd']):
value = datatype.export_value(value)
if isinstance(value, bool):
value = int(value)
# TODO: check if more has to be done for valid tcl data (strings?)
cmd = f'{command} {value}'
self.io.query(cmd)
return Done
attributes['write_' + key] = wfunc
# create standard parameters like value and status, if not yet there
for pname, pobj in cls.accessibles.items():
if pname == 'pollinterval':
pobj.export = False
attributes[pname] = pobj
pobj.__set_name__(cls, pname)
elif pname not in attributes and isinstance(pobj, Parameter):
pobj.needscfg = False
attributes[pname] = pobj
pobj.__set_name__(cls, pname)
classname = f'{cls.__name__}_{name}'
newcls = type(classname, (cls,), attributes)
result = Module.__new__(newcls)
return result
def updateEvent(self, module, parameter, value, timestamp, readerror):
upd = getattr(self, 'update_' + parameter, None)
if upd:
upd(value, timestamp, readerror)
return
try:
pobj = self.parameters[parameter]
except KeyError:
self.log.error('do not know %s:%s', self.name, parameter)
raise
pobj.timestamp = timestamp
# should be done here: deal with clock differences
if not readerror:
try:
pobj.value = value # store the value even in case of a validation error
pobj.value = pobj.datatype(value)
except Exception as e:
readerror = secop_error(e)
pobj.readerror = readerror
if pobj.export:
self.DISPATCHER.broadcast_event(make_update(self.name, pobj))
def initModule(self):
self.io.register_obj(self, self.sea_object)
super().initModule()
def doPoll(self):
pass
class SeaReadable(SeaModule, Readable):
def update_status(self, value, timestamp, readerror):
if readerror:
value = repr(readerror)
if value == '':
self.status = (self.Status.IDLE, '')
else:
self.status = (self.Status.ERROR, value)
def read_status(self):
return self.status
class SeaWritable(SeaModule, Writable):
def read_value(self):
return self.target
def update_target(self, value, timestamp, readerror):
self.target = value
if not readerror:
self.value = value
class SeaDrivable(SeaModule, Drivable):
_sea_status = ''
_is_running = 0
def read_status(self):
return self.status
# def read_target(self):
# return self.target
def write_target(self, value):
self.io.query(f'run {self.sea_object} {value}')
# self.status = [self.Status.BUSY, 'driving']
return value
def update_status(self, value, timestamp, readerror):
if not readerror:
self._sea_status = value
self.updateStatus()
def update_is_running(self, value, timestamp, readerror):
if not readerror:
self._is_running = value
self.updateStatus()
def updateStatus(self):
if self._sea_status:
self.status = (self.Status.ERROR, self._sea_status)
elif self._is_running:
self.status = (self.Status.BUSY, 'driving')
else:
self.status = (self.Status.IDLE, '')
def updateTarget(self, module, parameter, value, timestamp, readerror):
if value is not None:
self.target = value
@Command()
def stop(self):
"""propagate to SEA
- on stdsct drivables this will call the halt script
- on EaseDriv this will set the stopped state
"""
self.io.query(f'{self.sea_object} is_running 0')

307
frappy_psi/senis.py Normal file
View File

@ -0,0 +1,307 @@
#!/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
# 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>
# *****************************************************************************
"""senis hall sensor"""
import threading
import time
import numpy as np
from serial import Serial
from frappy.core import Attached, BoolType, FloatRange, IntRange, \
Parameter, Property, Readable, StringType, TupleOf
class Temperature(Readable):
enablePoll = False
value = Parameter(datatype=FloatRange(unit='degC'))
class Bcomp(Readable):
enablePoll = False
value = Parameter(datatype=FloatRange(unit='T'))
range = Parameter('working range', FloatRange(unit='T'), default=0)
class Raw(Readable):
enablePoll = False
value = Parameter(datatype=FloatRange())
class TeslameterBase(Readable):
"""code for both models
the protocol is somewhat weird as the read command 'B' initiates a permanent update
which has to be stopped in between the value polls and for other communication.
the B components (and temperatures for 3MH6) are implemented as separate modules
"""
x = Attached()
y = Attached()
z = Attached()
value = Parameter('B vector',
datatype=TupleOf(FloatRange(unit='T'), FloatRange(unit='T'), FloatRange(unit='T')))
usb = Parameter('usb device', StringType(), readonly=False)
enabled = Parameter('enable data acq', datatype=BoolType(), readonly=False, default=True)
nsample = Parameter('number of samples for average', datatype=IntRange(1, 1000), readonly=False, default=1)
def init_serial(self, baud):
self._conn = Serial(self.usb, baud, timeout=0.1)
self._lock = threading.Lock()
self.stop_reading()
def write_bytes(self, msg):
with self._lock:
self._conn.write(msg)
def read_bytes(self, cnt):
with self._lock:
return self._conn.read(cnt)
def stop_reading(self):
self.write_bytes(b'S')
self.read_bytes(9999) # swallow bytes until timeout
def write_enabled(self, value):
if value:
self.status = self.Status.IDLE, ''
else:
self.status = self.Status.DISABLED, 'disabled'
self._x.status = self._y.status = self._z.status = self.status
return value
class Teslameter3MH3(TeslameterBase):
"""simpler model without temperature and auto range
remark: no query for the sample rate is possible, therefore set always to
a default rate (therefore initwrite=True on the rate parameter)
"""
range = Property('full scale', datatype=FloatRange(), default=2)
def earlyInit(self):
self.init_serial(115200)
self.write_bytes(b'C') # put into calibrated mode
if self.read_bytes(1) != b'!':
self.log.error('missing response to C command')
self.write_bytes(b'A\x80\r') # set to 90 SPS
self.read_bytes(1) # wait 0.1 sec as we get no reply
def read_value(self):
if not self.enabled:
return self.value
s = self._conn
s.timeout = 0.1 + 0.02 * self.nsample
for _ in range(2):
self.write_bytes(b'B')
# t = time.time()
reply = self.read_bytes(8 * self.nsample)
s.timeout = 0.1
self.stop_reading()
remainder = len(reply) % 8
if remainder:
reply = reply[:-remainder]
if not reply:
continue
data = np.frombuffer(reply, dtype='i1,3<i2,i1')
# first byte must be 'B' and last byte must be CR
if np.all(data['f0'] == ord(b'B')) and np.all(data['f2'] == 13):
break
else:
self.status = self.Status.ERROR, 'bad reply'
raise ValueError('bad reply')
self.status = self.Status.IDLE, ''
value = np.average(data['f1'], axis=0) * self.range / 20000.
self._x.value, self._y.value, self._z.value = value
self._x.range = self._y.range =self._z.range = self.range
return value
class Teslameter3MH6(TeslameterBase):
"""luxury model with probe and box temperature and autorange"""
x_direct = Attached()
y_direct = Attached()
z_direct = Attached()
probe_temp = Attached()
box_temp = Attached()
probe_temp_direct = Attached()
box_temp_direct = Attached()
range = Parameter('range or 0 for autorange', FloatRange(0, 20, unit='T'), readonly=False, default=0)
rate = Parameter('sampling rate', datatype=FloatRange(10, 15000, unit='Hz'),
readonly=False)
avtime = Parameter('data acquisition time', FloatRange(), default=0)
SAMPLING_RATES = {0xe0: 15000, 0xd0: 7500, 0xc0: 3750, 0xb0: 2000, 0xa1: 1000,
0x92: 500, 0x82: 100, 0x72: 60, 0x63: 50, 0x53: 30, 0x23: 10}
RANGES = dict(zip(b'1234', [0.1, 0.5, 2, 20]))
def earlyInit(self):
self.init_serial(3000000)
self.write_rate(10)
def get_data(self):
for _ in range(2):
self.write_bytes(b'B')
reply = self.read_bytes(25 * self.nsample)
self.stop_reading()
remainder = len(reply) % 25
if remainder:
reply = reply[:-remainder]
if not reply:
continue
chk = np.frombuffer(reply, dtype='i1,23i1,i1')
if not np.all(np.sum(chk['f1'], axis=1) % 256 == 0):
status = 'checksum error'
continue
# first byte must be 'B' and last byte must be CR
if np.all(chk['f0'] == ord(b'B')) and np.all(chk['f2'] == 13):
break
status = 'bad reply'
else:
self.status = self.Status.ERROR, status
raise ValueError(status)
self.status = self.Status.IDLE, ''
return reply
def read_value(self):
if not self.enabled:
return self.value
t0 = time.time()
s = self._conn
s.timeout = 0.1 + self.nsample / self.rate
self.write_bytes(b'C') # put into calibrated mode
if self.read_bytes(1) != b'c':
self.log.error('missing response to C command')
reply = self.get_data()
data = np.frombuffer(reply,
dtype=[('_head', 'i1'),
('x', '>f4'), ('thc', '>f4'), ('y', '>f4'), ('z', '>f4'),
('_ted', '>i2'), ('tec', '>f4'), ('_tail', 'i2')])
mean = {}
for key in data.dtype.fields:
if not key.startswith('_'):
mean[key] = np.average(data[key])
self._x.value = mean['x'] * 0.001
self._y.value = mean['y'] * 0.001
self._z.value = mean['z'] * 0.001
self._probe_temp.value = mean['thc']
self._box_temp.value = mean['tec']
self.write_bytes(b'D') # put into NONcalibrated mode
if self.read_bytes(1) != b'd':
self.log.error('missing response to D command')
reply = self.get_data()
data = np.frombuffer(reply,
dtype=[('_head', 'i1'),
('x', '>i4'), ('thc', '>i4'), ('y', '>i4'), ('z', '>i4'),
('_ted', '>i2'), ('tec', '>i4'), ('_tail', 'i2')])
for key in data.dtype.fields:
if not key.startswith('_'):
mean[key] = np.average(data[key])
self._x_direct.value = mean['x']
self._y_direct.value = mean['y']
self._z_direct.value = mean['z']
self._probe_temp_direct.value = mean['thc']
self._box_temp_direct.value = mean['tec'] * 0.01
self.avtime = time.time() - t0
return self._x.value, self._y.value, self._z.value
def get_rate_code(self, value):
for rate_code, sr in sorted(self.SAMPLING_RATES.items(), key=lambda kv: kv[1]):
if value < sr * 1.1:
break
return sr, rate_code
def write_rate(self, value):
sr, code = self.get_rate_code(value)
for _ in range(2):
self.write_bytes(b'K%2.2x' % code)
if self.read_bytes(2) == b'k%c' % code:
break
self.stop_reading()
else:
raise ValueError('bad response from rate command')
return sr
def read_rate(self):
self.write_bytes(b'K?')
reply = self.read_bytes(2)
if reply[0:1] != b'k':
raise ValueError('bad response from rate query')
return self.SAMPLING_RATES[reply[1]]
def read_range(self):
self.write_bytes(b'amr?')
reply = self.read_bytes(5)
if reply == b'arng:':
ranges = [self.RANGES[c] for c in self.read_bytes(3)]
result = 0
elif reply == b'mrng:':
ranges = [self.RANGES[self.read_bytes(1)[0]]] * 3
result = ranges[0]
else:
raise ValueError('bad reply to range query %s' % repr(reply))
self._x.range, self._y.range, self._z.range = ranges
return result
def write_range(self, value):
status = None
for _ in range(2):
if status:
self.stop_reading()
try:
rng = self.read_range()
except ValueError:
status = 'can not read range'
continue
if value == rng:
return value
if value == 0:
self.write_bytes(b'T')
if self.read_bytes(3) != b'T-1':
status = 'bad reply to auto range command'
continue
return 0
if rng == 0:
self.write_bytes(b'T')
if self.read_bytes(3) != b'T-0':
status = 'bad reply to toggle manual range command'
continue
for code, rng in sorted(self.RANGES.items()):
if value < rng * 1.1:
break
self.write_bytes(b'mr%c' % code)
reply = self.read_bytes(6)
if reply != b'mrng:%c' % code:
status = 'bad reply to manual range command %s' % repr(reply)
continue
return rng
raise ValueError(status)

49
frappy_psi/simdpm.py Normal file
View File

@ -0,0 +1,49 @@
# -*- 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:
# M. Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""simulated transducer DPM3 read out"""
import random
import math
from frappy.core import Readable, Parameter, FloatRange, Attached
from frappy.lib import clamp
class DPM3(Readable):
motor = Attached()
jitter = Parameter('simulated jitter', FloatRange(unit='N'), default=0.1, readonly=False)
hysteresis = Parameter('simulated hysteresis', FloatRange(unit='deg'), default=100, readonly=False)
friction = Parameter('friction', FloatRange(unit='N/deg'), default=2.5, readonly=False)
slope = Parameter('slope', FloatRange(unit='N/deg'), default=0.5, readonly=False)
offset = Parameter('offset', FloatRange(unit='N'), default=0, readonly=False)
_pos = 0
def read_value(self):
mot = self._motor
d = self.friction * self.slope
self._pos = clamp(self._pos, mot.value - d, mot.value + d)
f = (mot.value - self._pos) / self.slope
if mot.value > 0:
f = max(f, (mot.value - self.hysteresis) / self.slope)
else:
f = min(f, (mot.value + self.hysteresis) / self.slope)
return f + self.jitter * (random.random() - random.random()) * 0.5

View File

@ -0,0 +1,57 @@
#!/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
# 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>
# *****************************************************************************
"""virtual sensor switching between two others depending on range"""
from frappy.core import ERROR, Attached, BoolType, Parameter, Readable, TupleOf, EnumType, FloatRange
class Sensor(Readable):
upper = Attached()
lower = Attached()
switch_range = Parameter('switching range', datatype=TupleOf(FloatRange(), FloatRange()), readonly=False, default=(0, 0))
used_sensor = Parameter('sensor to use', EnumType(lower=0, upper=1), readonly=False, default=0)
value = Parameter(datatype=FloatRange(unit='K'))
pollinterval = Parameter(export=False)
status = Parameter(default=(Readable.Status.ERROR, 'unintialized'))
def initModule(self):
super().initModule()
self.rawsensor.registerCallbacks(self)
def update_value(self, value):
src = self.upper if self.used_sensor == 'upper' else self.lower
try:
self.value = src.value
self.status = src.status
except Exception as e:
self.status = ERROR, repr(e)
def update_status(self, value):
self.update_value(None)
def read_value(self):
src = self.upper if self.used_sensor == 'upper' else self.lower
return src.read_value()
def read_status(self):
src = self.upper if self.used_sensor == 'upper' else self.lower
return src.read_status()

318
frappy_psi/triton.py Normal file
View File

@ -0,0 +1,318 @@
#!/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
# 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>
# *****************************************************************************
"""oxford instruments triton (kelvinoxjt dil)"""
from math import sqrt
from frappy.core import Writable, Parameter, Readable, Drivable, IDLE, WARN, BUSY, ERROR, \
Done, Property
from frappy.datatypes import EnumType, FloatRange, StringType
from frappy.lib.enum import Enum
from frappy_psi.mercury import MercuryChannel, Mapped, off_on, HasInput, SELF
from frappy_psi import mercury
actions = Enum(none=0, condense=1, circulate=2, collect=3)
open_close = Mapped(CLOSE=0, OPEN=1)
actions_map = Mapped(STOP=actions.none, COND=actions.condense, COLL=actions.collect)
actions_map.mapping['NONE'] = actions.none # when writing, STOP is used instead of NONE
class Action(MercuryChannel, Writable):
kind = 'ACTN'
cooldown_channel = Property('cool down channel', StringType(), 'T5')
mix_channel = Property('mix channel', StringType(), 'T5')
value = Parameter('running action', EnumType(actions))
target = Parameter('action to do', EnumType(none=0, condense=1, collect=3), readonly=False)
_target = 0
def read_value(self):
return self.query('SYS:DR:ACTN', actions_map)
def read_target(self):
return self._target
def write_target(self, value):
self._target = value
self.change('SYS:DR:CHAN:COOL', self.cooldown_channel, str)
# self.change('SYS:DR:CHAN:MC', self.mix_channel, str)
# self.change('DEV:T5:TEMP:MEAS:ENAB', 'ON', str)
return self.change('SYS:DR:ACTN', value, actions_map)
# actions:
# NONE (no action)
# COND (condense mixture)
# COLL (collect mixture)
# STOP (go to NONE)
#
# not yet used (would need a subclass of Action):
# CLDN (cool down)
# PCL (precool automation)
# PCOND (pause pre-cool (not condense?) automation)
# RCOND (resume pre-cool (not condense?) automation)
# WARM (warm-up)
# EPCL (empty pre-cool automation)
class Valve(MercuryChannel, Drivable):
kind = 'VALV'
value = Parameter('valve state', EnumType(closed=0, opened=1))
target = Parameter('valve target', EnumType(close=0, open=1))
_try_count = None
def doPoll(self):
self.read_status()
def read_value(self):
return self.query('DEV::VALV:SIG:STATE', open_close)
def read_status(self):
pos = self.read_value()
if self._try_count is None: # not switching
return IDLE, ''
if pos == self.target:
# success
if self._try_count:
# make sure last sent command was not opposite
self.change('DEV::VALV:SIG:STATE', self.target, open_close)
self._try_count = None
self.setFastPoll(False)
return IDLE, ''
self._try_count += 1
if self._try_count % 4 == 0:
# send to opposite position in order to unblock
self.change('DEV::VALV:SIG:STATE', pos, open_close)
return BUSY, 'unblock'
if self._try_count > 9:
# make sure system does not toggle later
self.change('DEV::VALV:SIG:STATE', pos, open_close)
return ERROR, 'can not %s valve' % self.target.name
self.change('DEV::VALV:SIG:STATE', self.target, open_close)
return BUSY, 'waiting'
def write_target(self, value):
if value != self.read_value():
self._try_count = 0
self.setFastPoll(True, 0.25)
self.change('DEV::VALV:SIG:STATE', value, open_close)
self.status = BUSY, self.target.name
return value
class Pump(MercuryChannel, Writable):
kind = 'PUMP'
value = Parameter('pump state', EnumType(off=0, on=1))
target = Parameter('pump target', EnumType(off=0, on=1))
def read_value(self):
return self.query('DEV::PUMP:SIG:STATE', off_on)
def write_target(self, value):
return self.change('DEV::PUMP:SIG:STATE', value, off_on)
def read_status(self):
return IDLE, ''
class TurboPump(Pump):
power = Parameter('pump power', FloatRange(unit='W'))
freq = Parameter('pump frequency', FloatRange(unit='Hz'))
powerstage_temp = Parameter('temperature of power stage', FloatRange(unit='K'))
motor_temp = Parameter('temperature of motor', FloatRange(unit='K'))
bearing_temp = Parameter('temperature of bearing', FloatRange(unit='K'))
pumpbase_temp = Parameter('temperature of pump base', FloatRange(unit='K'))
electronics_temp = Parameter('temperature of electronics', FloatRange(unit='K'))
def read_status(self):
status = self.query('DEV::PUMP:STATUS', str)
if status == 'OK':
return IDLE, ''
return WARN, status
def read_power(self):
return self.query('DEV::PUMP:SIG:POWR')
def read_freq(self):
return self.query('DEV::PUMP:SIG:SPD')
def read_powerstage_temp(self):
return self.query('DEV::PUMP:SIG:PST')
def read_motor_temp(self):
return self.query('DEV::PUMP:SIG:MT')
def read_bearing_temp(self):
return self.query('DEV::PUMP:SIG:BT')
def read_pumpbase_temp(self):
return self.query('DEV::PUMP:SIG:PBT')
def read_electronics_temp(self):
return self.query('DEV::PUMP:SIG:ET')
# class PulseTubeCompressor(MercuryChannel, Writable):
# kind = 'PTC'
# value = Parameter('compressor state', EnumType(closed=0, opened=1))
# target = Parameter('compressor target', EnumType(close=0, open=1))
# water_in_temp = Parameter('temperature of water inlet', FloatRange(unit='K'))
# water_out_temp = Parameter('temperature of water outlet', FloatRange(unit='K'))
# helium_temp = Parameter('temperature of helium', FloatRange(unit='K'))
# helium_low_pressure = Parameter('helium pressure (low side)', FloatRange(unit='mbar'))
# helium_high_pressure = Parameter('helium pressure (high side)', FloatRange(unit='mbar'))
# motor_current = Parameter('motor current', FloatRange(unit='A'))
#
# def read_value(self):
# return self.query('DEV::PTC:SIG:STATE', off_on)
#
# def write_target(self, value):
# return self.change('DEV::PTC:SIG:STATE', value, off_on)
#
# def read_status(self):
# # TODO: check possible status values
# return self.WARN, self.query('DEV::PTC:SIG:STATUS')
#
# def read_water_in_temp(self):
# return self.query('DEV::PTC:SIG:WIT')
#
# def read_water_out_temp(self):
# return self.query('DEV::PTC:SIG:WOT')
#
# def read_helium_temp(self):
# return self.query('DEV::PTC:SIG:HT')
#
# def read_helium_low_pressure(self):
# return self.query('DEV::PTC:SIG:HLP')
#
# def read_helium_high_pressure(self):
# return self.query('DEV::PTC:SIG:HHP')
#
# def read_motor_current(self):
# return self.query('DEV::PTC:SIG:MCUR')
class FlowMeter(MercuryChannel, Readable):
kind = 'FLOW'
def read_value(self):
return self.query('DEV::FLOW:SIG:FLOW')
class ScannerChannel(MercuryChannel):
# TODO: excitation, enable
# TODO: switch on/off filter, check
filter_time = Parameter('filter time', FloatRange(1, 200, unit='sec'), readonly=False)
dwell_time = Parameter('dwell time', FloatRange(1, 200, unit='sec'), readonly=False)
pause_time = Parameter('pause time', FloatRange(3, 200, unit='sec'), readonly=False)
def read_filter_time(self):
return self.query('DEV::TEMP:FILT:TIME')
def write_filter_time(self, value):
self.change('DEV::TEMP:FILT:WIN', 80)
return self.change('DEV::TEMP:FILT:TIME', value)
def read_dwell_time(self):
return self.query('DEV::TEMP:MEAS:DWEL')
def write_dwell_time(self, value):
self.change('DEV::TEMP:FILT:WIN', 80)
return self.change('DEV::TEMP:MEAS:DWEL', value)
def read_pause_time(self):
return self.query('DEV::TEMP:MEAS:PAUS')
def write_pause_time(self, value):
return self.change('DEV::TEMP:MEAS:PAUS', value)
class TemperatureSensor(ScannerChannel, mercury.TemperatureSensor):
pass
class TemperatureLoop(ScannerChannel, mercury.TemperatureLoop):
ENABLE = 'TEMP:LOOP:MODE'
ENABLE_RAMP = 'TEMP:LOOP:RAMP:ENAB'
RAMP_RATE = 'TEMP:LOOP:RAMP:RATE'
enable_pid_table = None # remove, does not work on triton
ctrlpars = Parameter('pid (gain, integral (inv. time), differential time')
system_channel = Property('system channel name', StringType(), 'MC')
def write_control_active(self, value):
if self.system_channel:
self.change('SYS:DR:CHAN:%s' % self.system_channel, self.slot.split(',')[0], str)
if value:
self.change('DEV::TEMP:LOOP:FILT:ENAB', 'ON', str)
if self.output_module:
limit = self.output_module.read_limit() or None # None: max. limit
self.output_module.write_limit(limit)
return super().write_control_active(value)
class HeaterOutput(HasInput, MercuryChannel, Writable):
"""heater output"""
kind = 'HTR'
value = Parameter('heater output', FloatRange(unit='uW'))
target = Parameter('heater output', FloatRange(0, unit='$'), readonly=False)
resistivity = Parameter('heater resistivity', FloatRange(unit='Ohm'))
def read_resistivity(self):
return self.query('DEV::HTR:RES')
def read_value(self):
return round(self.query('DEV::HTR:SIG:POWR'), 3)
def read_target(self):
if self.controlled_by != 0:
return Done
return self.value
def write_target(self, value):
self.write_controlled_by(SELF)
if self.resistivity:
# round to the next voltage step
value = round(sqrt(value * self.resistivity)) ** 2 / self.resistivity
return round(self.change('DEV::HTR:SIG:POWR', value), 3)
class HeaterOutputWithRange(HeaterOutput):
"""heater output with heater range"""
kind = 'HTR,TEMP'
limit = Parameter('max. heater power', FloatRange(unit='uW'), readonly=False)
def read_limit(self):
maxcur = self.query('DEV::TEMP:LOOP:RANGE') # mA
return self.read_resistivity() * maxcur ** 2 # uW
def write_limit(self, value):
if value is None:
maxcur = 100 # max. allowed current 100mA
else:
maxcur = sqrt(value / self.read_resistivity())
for cur in 0.0316, 0.1, 0.316, 1, 3.16, 10, 31.6, 100:
if cur > maxcur * 0.999:
maxcur = cur
break
else:
maxcur = cur
self.change('DEV::TEMP:LOOP:RANGE', maxcur)
return self.read_limit()

261
frappy_psi/ultrasound.py Normal file
View File

@ -0,0 +1,261 @@
#!/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
# 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>
# *****************************************************************************
"""frappy support for ultrasound"""
import math
#import serial
import os
import time
import numpy as np
import frappy_psi.iqplot as iqplot
from frappy_psi.adq_mr import Adq
from frappy.core import Attached, BoolType, Done, FloatRange, HasIO, \
IntRange, Module, Parameter, Readable, StringIO, StringType
from frappy.properties import Property
def fname_from_time(t, extension):
tm = time.localtime(t)
dirname = os.path.join('..', 'data', time.strftime("%Y-%m-%d_%H", tm))
filename = time.strftime("%Y-%m-%d_%H-%M-%S_", tm)
filename = filename + ("%.1f" % t)[-1]
if not os.path.isdir(dirname):
os.makedirs(dirname)
return os.path.join(dirname, filename)
class Roi(Readable):
main = Attached()
value = Parameter('amplitude', FloatRange(), default=0)
phase = Parameter('phase', FloatRange(unit='deg'), default=0)
i = Parameter('in phase', FloatRange(), default=0)
q = Parameter('out of phase', FloatRange(), default=0)
time = Parameter('start time', FloatRange(unit='nsec'), readonly=False)
size = Parameter('interval (symmetric around time)', FloatRange(unit='nsec'), readonly=False)
enable = Parameter('calculate this roi', BoolType(), readonly=False, default=True)
#status = Parameter(export=False)
pollinterval = Parameter(export=False)
interval = (0,0)
def initModule(self):
super().initModule()
self.main.register_roi(self)
self.calc_interval()
def calc_interval(self):
self.interval = (self.time - 0.5 * self.size, self.time + 0.5 * self.size)
def write_time(self, value):
self.time = value
self.calc_interval()
return Done
def write_size(self, value):
self.size = value
self.calc_interval()
return Done
class Pars(Module):
description = 'relevant parameters from SEA'
timestamp = Parameter('unix timestamp', StringType(), default='0', readonly=False)
temperature = Parameter('T', FloatRange(unit='K'), default=0, readonly=False)
mf = Parameter('field', FloatRange(unit='T'), default=0, readonly=False)
sr = Parameter('rotaion angle', FloatRange(unit='deg'), default=0, readonly=False)
class FreqStringIO(StringIO):
end_of_line = '\r'
class Frequency(HasIO, Readable):
pars = Attached()
sr = Property('samples per record', datatype=IntRange(), default=16384)
maxy = Property('plot y scale', datatype=FloatRange(), default=0.5)
value = Parameter('frequency@I,q', datatype=FloatRange(unit='Hz'), default=0)
basefreq = Parameter('base frequency', FloatRange(unit='Hz'), readonly=False)
nr = Parameter('number of records', datatype=IntRange(1,10000), default=500)
freq = Parameter('target frequency', FloatRange(unit='Hz'), readonly=False)
bw = Parameter('bandwidth lowpassfilter', datatype=FloatRange(unit='Hz'),default=10E6)
amp = Parameter('amplitude', FloatRange(unit='dBm'), readonly=False)
control = Parameter('control loop on?', BoolType(), readonly=False, default=True)
time = Parameter('pulse start time', FloatRange(unit='nsec'),
readonly=False)
size = Parameter('pulse length (starting from time)', FloatRange(unit='nsec'),
readonly=False)
pulselen = Parameter('adjusted pulse length (integer number of periods)', FloatRange(unit='nsec'), default=1)
maxstep = Parameter('max frequency step', FloatRange(unit='Hz'), readonly=False,
default=10000)
minstep = Parameter('min frequency step for slope calculation', FloatRange(unit='Hz'),
readonly=False, default=4000)
slope = Parameter('inphase/frequency slope', FloatRange(), readonly=False,
default=1e6)
plot = Parameter('create plot images', BoolType(), readonly=False, default=True)
save = Parameter('save data', BoolType(), readonly=False, default=True)
pollinterval = Parameter(datatype=FloatRange(0,120))
ioClass = FreqStringIO
lastfreq = None
old = None
starttime = None
interval = (0,0)
def earlyInit(self):
super().earlyInit()
self.adq = Adq(self.nr, self.sr, self.bw)
self.roilist = []
self.write_nr(self.nr)
self.skipctrl = 0
self.plotter = iqplot.Plot(self.maxy)
self.calc_interval()
def calc_interval(self):
self.interval = (self.time, self.time + self.size)
def write_time(self, value):
self.time = value
self.calc_interval()
return Done
def write_size(self, value):
self.size = value
self.calc_interval()
return Done
def write_nr(self, value):
# self.pollinterval = value * 0.0001
return value
def register_roi(self, roi):
self.roilist.append(roi)
def set_freq(self):
freq = self.freq + self.basefreq
self.communicate('FREQ %.15g;FREQ?' % freq)
#self._iodev.readline().decode('ascii')
return freq
def write_amp(self, amp):
reply = self.communicate('AMPR %g;AMPR?' % amp)
return float(reply)
def read_amp(self):
reply = self.communicate('AMPR?')
return float(reply)
def write_freq(self, value):
self.skipctrl = 2 # suppress control for the 2 next steps
return value
def doPoll(self):
"""main poll loop body"""
if self.lastfreq is None:
self.lastfreq = self.set_freq()
self.adq.start()
if self.starttime is None:
self.starttime = time.time()
times = []
times.append(('init', time.time()))
seadata = {p: float(getattr(self.pars, p)) for p in self.pars.parameters}
data = self.adq.getdata() # this will wait, if not yet finished
#save sample
#np.save('sample.dat',data)
times.append(('wait',time.time()))
if self.control:
freq = self.lastfreq # data was acquired at this freq
else:
freq = self.set_freq()
seadata['frequency'] = freq
if self.control:
self.lastfreq = self.set_freq()
times.append(('setf',time.time()))
self.adq.start() # start next acq
times.append(('start',time.time()))
roilist = [r for r in self.roilist if r.enable]
gates = self.adq.gates_and_curves(data, freq, self.interval,
[r.interval for r in roilist])
if self.save:
times.append(('save',time.time()))
tdata, idata, qdata, pdata = self.adq.curves
seadata['timestep'] = tdata[1] - tdata[0]
iqdata = np.array((idata, qdata, pdata), dtype='f4')
ts = seadata['timestamp']
if ts:
filename = fname_from_time(ts, '.npz')
seanp = np.array(list(seadata.items()), dtype=[('name', 'U16'), ('value', 'f8')])
np.savez(filename, seadata=seanp, iqdata=iqdata)
# can be load back via
# content = np.load(filename)
# content['seadata'], content['iqdata']
self.pulselen = self.adq.pulselen
times.append(('ana',time.time()))
if self.plot:
# get reduced interval from adq.sinW
pulseint = (self.interval[0], self.interval[0] + self.pulselen)
try:
self.plotter.plot(
self.adq.curves,
rois=[pulseint] + [r.interval for r in roilist],
average=([r.time for r in roilist],
[r.i for r in roilist],
[r.q for r in roilist]))
except Exception as e:
self.log.warning('can not plot %r' % e)
else:
self.plotter.close()
now = time.time()
times.append(('plot',now))
# print(' '.join('%s %5.3f' % (label, t - self.starttime) for label, t in times))
self.starttime = now
self.value = freq - self.basefreq
for i, roi in enumerate(roilist):
roi.i = a = gates[i][0]
roi.q = b = gates[i][1]
roi.value = math.sqrt(a ** 2 + b ** 2)
roi.phase = math.atan2(a,b) * 180 / math.pi
inphase = self.roilist[0].i
if self.control:
newfreq = freq + inphase * self.slope - self.basefreq
# step = sorted((-self.maxstep, inphase * self.slope, self.maxstep))[1]
if self.old:
fdif = freq - self.old[0]
idif = inphase - self.old[1]
if abs(fdif) >= self.minstep:
self.slope = - fdif / idif
else:
fdif = 0
idif = 0
newfreq = freq + self.minstep
self.old = (freq, inphase)
if self.skipctrl > 0: # do no control for some time after changing frequency
self.skipctrl -= 1
elif self.control:
self.freq = sorted((self.freq - self.maxstep, newfreq, self.freq + self.maxstep))[1]
#print(times)
return Done

432
frappy_psi/uniax.py Normal file
View File

@ -0,0 +1,432 @@
# -*- 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:
# M. Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""use transducer and motor to adjust force"""
import time
import math
from frappy.core import Drivable, Parameter, FloatRange, Done, \
Attached, Command, PersistentMixin, PersistentParam, BoolType
from frappy.errors import BadValueError, SECoPError
from frappy.lib.statemachine import Retry, StateMachine, Restart
# TODO: fix with new state machine!
class Error(SECoPError):
pass
class Uniax(PersistentMixin, Drivable):
value = Parameter(unit='N')
motor = Attached()
transducer = Attached()
limit = Parameter('abs limit of force', FloatRange(0, 190, unit='N'), readonly=False, default=150)
tolerance = Parameter('force tolerance', FloatRange(0, 10, unit='N'), readonly=False, default=0.2)
slope = PersistentParam('spring constant', FloatRange(unit='deg/N'), readonly=False,
default=0.5, persistent='auto')
pid_i = PersistentParam('integral', FloatRange(), readonly=False, default=0.5, persistent='auto')
filter_interval = Parameter('filter time', FloatRange(0, 60, unit='s'), readonly=False, default=5)
current_step = Parameter('', FloatRange(unit='deg'), default=0)
force_offset = PersistentParam('transducer offset', FloatRange(unit='N'), readonly=False, default=0,
initwrite=True, persistent='auto')
hysteresis = PersistentParam('force hysteresis', FloatRange(0, 190, unit='N'), readonly=False, default=5,
persistent='auto')
adjusting = Parameter('', BoolType(), readonly=False, default=False, initwrite=True)
adjusting_current = PersistentParam('current when adjusting force', FloatRange(0, 2.8, unit='A'), readonly=False,
default=0.5, persistent='auto')
safe_step = PersistentParam('max. motor step when adjusting force', FloatRange(0, unit='deg'), readonly=False,
default=5, persistent='auto')
safe_current = PersistentParam('current when moving far', FloatRange(0, 2.8, unit='A'), readonly=False,
default=0.2, persistent='auto')
low_pos = Parameter('max. position for positive forces', FloatRange(unit='deg'), readonly=False, needscfg=False)
high_pos = Parameter('min. position for negative forces', FloatRange(unit='deg'), readonly=False, needscfg=False)
substantial_force = Parameter('min. force change expected within motor play', FloatRange(), default=1)
motor_play = Parameter('acceptable motor play within hysteresis', FloatRange(), readonly=False, default=10)
motor_max_play = Parameter('acceptable motor play outside hysteresis', FloatRange(), readonly=False, default=90)
timeout = Parameter('driving finishes when no progress within this delay', FloatRange(), readonly=False, default=300)
pollinterval = 0.1
_mot_target = None # for detecting manual motor manipulations
_filter_start = 0
_cnt = 0
_sum = 0
_cnt_rderr = 0
_cnt_wrerr = 0
_zero_pos_tol = None
_state = None
_force = None # raw force
def earlyInit(self):
super().earlyInit()
self._zero_pos_tol = {}
def initModule(self):
super().initModule()
self._state = StateMachine(logger=self.log, threaded=False, cleanup=self.cleanup)
def doPoll(self):
self.read_value()
self._state.cycle()
def drive_relative(self, step, ntry=3):
"""drive relative, try 3 times"""
mot = self.motor
mot.read_value() # make sure motor value is fresh
if self.adjusting and abs(step) > self.safe_step:
step = math.copysign(self.safe_step, step)
self.current_step = step
for _ in range(ntry):
try:
if abs(mot.value - mot.target) < mot.tolerance:
# make sure rounding erros do not suppress small steps
newpos = mot.target + step
else:
newpos = mot.value + step
self._mot_target = self.motor.write_target(newpos)
self._cnt_wrerr = max(0, self._cnt_wrerr - 1)
return True
except Exception as e:
self.log.warning('drive error %s', e)
self._cnt_wrerr += 1
if self._cnt_wrerr > 5:
raise
self.log.warning('motor reset')
self.motor.reset()
return False
def motor_busy(self):
mot = self.motor
if mot.isBusy():
if mot.target != self._mot_target:
raise Error('control stopped - motor moved directly')
return True
return False
def read_value(self):
try:
self._force = force = self.transducer.read_value()
self._cnt_rderr = max(0, self._cnt_rderr - 1)
except Exception as e:
self._cnt_rderr += 1
if self._cnt_rderr > 10:
self.stop()
self.status = 'ERROR', 'too many read errors: %s' % e
self.log.error(self.status[1])
self.read_target()
return Done
now = time.time()
self._sum += force
self._cnt += 1
if now < self._filter_start + self.filter_interval:
return Done
force = self._sum / self._cnt
self.reset_filter(now)
if abs(force) > self.limit + self.hysteresis:
self.motor.stop()
self.status = 'ERROR', 'above max limit'
self.log.error(self.status[1])
self.read_target()
return Done
if self.zero_pos(force) is None and abs(force) > self.hysteresis:
self.set_zero_pos(force, self.motor.read_value())
return force
def reset_filter(self, now=0.0):
self._sum = self._cnt = 0
self._filter_start = now or time.time()
def zero_pos(self, value):
"""get high_pos or low_pos, depending on sign of value
:param value: return an estimate for a good starting position
"""
name = 'high_pos' if value > 0 else 'low_pos'
if name not in self._zero_pos_tol:
return None
return getattr(self, name)
def set_zero_pos(self, force, pos):
"""set zero position high_pos or low_pos, depending on sign and value of force
:param force: the force used for calculating zero_pos
:param pos: the position used for calculating zero_pos
"""
name = 'high_pos' if force > 0 else 'low_pos'
if pos is None:
self._zero_pos_tol.pop(name, None)
return None
pos -= force * self.slope
tol = (abs(force) - self.hysteresis) * self.slope * 0.2
if name in self._zero_pos_tol:
old = self.zero_pos(force)
if abs(old - pos) < self._zero_pos_tol[name] + tol:
return
self._zero_pos_tol[name] = tol
self.log.info('set %s = %.1f +- %.1f (@%g N)' % (name, pos, tol, force))
setattr(self, name, pos)
def cleanup(self, state):
"""in case of error, set error status"""
if state.stopped: # stop or restart
if state.stopped is Restart:
return
self.status = 'IDLE', 'stopped'
self.log.warning('stopped')
else:
self.status = 'ERROR', str(state.last_error)
if isinstance(state.last_error, Error):
self.log.error('%s', state.last_error)
else:
self.log.error('%r raised in state %r', str(state.last_error), state.status_string)
self.read_target() # make target invalid
self.motor.stop()
self.write_adjusting(False)
def reset_progress(self, state):
state.prev_force = self.value
state.prev_pos = self.motor.value
state.prev_time = time.time()
def check_progress(self, state):
force_step = self.target - self.value
direction = math.copysign(1, force_step)
try:
force_progress = direction * (self.value - state.prev_force)
except AttributeError: # prev_force undefined?
self.reset_progress(state)
return True
if force_progress >= self.substantial_force:
self.reset_progress(state)
else:
motor_dif = abs(self.motor.value - state.prev_pos)
if motor_dif > self.motor_play:
if motor_dif > self.motor_max_play:
raise Error('force seems not to change substantially %g %g (%g %g)' % (self.value, self.motor.value, state.prev_force, state.prev_pos))
return False
return True
def adjust(self, state):
"""adjust force"""
if state.init:
state.phase = 0 # just initialized
state.in_since = 0
state.direction = math.copysign(1, self.target - self.value)
state.pid_fact = 1
if self.motor_busy():
return Retry()
self.value = self._force
force_step = self.target - self.value
if abs(force_step) < self.tolerance:
if state.in_since == 0:
state.in_since = state.now
if state.now > state.in_since + 10:
return self.within_tolerance
else:
if force_step * state.direction < 0:
if state.pid_fact == 1:
self.log.info('overshoot -> adjust with reduced pid_i')
state.pid_fact = 0.1
state.in_since = 0
if state.phase == 0:
state.phase = 1
self.reset_progress(state)
self.write_adjusting(True)
self.status = 'BUSY', 'adjusting force'
elif not self.check_progress(state):
if abs(self.value) < self.hysteresis:
if motor_dif > self.motor_play:
self.log.warning('adjusting failed - try to find zero pos')
self.set_zero_pos(self.target, None)
return self.find
elif time.time() > state.prev_time + self.timeout:
if state.phase == 1:
state.phase = 2
self.log.warning('no substantial progress since %d sec', self.timeout)
self.status = 'IDLE', 'adjusting timeout'
self.drive_relative(force_step * self.slope * self.pid_i * min(1, state.delta()) * state.pid_fact)
return Retry()
def within_tolerance(self, state):
"""within tolerance"""
if state.init:
self.status = 'IDLE', 'within tolerance'
return Retry()
if self.motor_busy():
return Retry()
force_step = self.target - self.value
if abs(force_step) < self.tolerance * 0.5:
self.current_step = 0
else:
self.check_progress(state)
self.drive_relative(force_step * self.slope * self.pid_i * min(1, state.delta()) * 0.1)
if abs(force_step) > self.tolerance:
return self.out_of_tolerance
return Retry()
def out_of_tolerance(self, state):
"""out of tolerance"""
if state.init:
self.status = 'WARN', 'out of tolerance'
state.in_since = 0
return Retry()
if self.motor_busy():
return Retry()
force_step = self.target - self._force
if abs(force_step) < self.tolerance:
if state.in_since == 0:
state.in_since = state.now
if state.now > state.in_since + 10:
return self.within_tolerance
if abs(force_step) < self.tolerance * 0.5:
return Retry()
self.check_progress(state)
self.drive_relative(force_step * self.slope * self.pid_i * min(1, state.delta()) * 0.1)
return Retry()
def find(self, state):
"""find active (engaged) range"""
if state.init:
state.prev_direction = 0 # find not yet started
self.reset_progress(state)
direction = math.copysign(1, self.target)
self.value = self._force
abs_force = self.value * direction
if abs_force > self.hysteresis or abs_force > self.target * direction:
if self.motor_busy():
self.log.info('motor stopped - substantial force detected: %g', self.value)
self.motor.stop()
elif state.prev_direction == 0:
return self.adjust
if abs_force > self.hysteresis:
self.set_zero_pos(self.value, self.motor.read_value())
return self.adjust
if abs_force < -self.hysteresis:
state.force_before_free = self.value
return self.free
if self.motor_busy():
if direction == -state.prev_direction: # target direction changed
self.motor.stop()
state.init_find = True # restart find
return Retry()
zero_pos = self.zero_pos(self.target)
if state.prev_direction: # find already started
if abs(self.motor.target - self.motor.value) > self.motor.tolerance:
# no success on last find try, try short and strong step
self.write_adjusting(True)
self.log.info('one step to %g', self.motor.value + self.safe_step)
self.drive_relative(direction * self.safe_step)
return Retry()
else:
state.prev_direction = math.copysign(1, self.target)
side_name = 'negative' if direction == -1 else 'positive'
if zero_pos is not None:
self.status = 'BUSY', 'change to %s side' % side_name
zero_pos += direction * (self.hysteresis * self.slope - self.motor.tolerance)
if (self.motor.value - zero_pos) * direction < -self.motor.tolerance:
self.write_adjusting(False)
self.log.info('change side to %g', zero_pos)
self.drive_relative(zero_pos - self.motor.value)
return Retry()
# we are already at or beyond zero_pos
return self.adjust
self.write_adjusting(False)
self.status = 'BUSY', 'find %s side' % side_name
self.log.info('one turn to %g', self.motor.value + direction * 360)
self.drive_relative(direction * 360)
return Retry()
def free(self, state):
"""free from high force at other end"""
if state.init:
state.free_way = None
self.reset_progress(state)
if self.motor_busy():
return Retry()
self.value = self._force
if abs(self.value) > abs(state.force_before_free) + self.hysteresis:
raise Error('force increase while freeing')
if abs(self.value) < self.hysteresis:
return self.find
if state.free_way is None:
state.free_way = 0
self.log.info('free from high force %g', self.value)
self.write_adjusting(True)
direction = math.copysign(1, self.target)
if state.free_way > abs(state.force_before_free + self.hysteresis) * self.slope + self.motor_max_play:
raise Error('freeing failed')
state.free_way += self.safe_step
self.drive_relative(direction * self.safe_step)
return Retry()
def write_target(self, target):
if abs(target) > self.limit:
raise BadValueError('force above limit')
if abs(target - self.value) <= self.tolerance:
if not self.isBusy():
self.status = 'IDLE', 'already at target'
self._state.start(self.within_tolerance)
return target
self.log.info('new target %g', target)
self._cnt_rderr = 0
self._cnt_wrerr = 0
self.status = 'BUSY', 'changed target'
self.target = target
if self.value * math.copysign(1, target) > self.hysteresis:
self._state.start(self.adjust)
else:
self._state.start(self.find)
return Done
def read_target(self):
if self._state.state is None:
if self.status[1]:
raise Error(self.status[1])
raise Error('inactive')
return self.target
@Command()
def stop(self):
if self.motor.isBusy():
self.log.info('stop motor')
self.motor.stop()
self.status = 'IDLE', 'stopped'
self._state.stop()
def write_force_offset(self, value):
self.force_offset = value
self.transducer.write_offset(value)
return Done
def write_adjusting(self, value):
mot = self.motor
if value:
mot_current = self.adjusting_current
mot.write_move_limit(self.safe_step)
else:
mot_current = self.safe_current
mot.write_safe_current(mot_current)
if abs(mot_current - mot.maxcurrent) > 0.01: # resolution of current: 2.8 / 250
self.log.info('motor current %g', mot_current)
mot.write_maxcurrent(mot_current)
return value

89
frappy_psi/vector.py Normal file
View File

@ -0,0 +1,89 @@
# -*- 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:
# Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
"""generic 3D vector"""
from frappy.core import Attached, Drivable, Readable, Parameter, Done
from frappy.datatypes import FloatRange, TupleOf, StatusType, Enum
class VectorRd(Readable):
"""generic readable vector"""
value = Parameter(datatype=TupleOf(FloatRange(), FloatRange(), FloatRange()))
x = Attached()
y = Attached()
z = Attached()
pollFuncs = None
components = None
def initModule(self):
super().initModule()
members = []
status_codes = {} # collect all possible status codes
components = []
for name in 'xyz':
component = getattr(self, name)
members.append(component.parameters['value'].datatype.copy())
components.append(component)
for code in component.status[0].enum.members:
status_codes[int(code)] = code.name
self.parameters['value'].datatype = TupleOf(*members)
self.parameters['status'].datatype = StatusType(Enum(
'status', **{k: v for v, k in status_codes.items()}))
self.components = components
def doPoll(self):
for component in self.components:
component.doPoll()
# update
component.pollInfo.last_main = self.pollInfo.last_main
self.value = self.merge_value()
self.status = self.merge_status()
def merge_value(self):
return [c.value for c in self.components]
def merge_status(self):
status = -1, ''
for c in self.components:
if c.status[0] > status[0]:
status = c.status
return status
def read_value(self):
return tuple((c.read_value() for c in self.components))
def read_status(self):
[c.read_status() for c in self.components]
return self.merge_status()
class Vector(Drivable, VectorRd):
"""generic drivable vector"""
target = Parameter(datatype=TupleOf(FloatRange(), FloatRange(), FloatRange()))
def initModule(self):
super().initModule()
members = []
for component in self.components:
members.append(component.parameters['target'].datatype.copy())
self.parameters['target'].datatype = TupleOf(*members)
def write_target(self, value):
return tuple((c.write_target(v) for v, c in zip(value, self.components)))