update to gerrit version

Change-Id: Ifdaa28dd961a529cd9197c4c3639744f108b0a6a
This commit is contained in:
zolliker 2023-07-05 17:26:33 +02:00
parent ff6a98af92
commit aa7910c28c
12 changed files with 627 additions and 23 deletions

View File

@ -31,6 +31,7 @@ sys.path.insert(0, path.abspath(path.join(path.dirname(__file__), '..')))
import logging
from mlzlog import ColoredConsoleHandler
from frappy.gui.qt import QApplication
from frappy.gui.cfg_editor.mainwindow import MainWindow

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3
# pylint: disable=invalid-name
# -*- coding: utf-8 -*-
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
@ -23,8 +22,8 @@
#
# *****************************************************************************
import sys
import argparse
import sys
from os import path
# Add import path for inplace usage
@ -61,8 +60,9 @@ def parseArgv(argv):
action='store',
help="comma separated list of cfg files,\n"
"defaults to <name_of_the_instance>.\n"
"cfgfiles given without '.cfg' extension are searched in the configuration directory, "
"else they are treated as path names",
"cfgfiles given without '.cfg' extension are searched"
" in the configuration directory,"
" else they are treated as path names",
default=None)
parser.add_argument('-g',
'--gencfg',
@ -96,15 +96,13 @@ def main(argv=None):
generalConfig.init(args.gencfg)
logger.init(loglevel)
srv = Server(args.name, logger.log, cfgfiles=args.cfgfiles, interface=args.port, testonly=args.test)
srv = Server(args.name, logger.log, cfgfiles=args.cfgfiles,
interface=args.port, testonly=args.test)
if args.daemonize:
srv.start()
else:
try:
srv.run()
except KeyboardInterrupt:
pass
srv.run()
if __name__ == '__main__':

43
cfg/seop_cfg.py Normal file
View File

@ -0,0 +1,43 @@
description = """
3He system in Lab ...
"""
Node('mlz_seop',
description,
'tcp://10767',
)
Mod('cell',
'frappy_mlz.seop.Cell',
'interface module to the driver',
config_directory = '/home/jcns/daemon/config',
)
Mod('afp',
'frappy_mlz.seop.Afp',
'controls the afp flip of the cell',
cell = 'cell'
)
Mod('nmr',
'frappy_mlz.seop.Nmr',
'controls the ',
cell = 'cell'
)
fitparams = [
('amplitude', 'V'),
('T1', 's'),
('T2', 's'),
('b', ''),
('frequency', 'Hz'),
('phase', 'deg'),
]
for param, unit in fitparams:
Mod(f'nmr_{param.lower()}',
'frappy_mlz.seop.FitParam',
f'fittet parameter {param} of NMR',
cell = 'cell',
value = Param(unit=unit),
sigma = Param(unit=unit),
param = param,
)

46
debian/changelog vendored
View File

@ -1,3 +1,49 @@
frappy-core (0.17.13) focal; urgency=medium
[ Alexander Zaft ]
* add egg-info to gitignore
[ Markus Zolliker ]
* GUI bugfix: use isChecked instead of checkState in BoolInput
* frappy_psi.mercury/triton: add control_off command
* frappy_psi.phytron: rename reset_error to clear_errors
* frappy.mixins.HasOutputModule
* frappy_psi.mercury: proper handling of control_active
* add a hook for reads to be done initially
* frappy_psi.triton: fix HeaterOutput.limit
* frappy_psi.magfield: bug fix
* frappy_psi.sea: bug fixes
[ Alexander Zaft ]
* server: fix systemd variable scope
-- Alexander Zaft <jenkins@frm2.tum.de> Tue, 20 Jun 2023 14:38:00 +0200
frappy-core (0.17.12) focal; urgency=medium
[ Alexander Zaft ]
* Warn about duplicate module definitions in a file
* Add influences property to parameters/commands
[ Markus Zolliker ]
* frappy.client: dummy logger is missing 'exception' method
[ Alexander Zaft ]
* Add SEOP He3-polarization device
* Typo in influences description
* seop: fix fitparam command
[ Markus Zolliker ]
* silently catches error in systemd.daemon.notify
[ Alexander Zaft ]
* io: add option to retry first ident request
* config: fix merge_modules
* io: followup fix for retry-first-ident
* entangle: fix tango guards for pytango 9.3
-- Alexander Zaft <jenkins@frm2.tum.de> Tue, 13 Jun 2023 06:51:27 +0200
frappy-core (0.17.11) focal; urgency=medium
[ Alexander Zaft ]

View File

@ -401,10 +401,15 @@ class UniqueObject:
def merge_status(*args):
"""merge status
the status with biggest code wins
texts matching maximal code are joined with ', '
for combining stati of different mixins
- the status with biggest code wins
- texts matching maximal code are joined with ', '
- if texts already contain ', ', it is considered as composed by
individual texts and duplication is avoided. when commas are used
for other purposes, the behaviour might be surprising
"""
maxcode = max(a[0] for a in args)
merged = [a[1] for a in args if a[0] == maxcode and a[1]]
# use dict instead of set for preserving order
merged = {m: True for mm in merged for m in mm.split(', ')}
return maxcode, ', '.join(merged)

View File

@ -640,6 +640,13 @@ class Module(HasAccessibles):
all parameters are polled once
"""
def shutdownModule(self):
"""called when the sever shuts down
any cleanup-work should be performed here, like closing threads and
saving data.
"""
def doPoll(self):
"""polls important parameters like value and status

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
@ -24,6 +23,7 @@
"""Define helpers"""
import os
import signal
import sys
from collections import OrderedDict
@ -33,7 +33,6 @@ from frappy.dynamic import Pinata
from frappy.lib import formatException, generalConfig, get_class, mkthread
from frappy.lib.multievent import MultiEvent
from frappy.params import PREDEFINED_ACCESSIBLES
from frappy.modules import Attached
try:
from daemon import DaemonContext
@ -106,6 +105,12 @@ class Server:
self._cfgfiles = cfgfiles
self._pidfile = os.path.join(generalConfig.piddir, name + '.pid')
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
def signal_handler(self, _num, _frame):
if hasattr(self, 'interface') and self.interface:
self.shutdown()
def start(self):
if not DaemonContext:
@ -127,17 +132,18 @@ class Server:
return f"{cls.__name__} class don't know how to handle option(s): {', '.join(options)}"
def restart_hook(self):
pass
"""Actions to be done on restart. May be overridden by a subclass."""
def run(self):
global systemd # pylint: disable=global-statement
while self._restart:
self._restart = False
try:
# TODO: make systemd notifications configurable
if systemd: # pylint: disable=used-before-assignment
if systemd:
systemd.daemon.notify("STATUS=initializing")
except Exception:
systemd = None # pylint: disable=redefined-outer-name
systemd = None
try:
self._processCfg()
if self._testonly:
@ -156,13 +162,27 @@ class Server:
self.log.info('startup done, handling transport messages')
if systemd:
systemd.daemon.notify("READY=1\nSTATUS=accepting requests")
self.interface.serve_forever()
self.interface.server_close()
t = mkthread(self.interface.serve_forever)
# we wait here on the thread finishing, which means we got a
# signal to shut down or an exception was raised
# TODO: get the exception (and re-raise?)
t.join()
self.interface = None # fine due to the semantics of 'with'
# server_close() called by 'with'
self.log.info(f'stopped listenning, cleaning up'
f' {len(self.modules)} modules')
# if systemd:
# if self._restart:
# systemd.daemon.notify('RELOADING=1')
# else:
# systemd.daemon.notify('STOPPING=1')
for name in self._getSortedModules():
self.modules[name].shutdownModule()
if self._restart:
self.restart_hook()
self.log.info('restart')
else:
self.log.info('shut down')
self.log.info('restarting')
self.log.info('shut down')
def restart(self):
if not self._restart:
@ -268,3 +288,41 @@ class Server:
# history_path = os.environ.get('ALTERNATIVE_HISTORY')
# if history_path:
# from frappy_<xx>.historywriter import ... etc.
def _getSortedModules(self):
"""Sort modules topologically by inverse dependency.
Example: if there is an IO device A and module B depends on it, then
the result will be [B, A].
Right now, if the dependency graph is not a DAG, we give up and return
the unvisited nodes to be dismantled at the end.
Taken from Introduction to Algorithms [CLRS].
"""
def go(name):
if name in done: # visiting a node
return True
if name in visited:
visited.add(name)
return False # cycle in dependencies -> fail
visited.add(name)
if name in unmarked:
unmarked.remove(name)
for module in self.modules[name].attachedModules.values():
res = go(module.name)
if not res:
return False
visited.remove(name)
done.add(name)
l.append(name)
return True
unmarked = set(self.modules.keys()) # unvisited nodes
visited = set() # visited in DFS, but not completed
done = set()
l = [] # list of sorted modules
while unmarked:
if not go(unmarked.pop()):
self.log.error('cyclical dependency between modules!')
return l[::-1] + list(visited) + list(unmarked)
return l[::-1]

View File

@ -354,7 +354,7 @@ class Cryostat(CryoBase):
timestamp = t
self.read_value()
def shutdown(self):
def shutdownModule(self):
# should be called from server when the server is stopped
self._stopflag = True
if self._thread and self._thread.is_alive():

234
frappy_mlz/seop.py Normal file
View File

@ -0,0 +1,234 @@
# -*- 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:
# Georg Brandl <g.brandl@fz-juelich.de>
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
"""Adapter to the existing SEOP 3He spin filter system daemon."""
from os import path
# eventually he3control
from he3d import he3cell # pylint: disable=import-error
from frappy.core import Attached
from frappy.datatypes import ArrayOf, FloatRange, IntRange, StatusType, \
StringType, TupleOf
from frappy.errors import CommandRunningError
from frappy.modules import Command, Drivable, Module, Parameter, Property, \
Readable
from frappy.rwhandler import CommonReadHandler
integral = IntRange()
floating = FloatRange()
string = StringType()
# Configuration is kept in YAML files to stay compatible to the
# traditional 3He daemon, for now.
class Cell(Module):
""" Dummy module for creating He3Cell object in order for other modules to talk to the hardware.
Only deals with the config, and rotating the paramlog.
"""
config_directory = Property(
'Directory for the YAML config files', datatype=string)
def initModule(self):
super().initModule()
self.cell = he3cell.He3_cell(
path.join(self.config_directory, 'cell.yml'))
# Commands
@Command(result=string)
def raw_config_file(self):
"""return unparsed contents of yaml file"""
with open(self.cell._He3_cell__cfg_filename, 'r', encoding='utf-8') as f:
return str(f.read())
@Command(string, result=string)
def cfg_get(self, identifier):
"""Get a configuration value."""
return str(self.cell.cfg_get(identifier))
@Command((string, string), result=string)
def cfg_set(self, identifier, value):
"""Set a configuration value."""
try:
value = int(value)
except ValueError:
try:
value = float(value)
except ValueError:
pass
# The type is lost during transmission.
# Check type so the value to be set has the same type and
# is not eg. a string where an int would be needed in the config key.
oldty = type(self.cell.cfg_get(identifier))
if oldty is not type(value):
raise ValueError('Type of value to be set does not match the '
'value in the configuration!')
return str(self.cell.cfg_set(identifier, value))
@Command()
def nmr_paramlog_rotate(self):
"""resets fitting and switches to a new logfile"""
self.cell.nmr_paramlog_rotate()
class Afp(Readable):
"""Polarisation state of the SEOP waveplates"""
value = Parameter('Current polarisation state of the SEOP waveplates', IntRange(0, 1))
cell = Attached(Cell)
def read_value(self):
return self.cell.cell.afp_state_get()
# Commands
@Command(description='Flip polarization of SEOP waveplates')
def afp_flip(self):
self.cell.cell.afp_flip_do()
self.read_value()
class Nmr(Readable):
Status = Drivable.Status
status = Parameter(datatype=StatusType(Drivable.Status))
value = Parameter('Timestamp of last NMR', string)
cell = Attached(Cell)
def initModule(self):
super().initModule()
self.interval = 0
def read_value(self):
return str(self.cell.cell.nmr_timestamp_get())
def read_status(self):
cellstate = self.cell.cell.nmr_state_get()
if self.cell.cell.nmr_background_check():
status = self.Status.BUSY, 'running every %d seconds' % self.interval
else:
status = self.Status.IDLE, 'not running'
# TODO: what do we do here with None and -1?
# -> None basically indicates that the fit for the parameters did not converge
if cellstate is None:
return self.Status.IDLE, f'returned None, {status[1]}'
if cellstate in (0, 1):
return status[0], f'nmr cellstate {cellstate}, {status[1]}'
if cellstate == -1:
return self.Status.WARN, f'got error from cell, {status[1]}'
return self.Status.ERROR, 'Unrecognized cellstate!'
# Commands
@Command()
def nmr_do(self):
"""Triggers the NMR to run"""
self.cell.cell.nmr_do()
self.read_status()
@Command()
def bgstart(self):
"""Start background NMR"""
if self.isBusy():
raise CommandRunningError('backgroundNMR is already running')
interval = self.cell.cell.cfg_get('tasks/nmr/background/interval')
self.interval = interval
self.cell.cell.nmr_background_start(interval)
self.read_status()
@Command()
def bgstop(self):
"""Stop background NMR"""
self.cell.cell.nmr_background_stop()
self.read_status()
# Commands to get large datasets we do not want directly in the NICOS cache
@Command(result=TupleOf(ArrayOf(floating, maxlen=100000),
ArrayOf(floating, maxlen=100000)))
def get_processed_nmr(self):
"""Get data for processed signal."""
val= self.cell.cell.nmr_processed_get()
return (val['xval'], val['yval'])
@Command(result=TupleOf(ArrayOf(floating, maxlen=100000),
ArrayOf(floating, maxlen=100000)))
def get_raw_nmr(self):
"""Get raw signal data."""
val = self.cell.cell.nmr_raw_get()
return (val['xval'], val['yval'])
@Command(result=TupleOf(ArrayOf(floating, maxlen=100000),
ArrayOf(floating, maxlen=100000)))
def get_raw_spectrum(self):
"""Get the raw spectrum."""
val = self.cell.cell.nmr_raw_spectrum_get()
y = val['yval'][:len(val['xval'])]
return (val['xval'], y)
@Command(result=TupleOf(ArrayOf(floating, maxlen=100000),
ArrayOf(floating, maxlen=100000)))
def get_processed_spectrum(self):
"""Get the processed spectrum."""
val = self.cell.cell.nmr_processed_spectrum_get()
x = val['xval'][:len(val['yval'])]
return (x, val['yval'])
@Command(result=TupleOf(ArrayOf(string, maxlen=100),
ArrayOf(floating, maxlen=100)))
def get_amplitude(self):
"""Last 20 amplitude datapoints."""
rv = self.cell.cell.nmr_paramlog_get('amplitude', 20)
x = [ str(timestamp) for timestamp in rv['xval']]
return (x,rv['yval'])
@Command(result=TupleOf(ArrayOf(string, maxlen=100),
ArrayOf(floating, maxlen=100)))
def get_phase(self):
"""Last 20 phase datapoints."""
val = self.cell.cell.nmr_paramlog_get('phase', 20)
return ([str(timestamp) for timestamp in val['xval']], val['yval'])
class FitParam(Readable):
value = Parameter('fitted value', unit='$', default=0.0)
sigma = Parameter('variance of the fitted value', FloatRange(), default=0.0)
param = Property('the parameter that should be accesssed',
StringType(), export=False)
cell = Attached(Cell)
@CommonReadHandler(['value', 'sigma'])
def read_amplitude(self):
ret = self.cell.cell.nmr_param_get(self.param)
self.value = ret['value']
self.sigma = ret['sigma']
# Commands
@Command(integral, result=TupleOf(ArrayOf(string),
ArrayOf(floating)))
def nmr_paramlog_get(self, n):
"""returns the log of the last 'n' values for this parameter"""
val = self.cell.cell.nmr_paramlog_get(self.param, n)
return ([str(timestamp) for timestamp in val['xval']], val['yval'])

141
test/test_config.py Normal file
View File

@ -0,0 +1,141 @@
# *****************************************************************************
#
# 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>
#
# *****************************************************************************
# false positive with fixtures
# pylint: disable=redefined-outer-name
import pytest
from frappy.config import Collector, Config, Mod, NodeCollector, load_config, \
process_file, to_config_path
from frappy.errors import ConfigError
from frappy.lib import generalConfig
class LoggerStub:
def debug(self, fmt, *args):
pass
info = warning = exception = error = debug
handlers = []
@pytest.fixture
def log():
return LoggerStub()
PY_FILE = """Node('foonode', 'fodesc', 'fooface')
Mod('foo', 'frappy.modules.Readable', 'description', value=5)
Mod('bar', 'frappy.modules.Readable', 'about me', export=False)
Mod('baz', 'frappy.modules.Readable', 'things', value=Param(3, unit='BAR'))
"""
# fixture file system, TODO: make a bit nicer?
@pytest.fixture
def direc(tmp_path_factory):
d = tmp_path_factory.mktemp('cfgdir')
a = d / 'a'
b = d / 'b'
a.mkdir()
b.mkdir()
f = a / 'config_cfg.py'
pyfile = a / 'pyfile_cfg.py'
ff = b / 'test_cfg.py'
fff = b / 'alsoworks.py'
f.touch()
ff.touch()
fff.touch()
pyfile.write_text(PY_FILE)
generalConfig.testinit(confdir=f'{a}:{b}', piddir=str(d))
return d
files = [('config', 'a/config_cfg.py'),
('config_cfg', 'a/config_cfg.py'),
('config_cfg.py', 'a/config_cfg.py'),
('test', 'b/test_cfg.py'),
('test_cfg', 'b/test_cfg.py'),
('test_cfg.py', 'b/test_cfg.py'),
('alsoworks', 'b/alsoworks.py'),
('alsoworks.py', 'b/alsoworks.py'),
]
@pytest.mark.parametrize('file, res', files)
def test_to_cfg_path(log, direc, file, res):
assert to_config_path(file, log).endswith(res)
def test_cfg_not_existing(direc, log):
with pytest.raises(ConfigError):
to_config_path('idonotexist', log)
def collector_helper(node, mods):
n = NodeCollector()
n.add(*node)
m = Collector(Mod)
m.list = [Mod(module, '', '') for module in mods]
return n, m
configs = [
(['n1', 'desc', 'iface'], ['foo', 'bar', 'baz'], ['n2', 'foo', 'bar'],
['foo', 'more', 'other'], ['n1', 'iface', 5, {'foo'}]),
(['n1', 'desc', 'iface'], ['foo', 'bar', 'baz'], ['n2', 'foo', 'bar'],
['different', 'more', 'other'], ['n1', 'iface', 6, set()]),
]
@pytest.mark.parametrize('n1, m1, n2, m2, res', configs)
def test_merge(n1, m1, n2, m2, res):
name, iface, num_mods, ambig = res
c1 = Config(*collector_helper(n1, m1))
c2 = Config(*collector_helper(n2, m2))
c1.merge_modules(c2)
assert c1['node']['equipment_id'] == name
assert c1['node']['interface'] == iface
assert len(c1.module_names) == num_mods
assert c1.ambiguous == ambig
def do_asserts(ret):
assert len(ret.module_names) == 3
assert set(ret.module_names) == set(['foo', 'bar', 'baz'])
assert ret['node']['equipment_id'] == 'foonode'
assert ret['node']['interface'] == 'fooface'
assert ret['foo'] == {'cls': 'frappy.modules.Readable',
'description': 'description', 'value': {'value': 5}}
assert ret['bar'] == {'cls': 'frappy.modules.Readable',
'description': 'about me', 'export': {'value': False}}
assert ret['baz'] == {'cls': 'frappy.modules.Readable',
'description': 'things',
'value': {'value': 3, 'unit': 'BAR'}}
def test_process_file(direc, log):
ret = process_file(str(direc / 'a' / 'pyfile_cfg.py'), log)
do_asserts(ret)
def test_full(direc, log):
ret = load_config('pyfile_cfg.py', log)
do_asserts(ret)

View File

@ -22,7 +22,7 @@
import pytest
from frappy.lib import parse_host_port
from frappy.lib import parse_host_port, merge_status
@pytest.mark.parametrize('hostport, defaultport, result', [
@ -46,3 +46,19 @@ def test_parse_host(hostport, defaultport, result):
parse_host_port(hostport, defaultport)
else:
assert result == parse_host_port(hostport, defaultport)
@pytest.mark.parametrize('args, result', [
([(100, 'idle'), (200, 'warning')],
(200, 'warning')),
([(300, 'ramping'), (300, 'within tolerance')],
(300, 'ramping, within tolerance')),
([(300, 'ramping, within tolerance'), (300, 'within tolerance, slow'), (200, 'warning')],
(300, 'ramping, within tolerance, slow')),
# when a comma is used for other purposes than separating individual status texts,
# the behaviour might not be as desired. However, this case is somewhat constructed.
([(100, 'blue, yellow is my favorite'), (100, 'white, blue, red is a bad color mix')],
(100, 'blue, yellow is my favorite, white, red is a bad color mix')),
])
def test_merge_status(args, result):
assert merge_status(*args) == result

55
test/test_server.py Normal file
View File

@ -0,0 +1,55 @@
# *****************************************************************************
#
# 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>
#
# *****************************************************************************
import pytest
# pylint: disable=redefined-outer-name
from frappy.server import Server
from .test_config import direc # pylint: disable=unused-import
class LoggerStub:
def debug(self, fmt, *args):
pass
def getChild(self, *args):
return self
info = warning = exception = error = debug
handlers = []
@pytest.fixture
def log():
return LoggerStub()
def test_name_only(direc, log):
"""only see that this does not throw. get config from name."""
s = Server('pyfile', log)
s._processCfg()
def test_file(direc, log):
"""only see that this does not throw. get config from cfgfiles."""
s = Server('foo', log, cfgfiles='pyfile_cfg.py')
s._processCfg()