From 07377c8bf56785bcc8ade0563addefe115ca9f59 Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Mon, 17 Apr 2023 14:25:34 +0200 Subject: [PATCH] core: Add Acquisition Interface + Adds first implementation for the Acquisition interface, split into Controller and Channel Modules + frappy_demo: adds an example simulation + new property AttachedDict for a collection of attached modules + move Attach and AttachDict to a new file frappy/attached.py + interface_classes creation changed. includes now also Acquisition Change-Id: I198a96065a65bb28f73e468ce0465fca2d8734d7 --- cfg/acquisition_cfg.py | 40 ++++++ frappy/attached.py | 131 ++++++++++++++++++++ frappy/core.py | 4 +- frappy/modulebase.py | 3 + frappy/modules.py | 119 ++++++++++++------ frappy_demo/acquisition.py | 241 +++++++++++++++++++++++++++++++++++++ frappy_demo/test.py | 1 + 7 files changed, 497 insertions(+), 42 deletions(-) create mode 100644 cfg/acquisition_cfg.py create mode 100644 frappy/attached.py create mode 100644 frappy_demo/acquisition.py diff --git a/cfg/acquisition_cfg.py b/cfg/acquisition_cfg.py new file mode 100644 index 00000000..495237ac --- /dev/null +++ b/cfg/acquisition_cfg.py @@ -0,0 +1,40 @@ +Node('measure.frappy.demo', + '''Measureable demo''', + 'tcp://10770', +) +Mod('control', + 'frappy_demo.acquisition.Controller', + 'simple demo controller', + channels = {'first': 'chan1', 'second': 'chan2', 'third': 'chan3'}, + pollinterval = 1, +) +Mod('chan1', + 'frappy_demo.acquisition.Channel', + 'simple channel demo', + goal = 50, + goal_enable = True, + pollinterval = 1, +) +Mod('chan2', + 'frappy_demo.acquisition.Channel', + 'simple channel demo', + pollinterval = 1, +) +Mod('chan3', + 'frappy_demo.acquisition.Channel', + 'simple channel demo', + pollinterval = 1, +) +Mod('single', + 'frappy_demo.acquisition.SimpleAcquisition', + 'Acquisition demo', + pollinterval = 1, + goal = 20, + goal_enable=True, + acquisition_key='single', +) +Mod('ng', + 'frappy_demo.acquisition.NoGoalAcquisition', + 'Acquisition demo', + pollinterval = 5, +) diff --git a/frappy/attached.py b/frappy/attached.py new file mode 100644 index 00000000..3ca2cf09 --- /dev/null +++ b/frappy/attached.py @@ -0,0 +1,131 @@ +# ***************************************************************************** +# +# 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: +# Enrico Faulhaber +# Markus Zolliker +# Alexander Zaft +# +# ***************************************************************************** + +from frappy.errors import ConfigError +from frappy.modulebase import Module +from frappy.datatypes import StringType, ValueType +from frappy.properties import Property + + +class Attached(Property): + """a special property, defining an attached module + + assign a module name to this property in the cfg file, + and the server will create an attribute with this module + + When mandatory is set to False, and there is no value or an empty string + given in the config file, the value of the attribute will be None. + """ + def __init__(self, basecls=Module, description='attached module', mandatory=True): + self.basecls = basecls + super().__init__(description, StringType(), mandatory=mandatory) + + def __get__(self, obj, owner): + if obj is None: + return self + modobj = obj.attachedModules.get(self.name) + if not modobj: + modulename = super().__get__(obj, owner) + if not modulename: + return None # happens when mandatory=False and modulename is not given + modobj = obj.secNode.get_module(modulename) + if not modobj: + raise ConfigError(f'attached module {self.name}={modulename!r} ' + f'does not exist') + if not isinstance(modobj, self.basecls): + raise ConfigError(f'attached module {self.name}={modobj.name!r} ' + f'must inherit from {self.basecls.__qualname__!r}') + obj.attachedModules[self.name] = modobj + return modobj + + def copy(self): + return Attached(self.basecls, self.description, self.mandatory) + + +class DictWithFlag(dict): + flag = False + + +class AttachDictType(ValueType): + """a custom datatype for a dict of names or modules""" + def __init__(self): + super().__init__(DictWithFlag) + + def copy(self): + return AttachDictType() + + def export_value(self, value): + """export either names or the name attribute + + to treat bare names and modules the same + """ + return {k: getattr(v, 'name', v) for k, v in value.items()} + + +class AttachedDict(Property): + def __init__(self, description='attached modules', elements=None, optional=None, basecls=None, + **kwds): + """a mapping of attached modules + + :param elements: None or a dict of for mandatory elements + :param optional: None or a dict of for optional elements + :param basecls: None or a base class for arbitrary keys + if not given, only keys given in parameters 'elements' and 'optional' are allowed + :param description: the property description + + might also be a number or any other immutable + """ + self.elements = elements or {} + self.basecls = basecls + self.baseclasses = {**self.elements, **(optional or {})} + super().__init__(description, AttachDictType(), default={}, **kwds) + + def __get__(self, obj, owner): + if obj is None: + return self + attach_dict = super().__get__(obj, owner) or DictWithFlag({}) + if attach_dict.flag: + return attach_dict + + for key, modulename in attach_dict.items(): + basecls = self.baseclasses.get(key, self.basecls) + if basecls is None: + raise ConfigError(f'unknown key {key!r} for attached modules {self.name}') + modobj = obj.secNode.get_module(modulename) + if modobj is None: + raise ConfigError(f'attached modules {self.name}: ' + f'{key}={modulename!r} does not exist') + if not isinstance(modobj, basecls): + raise ConfigError(f'attached modules {self.name}: ' + f'module {key}={modulename!r} must inherit ' + f'from {basecls.__qualname__!r}') + obj.attachedModules[self.name, key] = attach_dict[key] = modobj + missing_keys = set(self.elements) - set(attach_dict) + if missing_keys: + raise ConfigError(f'attached modules {self.name}: ' + f"missing {', '.join(missing_keys)} ") + attach_dict.flag = True + return attach_dict + + def copy(self): + return AttachedDict(self.elements, self.baseclasses, self.basecls, self.description) diff --git a/frappy/core.py b/frappy/core.py index e89fa52a..36ff2a64 100644 --- a/frappy/core.py +++ b/frappy/core.py @@ -29,8 +29,8 @@ from frappy.datatypes import ArrayOf, BLOBType, BoolType, EnumType, \ FloatRange, IntRange, ScaledInteger, StringType, StructOf, TupleOf, StatusType from frappy.lib.enum import Enum from frappy.modulebase import Done, Module, Feature -from frappy.modules import Attached, Communicator, \ - Drivable, Readable, Writable +from frappy.modules import Communicator, Drivable, Readable, Writable +from frappy.attached import Attached, AttachedDict from frappy.params import Command, Parameter, Limit from frappy.properties import Property from frappy.proxy import Proxy, SecNode, proxy_class diff --git a/frappy/modulebase.py b/frappy/modulebase.py index b1bd473c..a6f17eba 100644 --- a/frappy/modulebase.py +++ b/frappy/modulebase.py @@ -320,6 +320,9 @@ class Module(HasAccessibles): pollInfo = None triggerPoll = None # trigger event for polls. used on io modules and modules without io __poller = None # the poller thread, if used + SECoP_CLASS = None + SECoP_BASE_CLASSES = [] # predefined SECoP base classes + SECoP_CLASSES = [] # all predefined SECoP interface classes def __init__(self, name, logger, cfgdict, srv): # remember the secnode for interacting with other modules and the diff --git a/frappy/modules.py b/frappy/modules.py index 425e63d8..023a62ee 100644 --- a/frappy/modules.py +++ b/frappy/modules.py @@ -22,16 +22,19 @@ # ***************************************************************************** """Define base classes for real Modules implemented in the server""" - -from frappy.datatypes import FloatRange, \ - StatusType, StringType +from frappy.datatypes import BoolType, FloatRange, StatusType, StringType from frappy.errors import ConfigError, ProgrammingError from frappy.lib.enum import Enum -from frappy.params import Command, Parameter -from frappy.properties import Property from frappy.logging import HasComlog +from frappy.params import Command, Parameter from .modulebase import Module +from .attached import AttachedDict + +# import compatibility: +# pylint: disable=unused-import +from .properties import Property +from .attached import Attached class Readable(Module): @@ -98,6 +101,77 @@ class Drivable(Writable): """not implemented - this is a no-op""" +class AcquisitionChannel(Readable): + """A Readable which is part of a data acquisition.""" + interface_classes = ['AcquisitionChannel', 'Readable'] + # copy Readable.status and extend it with BUSY + status = Parameter(datatype=StatusType(Readable, 'BUSY')) + goal = Parameter('stops the data acquisition when it is reached', + FloatRange(), default=0, readonly=False, optional=True) + goal_enable = Parameter('enable goal', BoolType(), readonly=False, + default=False, optional=True) + + # clear is no longer part of the proposed spec, so it does not appear + # as optional command here. however, a subclass may still implement it + + +class AcquisitionController(Module): + """Controls other modules. + + Controls the data acquisition from AcquisitionChannels. + """ + interface_classes = ['AcquisitionController'] + # channels might be configured to an arbitrary number of channels with arbitrary roles + # - to forbid the use fo arbitrary roles, override base=None + # - to restrict roles and base classes override elements={: } + # and/or optional={: } + channels = AttachedDict('mapping of role to module name for attached channels', + elements=None, optional=None, + basecls=AcquisitionChannel, + extname='acquisition_channels') + status = Drivable.status + isBusy = Drivable.isBusy + # add pollinterval parameter to enable faster polling of the status + pollinterval = Readable.pollinterval + + def doPoll(self): + self.read_status() + + @Command() + def go(self): + """Start the acquisition. No-op if the controller is already Busy.""" + raise NotImplementedError() + + @Command(optional=True) + def prepare(self): + """Prepare the hardware so 'go' can trigger immediately.""" + + @Command(optional=True) + def hold(self): + """Pause the operation. + + The next go will continue without clearing any channels or resetting hardware.""" + + @Command(optional=True) + def stop(self): + """Stop the data acquisition or operation.""" + + +class Acquisition(AcquisitionController, AcquisitionChannel): # pylint: disable=abstract-method + """Combines AcquisitionController and AcquisitionChannel into one Module + + for the special case where there is only one channel. + remark: when using multiple inheritance, Acquisition must appear + before any base class inheriting from AcquisitionController + """ + interface_classes = ['Acquisition', 'Readable'] + channels = None # remove property + acquisition_key = Property('acquisition role (equivalent to NICOS preset name)', + StringType(), export=True, default='') + + doPoll = Readable.doPoll + + class Communicator(HasComlog, Module): """basic abstract communication module""" interface_classes = ['Communicator'] @@ -110,38 +184,3 @@ class Communicator(HasComlog, Module): :return: the reply """ raise NotImplementedError() - - -class Attached(Property): - """a special property, defining an attached module - - assign a module name to this property in the cfg file, - and the server will create an attribute with this module - - When mandatory is set to False, and there is no value or an empty string - given in the config file, the value of the attribute will be None. - """ - def __init__(self, basecls=Module, description='attached module', mandatory=True): - self.basecls = basecls - super().__init__(description, StringType(), mandatory=mandatory) - - def __get__(self, obj, owner): - if obj is None: - return self - modobj = obj.attachedModules.get(self.name) - if not modobj: - modulename = super().__get__(obj, owner) - if not modulename: - return None # happens when mandatory=False and modulename is not given - modobj = obj.secNode.get_module(modulename) - if not modobj: - raise ConfigError(f'attached module {self.name}={modulename!r} ' - f'does not exist') - if not isinstance(modobj, self.basecls): - raise ConfigError(f'attached module {self.name}={modobj.name!r} ' - f'must inherit from {self.basecls.__qualname__!r}') - obj.attachedModules[self.name] = modobj - return modobj - - def copy(self): - return Attached(self.basecls, self.description, self.mandatory) diff --git a/frappy_demo/acquisition.py b/frappy_demo/acquisition.py new file mode 100644 index 00000000..18f9b259 --- /dev/null +++ b/frappy_demo/acquisition.py @@ -0,0 +1,241 @@ +# ***************************************************************************** +# +# 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: +# Alexander Zaft +# Markus Zolliker +# +# ***************************************************************************** +import time +import random +import threading +from frappy.lib import clamp, mkthread +from frappy.core import IntRange, Parameter, ArrayOf, TupleOf, FloatRange, \ + IDLE, ERROR, BUSY +from frappy.modules import AcquisitionController, AcquisitionChannel, Acquisition +from frappy.params import Command + + +class AcquisitionSimulation: + def __init__(self, keys): + self.values = {k: 0 for k in keys} + self.err = None + self._stopflag = threading.Event() + self.run_acquisition = threading.Event() + self.lock = threading.Lock() + self.need_reset = False + self._thread = None + self.start() + + def start(self): + if self.need_reset: + self.reset() + if self._thread is None: + self._thread = mkthread(self.threadfun) + + def threadfun(self): + self.sim_interval = 1 + self.err = None + try: + self.__sim() + except Exception as e: + self.err = str(e) + # the thread stops here, but will be restarted with the go command + self._thread = None + + def __sim(self): + timestamp = time.time() + delay = 0 + while not self._stopflag.wait(delay): + self.run_acquisition.wait() + t = time.time() + diff = t - timestamp + if diff < self.sim_interval: + delay = clamp(0.1, self.sim_interval, 10) + continue + delay = 0 + with self.lock: + self.values = {k: v + max(0., random.normalvariate(4., 1.)) + for k, v in self.values.items()} + timestamp = t + + def reset(self): + with self.lock: + for key in self.values: + self.values[key] = 0 + self.need_reset = False + + def shutdown(self): + # unblock thread: + self._stopflag.set() + self.run_acquisition.set() + if self._thread and self._thread.is_alive(): + self._thread.join() + + +class Controller(AcquisitionController): + _status = None # for sticky status values + + def init_ac(self): + self.ac = AcquisitionSimulation(m.name for m in self.channels.values()) + self.ac.reset() + for key, channel in self.channels.items(): + self.log.debug('register %s: %s', key, channel.name) + channel.register_acq(self.ac) + + def initModule(self): + super().initModule() + self.init_ac() + + def read_status(self): + with self.ac.lock: + if self.ac.err: + status = self.Status.ERROR, self.ac.err + elif self.ac.run_acquisition.is_set(): + status = self.Status.BUSY, 'running acquisition' + else: + status = self._status or (self.Status.IDLE, '') + for chan in self.channels.values(): + chan.read_status() + return status + + def go(self): + self.ac.start() # restart sim thread if it failed + self.ac.run_acquisition.set() + self._status = None + self.read_status() + + def hold(self): + self.ac.run_acquisition.clear() + self._status = IDLE, 'paused' + self.read_status() + + def stop(self): + self.ac.run_acquisition.clear() + self.ac.need_reset = True + self._status = IDLE, 'stopped' + + @Command() + def clear(self): + """clear all channels""" + self.ac.reset() + + def shutdownModule(self): + self.ac.shutdown() + + +class Channel(AcquisitionChannel): + _status = None # for sticky status values + # activate optional parameters: + goal = Parameter() + goal_enable = Parameter() + + def register_acq(self, ac): + self.ac = ac + + def read_value(self): + with self.ac.lock: + try: + ret = self.ac.values[self.name] + except KeyError: + return -1 + if self.goal_enable and self.goal < ret: + if self.ac.run_acquisition.is_set(): + self.ac.run_acquisition.clear() + self.ac.need_reset = True + self._status = IDLE, 'hit goal' + else: + self._status = None + return ret + + def read_status(self): + if self.ac.err: + return ERROR, self.ac.err + if self.ac.run_acquisition.is_set(): + return BUSY, 'running acquisition' + return self._status or (IDLE, '') + + @Command() + def clear(self): + """clear this channel""" + with self.ac.lock: + try: + self.ac.values[self.name] = 0. + except KeyError: + pass + self.read_value() + + +class SimpleAcquisition(Acquisition, Controller, Channel): + def init_ac(self): + self.channels = {} + self.ac = AcquisitionSimulation([self.name]) + self.ac.reset() + + +class NoGoalAcquisition(Acquisition): + _value = 0 + _deadline = 0 + + def read_value(self): + return self._value + + def read_status(self): + if self.status[0] == BUSY: + overtime = time.time() - self._deadline + if overtime < 0: + return BUSY, '' + self.setFastPoll(False) + self._value = overtime + self.read_value() + return IDLE, '' + + def go(self): + self._value = 0 + self.status = BUSY, 'started' + self.setFastPoll(True, 0.1) + self._deadline = time.time() + 1 + self.read_status() + + +# TODO +class MatrixChannel(AcquisitionChannel): + roi = Parameter('region of interest', + ArrayOf(TupleOf(IntRange(), IntRange()), 0, 1), + default=[], readonly=False) + + def initModule(self): + self.data = [0.] * 128 + + def read_value(self): + # mean of data or roi + if self.roi: + b, e = self.roi[0] + else: + b, e = 0, len(self.data) - 1 + return self.data[b:e] / (e - b) + + def write_roi(self, roi): + pass + + @Command(result=ArrayOf(FloatRange())) + def get_data(self): + return self.data + + # axes + # binning + def clear(self): + raise NotImplementedError() diff --git a/frappy_demo/test.py b/frappy_demo/test.py index 218de41f..df317013 100644 --- a/frappy_demo/test.py +++ b/frappy_demo/test.py @@ -53,6 +53,7 @@ class WithAtt(Readable): def read_value(self): return self.att.read_value() + class LN2(Readable): """Just a readable.