merge with wip branch
This commit is contained in:
parent
439c9d34c1
commit
e1b30bf37e
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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_))
|
||||
FloatRange(unit='$'), readonly=False, default=0)
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
16
frappy/io.py
16
frappy/io.py
@ -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:
|
||||
|
@ -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:
|
||||
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
|
||||
colons = host.count(':')
|
||||
if colons == 0: # hostname/ipv4 wihtout port
|
||||
port = defaultport
|
||||
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]])
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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}')
|
||||
|
@ -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 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 isinstance(pobj, Limit):
|
||||
basepname = pname.rpartition('_')[0]
|
||||
baseparam = self.parameters.get(basepname)
|
||||
if not baseparam:
|
||||
errors.append(f'parameter {pname!r} is given, but not {head!r}')
|
||||
errors.append(f'limit {pname!r} is given, but not {basepname!r}')
|
||||
continue
|
||||
dt = baseparam.datatype
|
||||
if dt is None:
|
||||
if baseparam.datatype 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}')
|
||||
pobj.set_datatype(baseparam.datatype)
|
||||
|
||||
if not pobj.hasDatatype():
|
||||
errors.append(f'{pname} needs a datatype')
|
||||
continue
|
||||
|
||||
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')
|
||||
min_, max_ = getattr(self, pname + '_limits')
|
||||
if not min_ <= value <= max_:
|
||||
raise RangeError(f'{pname} outside {pname}_limits')
|
||||
return
|
||||
except AttributeError:
|
||||
min_ = getattr(self, parametername + '_min', float('-inf'))
|
||||
max_ = getattr(self, parametername + '_max', float('inf'))
|
||||
if not min_ <= value <= max_:
|
||||
if min_ > max_:
|
||||
raise RangeError(f'invalid limits: [{min_:g}, {max_:g}]')
|
||||
raise RangeError(f'limits violation: {value:g} outside [{min_:g}, {max_:g}]')
|
||||
pass
|
||||
min_ = getattr(self, pname + '_min', float('-inf'))
|
||||
max_ = getattr(self, pname + '_max', float('inf'))
|
||||
if min_ > max_:
|
||||
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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
sm.status = self.get_status(statefunc, BUSY)
|
||||
if sm.statefunc:
|
||||
sm.status = sm.status[0], 'restarting'
|
||||
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
219
frappy_psi/SR_7270.py
Normal 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
294
frappy_psi/adq_mr.py
Normal 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
264
frappy_psi/attocube.py
Normal 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)
|
@ -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()
|
||||
|
@ -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
584
frappy_psi/cryoltd.py
Normal 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
144
frappy_psi/cryotel.py
Normal 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
62
frappy_psi/dg645.py
Normal 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
97
frappy_psi/dilsc.py
Normal 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
125
frappy_psi/dpm.py
Normal 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
|
@ -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
340
frappy_psi/ips_mercury.py
Normal 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
102
frappy_psi/iqplot.py
Normal 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
105
frappy_psi/ls240.py
Normal 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
45
frappy_psi/ls340res.py
Normal 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))
|
@ -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
75
frappy_psi/ls370sim.py
Normal 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
329
frappy_psi/ls372.py
Normal 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
400
frappy_psi/magfield.py
Normal 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')
|
@ -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,148 +63,162 @@ 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)}"
|
||||
reply = self.communicate(cmd)
|
||||
head = f'STAT:{adr}:'
|
||||
try:
|
||||
assert reply.startswith(head)
|
||||
replyiter = iter(reply[len(head):].split(':'))
|
||||
keys, result = zip(*zip(replyiter, replyiter))
|
||||
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
|
||||
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)
|
||||
replyiter = iter(reply[len(head):].split(':'))
|
||||
keys, result = zip(*zip(replyiter, replyiter))
|
||||
assert keys == tuple(names)
|
||||
return tuple(convert(r) for r in result)
|
||||
except (AssertionError, AttributeError, ValueError):
|
||||
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)}"
|
||||
reply = self.communicate(cmd)
|
||||
head = f'STAT:SET:{adr}:'
|
||||
|
||||
try:
|
||||
assert reply.startswith(head)
|
||||
replyiter = iter(reply[len(head):].split(':'))
|
||||
keys, result, valid = zip(*zip(replyiter, replyiter, replyiter))
|
||||
assert keys == tuple(k for k, _ in values)
|
||||
assert any(v == 'VALID' for v in valid)
|
||||
return tuple(convert(r) for r in result)
|
||||
except (AssertionError, AttributeError, ValueError) as e:
|
||||
raise HardwareError(f'invalid reply {reply!r} to cmd {cmd!r}') from e
|
||||
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 == givenkeys
|
||||
assert any(v == 'VALID' for v in valid)
|
||||
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.set_output(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
288
frappy_psi/phytron.py
Normal 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
|
@ -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
700
frappy_psi/sea.py
Normal 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
307
frappy_psi/senis.py
Normal 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
49
frappy_psi/simdpm.py
Normal 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
|
57
frappy_psi/switching_sensor.py
Normal file
57
frappy_psi/switching_sensor.py
Normal 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
318
frappy_psi/triton.py
Normal 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
261
frappy_psi/ultrasound.py
Normal 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
432
frappy_psi/uniax.py
Normal 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
89
frappy_psi/vector.py
Normal 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)))
|
Loading…
x
Reference in New Issue
Block a user