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
This commit is contained in:
Alexander Zaft
2023-04-17 14:25:34 +02:00
committed by Markus Zolliker
parent 84ee2dd508
commit 07377c8bf5
7 changed files with 497 additions and 42 deletions

40
cfg/acquisition_cfg.py Normal file
View File

@@ -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,
)

131
frappy/attached.py Normal file
View File

@@ -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 <enrico.faulhaber@frm2.tum.de>
# Markus Zolliker <markus.zolliker@psi.ch>
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
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 <key> 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 <key> of <basecls> for mandatory elements
:param optional: None or a dict <key> of <basecls> 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
<key> 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)

View File

@@ -29,8 +29,8 @@ from frappy.datatypes import ArrayOf, BLOBType, BoolType, EnumType, \
FloatRange, IntRange, ScaledInteger, StringType, StructOf, TupleOf, StatusType FloatRange, IntRange, ScaledInteger, StringType, StructOf, TupleOf, StatusType
from frappy.lib.enum import Enum from frappy.lib.enum import Enum
from frappy.modulebase import Done, Module, Feature from frappy.modulebase import Done, Module, Feature
from frappy.modules import Attached, Communicator, \ from frappy.modules import Communicator, Drivable, Readable, Writable
Drivable, Readable, Writable from frappy.attached import Attached, AttachedDict
from frappy.params import Command, Parameter, Limit from frappy.params import Command, Parameter, Limit
from frappy.properties import Property from frappy.properties import Property
from frappy.proxy import Proxy, SecNode, proxy_class from frappy.proxy import Proxy, SecNode, proxy_class

View File

@@ -320,6 +320,9 @@ class Module(HasAccessibles):
pollInfo = None pollInfo = None
triggerPoll = None # trigger event for polls. used on io modules and modules without io triggerPoll = None # trigger event for polls. used on io modules and modules without io
__poller = None # the poller thread, if used __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): def __init__(self, name, logger, cfgdict, srv):
# remember the secnode for interacting with other modules and the # remember the secnode for interacting with other modules and the

View File

@@ -22,16 +22,19 @@
# ***************************************************************************** # *****************************************************************************
"""Define base classes for real Modules implemented in the server""" """Define base classes for real Modules implemented in the server"""
from frappy.datatypes import BoolType, FloatRange, StatusType, StringType
from frappy.datatypes import FloatRange, \
StatusType, StringType
from frappy.errors import ConfigError, ProgrammingError from frappy.errors import ConfigError, ProgrammingError
from frappy.lib.enum import Enum from frappy.lib.enum import Enum
from frappy.params import Command, Parameter
from frappy.properties import Property
from frappy.logging import HasComlog from frappy.logging import HasComlog
from frappy.params import Command, Parameter
from .modulebase import Module 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): class Readable(Module):
@@ -98,6 +101,77 @@ class Drivable(Writable):
"""not implemented - this is a no-op""" """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={<key>: <basecls>}
# and/or optional={<key>: <basecls>}
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): class Communicator(HasComlog, Module):
"""basic abstract communication module""" """basic abstract communication module"""
interface_classes = ['Communicator'] interface_classes = ['Communicator']
@@ -110,38 +184,3 @@ class Communicator(HasComlog, Module):
:return: the reply :return: the reply
""" """
raise NotImplementedError() 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)

241
frappy_demo/acquisition.py Normal file
View File

@@ -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 <a.zaft@fz-juelich.de>
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
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()

View File

@@ -53,6 +53,7 @@ class WithAtt(Readable):
def read_value(self): def read_value(self):
return self.att.read_value() return self.att.read_value()
class LN2(Readable): class LN2(Readable):
"""Just a readable. """Just a readable.