diff --git a/frappy/client/__init__.py b/frappy/client/__init__.py index 32df0b2..59fb20f 100644 --- a/frappy/client/__init__.py +++ b/frappy/client/__init__.py @@ -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=(, # # ***************************************************************************** -"""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 - - # list all parameters -. = # change parameter -() # 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 + + # list all parameters +. = # change parameter +() # 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 . = 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() diff --git a/frappy/config.py b/frappy/config.py index b8b645e..c19cc6a 100644 --- a/frappy/config.py +++ b/frappy/config.py @@ -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: diff --git a/frappy/core.py b/frappy/core.py index 72e201b..02948d4 100644 --- a/frappy/core.py +++ b/frappy/core.py @@ -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 diff --git a/frappy/datatypes.py b/frappy/datatypes.py index 5511e81..28dad55 100644 --- a/frappy/datatypes.py +++ b/frappy/datatypes.py @@ -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 diff --git a/frappy/features.py b/frappy/features.py index bcbe10a..0c7edd2 100644 --- a/frappy/features.py +++ b/frappy/features.py @@ -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) diff --git a/frappy/gui/cfg_editor/config_file.py b/frappy/gui/cfg_editor/config_file.py index 9be17a5..d6a3937 100644 --- a/frappy/gui/cfg_editor/config_file.py +++ b/frappy/gui/cfg_editor/config_file.py @@ -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) diff --git a/frappy/gui/cfg_editor/mainwindow.py b/frappy/gui/cfg_editor/mainwindow.py index 46b7a92..1bb9f54 100644 --- a/frappy/gui/cfg_editor/mainwindow.py +++ b/frappy/gui/cfg_editor/mainwindow.py @@ -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)) diff --git a/frappy/gui/cfg_editor/node_display.py b/frappy/gui/cfg_editor/node_display.py index 84e661f..ecbb0fa 100644 --- a/frappy/gui/cfg_editor/node_display.py +++ b/frappy/gui/cfg_editor/node_display.py @@ -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) diff --git a/frappy/gui/cfg_editor/utils.py b/frappy/gui/cfg_editor/utils.py index dc19276..27a8cd3 100644 --- a/frappy/gui/cfg_editor/utils.py +++ b/frappy/gui/cfg_editor/utils.py @@ -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() diff --git a/frappy/gui/cfg_editor/widgets.py b/frappy/gui/cfg_editor/widgets.py index b472628..c1b1297 100644 --- a/frappy/gui/cfg_editor/widgets.py +++ b/frappy/gui/cfg_editor/widgets.py @@ -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) diff --git a/frappy/gui/valuewidgets.py b/frappy/gui/valuewidgets.py index 19a742b..9dd1a35 100644 --- a/frappy/gui/valuewidgets.py +++ b/frappy/gui/valuewidgets.py @@ -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 diff --git a/frappy/io.py b/frappy/io.py index d64141f..dc669cd 100644 --- a/frappy/io.py +++ b/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: diff --git a/frappy/lib/__init__.py b/frappy/lib/__init__.py index 2c48b3d..827ac03 100644 --- a/frappy/lib/__init__.py +++ b/frappy/lib/__init__.py @@ -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}(? 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]]) diff --git a/frappy/lib/asynconn.py b/frappy/lib/asynconn.py index b09dc8f..143d58a 100644 --- a/frappy/lib/asynconn.py +++ b/frappy/lib/asynconn.py @@ -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 diff --git a/frappy/lib/enum.py b/frappy/lib/enum.py index 7dd65e4..cff1fa0 100644 --- a/frappy/lib/enum.py +++ b/frappy/lib/enum.py @@ -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) diff --git a/frappy/mixins.py b/frappy/mixins.py index c353ad4..8bfad2c 100644 --- a/frappy/mixins.py +++ b/frappy/mixins.py @@ -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}') diff --git a/frappy/modules.py b/frappy/modules.py index 22359b8..9844b2f 100644 --- a/frappy/modules.py +++ b/frappy/modules.py @@ -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 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 _min <= value <= _max - :param parametername: parameter name, default is 'target' + :param value: the value to be checked for _min <= value <= _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 diff --git a/frappy/params.py b/frappy/params.py index 62263e6..526738c 100644 --- a/frappy/params.py +++ b/frappy/params.py @@ -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, diff --git a/frappy/persistent.py b/frappy/persistent.py index a2ed722..03e2499 100644 --- a/frappy/persistent.py +++ b/frappy/persistent.py @@ -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 diff --git a/frappy/playground.py b/frappy/playground.py index 4664ca5..07061f6 100644 --- a/frappy/playground.py +++ b/frappy/playground.py @@ -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: diff --git a/frappy/protocol/interface/tcp.py b/frappy/protocol/interface/tcp.py index 988b94b..1a28fd3 100644 --- a/frappy/protocol/interface/tcp.py +++ b/frappy/protocol/interface/tcp.py @@ -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) diff --git a/frappy/protocol/router.py b/frappy/protocol/router.py index 011cac8..795ca65 100644 --- a/frappy/protocol/router.py +++ b/frappy/protocol/router.py @@ -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) diff --git a/frappy/rwhandler.py b/frappy/rwhandler.py index 2a3c7c1..4a33887 100644 --- a/frappy/rwhandler.py +++ b/frappy/rwhandler.py @@ -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 diff --git a/frappy/server.py b/frappy/server.py index 0d195a1..f0cfb80 100644 --- a/frappy/server.py +++ b/frappy/server.py @@ -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 diff --git a/frappy/states.py b/frappy/states.py index eb4b5bb..53dcfde 100644 --- a/frappy/states.py +++ b/frappy/states.py @@ -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: diff --git a/frappy_psi/SR_7270.py b/frappy_psi/SR_7270.py new file mode 100644 index 0000000..0111e68 --- /dev/null +++ b/frappy_psi/SR_7270.py @@ -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 +# ***************************************************************************** +"""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='')) diff --git a/frappy_psi/adq_mr.py b/frappy_psi/adq_mr.py new file mode 100644 index 0000000..d0e9fc1 --- /dev/null +++ b/frappy_psi/adq_mr.py @@ -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] + diff --git a/frappy_psi/attocube.py b/frappy_psi/attocube.py new file mode 100644 index 0000000..c923fca --- /dev/null +++ b/frappy_psi/attocube.py @@ -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 +# ***************************************************************************** + +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) diff --git a/frappy_psi/channelswitcher.py b/frappy_psi/channelswitcher.py index 774e81a..8a90e8e 100644 --- a/frappy_psi/channelswitcher.py +++ b/frappy_psi/channelswitcher.py @@ -65,19 +65,22 @@ class ChannelSwitcher(Drivable): FloatRange(0, None), readonly=False, default=2) fast_poll = 0.1 - _channels = None # dict of + channels = None # dict of _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() diff --git a/frappy_psi/convergence.py b/frappy_psi/convergence.py index 0a34a95..6a079c4 100644 --- a/frappy_psi/convergence.py +++ b/frappy_psi/convergence.py @@ -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 + + . ''', 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 - 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 diff --git a/frappy_psi/cryoltd.py b/frappy_psi/cryoltd.py new file mode 100644 index 0000000..605cdff --- /dev/null +++ b/frappy_psi/cryoltd.py @@ -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 +# ***************************************************************************** + +"""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= or pname=(, 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('', 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('', 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=('_Field', self.to_gauss), + _status_text=('_Status', str), + _ready_text=('_Ready', str), + _error_text=('_Error', self.cvt_error), + _rate_units=('_Rate Units', str), + current=('_PSU Output', self.to_gauss), + voltage='_Voltage', + workingramp=('_Ramp Rate', self.to_gauss_min), + setpoint=('_Setpoint', self.to_gauss), + switch_heater=('_Heater', self.cvt_switch_heater), + cs_mode=('_Persistent Mode', self.cvt_cs_mode), + approach_mode=('_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::ChangeRateUnits A/s') + self.sendcmd('Set::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::Sweep %gA' % (target / self.A_to_G)) + else: + self.sendcmd('Set::Sweep %gT' % (target / 10000)) + self.block('_ready_text', 'FALSE') + + def start_field_change(self, sm): + if self._ready_text == 'FALSE': + self.sendcmd('Set::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::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::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::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::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::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::Abort') + + @Command() + def reset_quench(self): + """reset quench condition""" + self.sendcmd('Set::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_Status', self.cvt_value), + _ready_text=('Compressor_Ready', str), + _error_text=('Compressor_Error', str), + run_time='Compressor_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 ') + else: + self.sendcmd('Set:Compressor:Stop ') + self.block('target') + self.read_status() + return value + + @Command() + def reset_error(self): + """reset error""" + self.sendcmd('Set:Compressor:Reset ') + self._error_text = '' diff --git a/frappy_psi/cryotel.py b/frappy_psi/cryotel.py new file mode 100644 index 0000000..c979f41 --- /dev/null +++ b/frappy_psi/cryotel.py @@ -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 +# +# ***************************************************************************** + +"""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 diff --git a/frappy_psi/dg645.py b/frappy_psi/dg645.py new file mode 100644 index 0000000..4c06a28 --- /dev/null +++ b/frappy_psi/dg645.py @@ -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 +# ***************************************************************************** +"""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]) diff --git a/frappy_psi/dilsc.py b/frappy_psi/dilsc.py new file mode 100644 index 0000000..5d49b43 --- /dev/null +++ b/frappy_psi/dilsc.py @@ -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 +# ***************************************************************************** +"""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 diff --git a/frappy_psi/dpm.py b/frappy_psi/dpm.py new file mode 100644 index 0000000..71d07da --- /dev/null +++ b/frappy_psi/dpm.py @@ -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 +# +# ***************************************************************************** +"""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 diff --git a/frappy_psi/historywriter.py b/frappy_psi/historywriter.py index a3667a4..7e39a2e 100644 --- a/frappy_psi/historywriter.py +++ b/frappy_psi/historywriter.py @@ -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 {})] diff --git a/frappy_psi/ips_mercury.py b/frappy_psi/ips_mercury.py new file mode 100644 index 0000000..467f9eb --- /dev/null +++ b/frappy_psi/ips_mercury.py @@ -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 +# ***************************************************************************** +"""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 diff --git a/frappy_psi/iqplot.py b/frappy_psi/iqplot.py new file mode 100644 index 0000000..344f062 --- /dev/null +++ b/frappy_psi/iqplot.py @@ -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 .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() diff --git a/frappy_psi/ls240.py b/frappy_psi/ls240.py new file mode 100644 index 0000000..417acfb --- /dev/null +++ b/frappy_psi/ls240.py @@ -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 +# ***************************************************************************** +"""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 diff --git a/frappy_psi/ls340res.py b/frappy_psi/ls340res.py new file mode 100644 index 0000000..a9f9c7d --- /dev/null +++ b/frappy_psi/ls340res.py @@ -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 +# ***************************************************************************** +"""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)) diff --git a/frappy_psi/ls370res.py b/frappy_psi/ls370res.py index 04cdea0..86bca8a 100644 --- a/frappy_psi/ls370res.py +++ b/frappy_psi/ls370res.py @@ -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 diff --git a/frappy_psi/ls370sim.py b/frappy_psi/ls370sim.py new file mode 100644 index 0000000..b934685 --- /dev/null +++ b/frappy_psi/ls370sim.py @@ -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 +# ***************************************************************************** +"""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 diff --git a/frappy_psi/ls372.py b/frappy_psi/ls372.py new file mode 100644 index 0000000..633dcaf --- /dev/null +++ b/frappy_psi/ls372.py @@ -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 +# ***************************************************************************** +"""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 diff --git a/frappy_psi/magfield.py b/frappy_psi/magfield.py new file mode 100644 index 0000000..71f128b --- /dev/null +++ b/frappy_psi/magfield.py @@ -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 +# ***************************************************************************** +"""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') diff --git a/frappy_psi/mercury.py b/frappy_psi/mercury.py index 38c8aa7..bdb5227 100644 --- a/frappy_psi/mercury.py +++ b/frappy_psi/mercury.py @@ -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 - - 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 + slot = Property('comma separated slot id(s), e.g. DB6.T1', StringType()) + kind = '' #: used slot kind(s) + slots = () #: dict[] of + + 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: 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') diff --git a/frappy_psi/phytron.py b/frappy_psi/phytron.py new file mode 100644 index 0000000..36062db --- /dev/null +++ b/frappy_psi/phytron.py @@ -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 +# +# ***************************************************************************** + +"""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 diff --git a/frappy_psi/ppmssim.py b/frappy_psi/ppmssim.py index 997c9bd..d8d9939 100644 --- a/frappy_psi/ppmssim.py +++ b/frappy_psi/ppmssim.py @@ -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 diff --git a/frappy_psi/sea.py b/frappy_psi/sea.py new file mode 100644 index 0000000..8bb60f4 --- /dev/null +++ b/frappy_psi/sea.py @@ -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 +# ***************************************************************************** +"""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') diff --git a/frappy_psi/senis.py b/frappy_psi/senis.py new file mode 100644 index 0000000..f129a04 --- /dev/null +++ b/frappy_psi/senis.py @@ -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 +# ***************************************************************************** +"""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,3f4'), ('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) diff --git a/frappy_psi/simdpm.py b/frappy_psi/simdpm.py new file mode 100644 index 0000000..de00edb --- /dev/null +++ b/frappy_psi/simdpm.py @@ -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 +# +# ***************************************************************************** +"""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 diff --git a/frappy_psi/switching_sensor.py b/frappy_psi/switching_sensor.py new file mode 100644 index 0000000..08976dc --- /dev/null +++ b/frappy_psi/switching_sensor.py @@ -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 +# ***************************************************************************** +"""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() diff --git a/frappy_psi/triton.py b/frappy_psi/triton.py new file mode 100644 index 0000000..99f7f40 --- /dev/null +++ b/frappy_psi/triton.py @@ -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 +# ***************************************************************************** +"""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() diff --git a/frappy_psi/ultrasound.py b/frappy_psi/ultrasound.py new file mode 100644 index 0000000..e4c9fe8 --- /dev/null +++ b/frappy_psi/ultrasound.py @@ -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 +# ***************************************************************************** +"""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 diff --git a/frappy_psi/uniax.py b/frappy_psi/uniax.py new file mode 100644 index 0000000..64a7e51 --- /dev/null +++ b/frappy_psi/uniax.py @@ -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 +# +# ***************************************************************************** +"""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 diff --git a/frappy_psi/vector.py b/frappy_psi/vector.py new file mode 100644 index 0000000..9e28386 --- /dev/null +++ b/frappy_psi/vector.py @@ -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 +# ***************************************************************************** +"""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))) \ No newline at end of file