Devices infrastructure and minimal server

starts and creates (server-side) devices
no daemonizing and servicing yet

hint: try starting:
$ bin/server.py -v start

Change-Id: I6ac7a78dfff309a459cc0338a8d0d319ee72ada5
This commit is contained in:
Enrico Faulhaber 2016-06-20 18:36:12 +02:00
parent 8a9d2da503
commit d3c430e1b9
25 changed files with 1390 additions and 57 deletions

3
.gitignore vendored
View File

@ -1,2 +1,5 @@
log/*
html/* html/*
*.pyc *.pyc
pid/*

283
.pylintrc Normal file
View File

@ -0,0 +1,283 @@
[MASTER]
# Specify a configuration file.
#rcfile=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Profiled execution.
profile=no
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS,doc,html,pid,log,etc
# Pickle collected data for later comparisons.
persistent=yes
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=
[MESSAGES CONTROL]
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time. See also the "--disable" option for examples.
#enable=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once).You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W"
disable=C0301,C0103,W0614,W0403,W0142,R0903,W0212,W0401,R0904,R0913,E1103
[REPORTS]
# Set the output format. Available formats are text, parseable, colorized, msvs
# (visual studio) and html. You can also give a reporter class, eg
# mypackage.mymodule.MyReporterClass.
output-format=colorized
# Include message's id in output
include-ids=yes
# Put messages in a separate file for each module / package specified on the
# command line instead of printing them on stdout. Reports (if any) will be
# written in a file name "pylint_global.[txt|html]".
files-output=no
# Tells whether to display a full report or only the messages
reports=no
# Python expression which should return a note less than 10 (10 is the highest
# note). You have access to the variables errors warning, statement which
# respectively contain the number of errors / warnings messages and the total
# number of statements analyzed. This is used by the global evaluation report
# (RP0004).
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
# Add a comment according to your evaluation note. This is used by the global
# evaluation report (RP0004).
comment=no
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details
#msg-template=
[TYPECHECK]
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
ignore-mixin-members=yes
# List of classes names for which member attributes should not be checked
# (useful for classes with attributes dynamically set).
ignored-classes=SQLObject
# When zope mode is activated, add a predefined set of Zope acquired attributes
# to generated-members.
zope=no
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E0201 when accessed. Python regular
# expressions are accepted.
generated-members=REQUEST,acl_users,aq_parent
[BASIC]
# Required attributes for module, separated by a comma
required-attributes=
# List of builtins function names that should not be used, separated by a comma
bad-functions=map,filter,apply,input
# Regular expression which should only match correct module names
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
# Regular expression which should only match correct module level names
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
# Regular expression which should only match correct class names
class-rgx=[A-Z_][a-zA-Z0-9]+$
# Regular expression which should only match correct function names
function-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct method names
method-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct instance attribute names
attr-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct argument names
argument-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct variable names
variable-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct attribute names in class
# bodies
class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
# Regular expression which should only match correct list comprehension /
# generator expression variable names
inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
# Good variable names which should always be accepted, separated by a comma
good-names=i,j,k,ex,Run,_
# Bad variable names which should always be refused, separated by a comma
bad-names=foo,bar,baz,toto,tutu,tata
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=__.*__
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=3
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,XXX,TODO,HACK
[FORMAT]
# Maximum number of characters on a single line.
max-line-length=80
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
# List of optional constructs for which whitespace checking is disabled
no-space-check=trailing-comma,dict-separator
# Maximum number of lines in a module
max-module-lines=1000
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
[SIMILARITIES]
# Minimum lines number of a similarity.
min-similarity-lines=4
# Ignore comments when computing similarities.
ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
# Ignore imports when computing similarities.
ignore-imports=no
[VARIABLES]
# Tells whether we should check for unused import in __init__ files.
init-import=no
# A regular expression matching the beginning of the name of dummy variables
# (i.e. not used).
dummy-variables-rgx=_.*|dummy
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid to define new builtins when possible.
additional-builtins=
[DESIGN]
# Maximum number of arguments for function / method
max-args=5
# Argument names that match this expression will be ignored. Default to name
# with leading underscore
ignored-argument-names=_.*
# Maximum number of locals for function / method body
max-locals=15
# Maximum number of return / yield for function / method body
max-returns=6
# Maximum number of branch for function / method body
max-branches=12
# Maximum number of statements in function / method body
max-statements=50
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
[IMPORTS]
# Deprecated modules which should not be used, separated by a comma
deprecated-modules=regsub,TERMIOS,Bastion,rexec
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report RP0402 must not be disabled)
import-graph=
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled)
ext-import-graph=
# Create a graph of internal dependencies in the given file (report RP0402 must
# not be disabled)
int-import-graph=
[CLASSES]
# List of interface methods to ignore, separated by a comma. This is used for
# instance to not check methods defines in Zope's Interface base class.
ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,__new__,setUp
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception"
overgeneral-exceptions=Exception

View File

@ -25,8 +25,83 @@ import os
import sys import sys
from os import path from os import path
sys.path[0]=path.abspath(path.join(sys.path[0],'../src')) # Pathes magic to make python find out stuff.
# also remember our basepath (for etc, pid lookup, etc)
basepath = path.abspath(path.join(sys.path[0], '..'))
etc_path = path.join(basepath, 'etc')
pid_path = path.join(basepath, 'pid')
log_path = path.join(basepath, 'log')
sys.path[0] = path.join(basepath, 'src')
import transport
transport.startup_server() import argparse
from lib import check_pidfile, start_server, kill_server
parser = argparse.ArgumentParser(description = "Manage a SECoP server")
loggroup = parser.add_mutually_exclusive_group()
loggroup.add_argument("-v", "--verbose", help="Output lots of diagnostic information",
action='store_true', default=False)
loggroup.add_argument("-q", "--quiet", help="suppress non-error messages", action='store_true',
default=False)
parser.add_argument("action", help="What to do with the server: (re)start, status or stop",
choices=['start', 'status', 'stop', 'restart'], default="status")
parser.add_argument("name", help="Name of the instance. Uses etc/name.cfg for configuration\n"
"may be omitted to mean ALL (which are configured)",
nargs='?', default='')
args = parser.parse_args()
import logging
loglevel = logging.DEBUG if args.verbose else (logging.ERROR if args.quiet else logging.INFO)
logging.basicConfig(level=loglevel, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('server')
logger.setLevel(loglevel)
fh = logging.FileHandler(path.join(log_path, 'server.log'), 'w')
fh.setLevel(loglevel)
logger.addHandler(fh)
logger.debug("action specified %r" % args.action)
def handle_servername(name, action):
pidfile = path.join(pid_path, name + '.pid')
cfgfile = path.join(etc_path, name + '.cfg')
if action == "restart":
handle_servername(name, 'stop')
handle_servername(name, 'start')
return
elif action == "start":
logger.info("Starting server %s" % name)
# XXX also do it !
start_server(name, basepath, loglevel)
elif action == "stop":
pid = check_pidfile(pidfile)
if pid:
logger.info("Stopping server %s" % name)
# XXX also do it!
stop_server(pidfile)
else:
logger.info("Server %s already dead" % name)
elif action == "status":
if check_pidfile(pidfile):
logger.info("Server %s is running." % name)
else:
logger.info("Server %s is DEAD!" % name)
else:
logger.error("invalid action specified: How can this ever happen???")
print "================================"
if not args.name:
logger.debug("No name given, iterating over all specified servers")
for dirpath, dirs, files in os.walk(etc_path):
for fn in files:
if fn.endswith('.cfg'):
handle_servername(fn[:-4], args.action)
else:
logger.debug('configfile with strange extension found: %r'
% path.basename(fn))
# ignore subdirs!
while(dirs):
dirs.pop()
else:
handle_servername(args.name, args.action)
print "================================"

View File

@ -1,6 +1,8 @@
Markdown docu to be generated Markdown docu to be generated
============================= =============================
[Notes](notes.html)
TODO's TODO's
====== ======

39
doc/notes.md Normal file
View File

@ -0,0 +1,39 @@
------
No installation required or recommended.
-----
everything runs directly from the checkout.
you need:
- python2.7.*
- pip
- linux OS (Mac may work as well)
install requirements with pip:
$ sudo pip install -r requirements.txt
to execute a program, prefix its name with bin/, e.g.:
$ bin/make_doc.py
$ bin/server.py start test
a testsuite is planned but nothing is there yet.
## structure ##
* bin contains the executables (make_doc.py, server.py)
* doc is the root node of the docu (see index.md)
* etc contains the configurations for the server(s) and devices
* html contains the docu after make_doc.py was run
* log contains some (hopefully) log output from the servers
* pid contains pidfiles if a server is running
* src contains the python source
* src/client: client specific stuff (proxy)
* src/devices: devices to be used by the server (and exported via SECoP)
* src/lib: helper stuff (startup, pidfiles, etc)
* src/protocol: protocol specific stuff
* src/errors.py: internal errors
* src/server.py: device-managing part of the server (transport is in src/protocol/transport)
* src/validators.py: validators used by the devices. may be moved to src/protocol
# THERE IS STILL MUCH WORK TO DO! #

View File

@ -6,6 +6,7 @@
* src/server for everything server related * src/server for everything server related
* src/client for everything client related (ProxyDevice!) * src/client for everything client related (ProxyDevice!)
* src/protocol for protocol specific things * src/protocol for protocol specific things
* need subtree for different implementations to play with
* src/lib for helpers and other stuff * src/lib for helpers and other stuff
* possibly a parallel src tree for cpp version * possibly a parallel src tree for cpp version
@ -20,16 +21,31 @@
## A Server ## ## A Server ##
* evaluate config.ini * get daemonizing working
* handle cmdline args (specify different server.ini) * handle -d (nodaemon) and -D (default, daemonize) cmd line args
* support Async data units * support Async data units
* support feature publishing and selection * support feature publishing and selection
* rewrite MessageHadler to be agnostic of server * rewrite MessageHandler to be agnostic of server
## Device framework ##
* unify PARAMS and CONFIG (if no default value is given,
it needs to be specified in cfgfile, otherwise its optional)
* supply properties for PARAMS to auto-generate async data units
## Testsuite ## ## Testsuite ##
* embedded tests inside the actual files grow difficult to maintain * embedded tests inside the actual files grow difficult to maintain
* needed ? => need a testsuite (nose+pylint?)
## docu ##
* mabe use sphinx to generate docu: a pdf can then be auto-generated....
* transfer build docu into wiki via automated jobfile
Problem: wiki does not understand .md or .html

View File

@ -3,14 +3,14 @@ bindto=localhost
bindport=10767 bindport=10767
protocol=pickle protocol=pickle
[device "LN2"] [device LN2]
class=devices.test.LN2 class=devices.test.LN2
[device "heater"] [device heater]
class=devices.test.Heater class=devices.test.Heater
maxheaterpower=10 maxheaterpower=10
[device "T1"] [device T1]
class=devices.test.temp class=devices.test.Temp
sensor="X34598T7" sensor="X34598T7"

View File

@ -1,5 +1,9 @@
# for generating docu # for generating docu
markdown>=2.6 markdown>=2.6
# general stuff
psutil
# daemonizing not yet functional (logging problems)
#daemonize
# for zmq # for zmq
#pyzmq>=13.1.0 #pyzmq>=13.1.0

0
src/__init__.py Normal file
View File

0
src/client/__init__.py Normal file
View File

0
src/devices/__init__.py Normal file
View File

170
src/devices/core.py Normal file
View File

@ -0,0 +1,170 @@
# -*- 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:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
#
# *****************************************************************************
"""Define Baseclasses for real devices implemented in the server"""
import types
import inspect
from errors import ConfigError, ProgrammingError
from protocol import status
# storage for CONFIGurable settings (from configfile)
class CONFIG(object):
def __init__(self, description, validator=None, default=None, unit=None):
self.description = description
self.validator = validator
self.default = default
self.unit = unit
# storage for PARAMeter settings (changeable during runtime)
class PARAM(object):
def __init__(self, description, validator=None, default=None, unit=None, readonly=False):
self.description = description
self.validator = validator
self.default = default
self.unit = unit
self.readonly = readonly
# internal caching...
self.currentvalue = default
# storage for CMDs settings (names + call signature...)
class CMD(object):
def __init__(self, description, *args):
self.description = description
self.arguments = args
# Meta class
# warning: MAGIC!
class DeviceMeta(type):
def __new__(mcs, name, bases, attrs):
newtype = type.__new__(mcs, name, bases, attrs)
if '__constructed__' in attrs:
return newtype
# merge CONFIG, PARAM, CMDS from all sub-classes
for entry in ['CONFIG', 'PARAMS', 'CMDS']:
newentry = {}
for base in reversed(bases):
if hasattr(base, entry):
newentry.update(getattr(base, entry))
newentry.update(attrs.get(entry, {}))
setattr(newtype, entry, newentry)
# check validity of entries
for cname, info in newtype.CONFIG.items():
if not isinstance(info, CONFIG):
raise ProgrammingError("%r: device CONFIG %r should be a CONFIG object!" %
(name, cname))
#XXX: greate getters for the config value
for pname, info in newtype.PARAMS.items():
if not isinstance(info, PARAM):
raise ProgrammingError("%r: device PARAM %r should be a PARAM object!" %
(name, pname))
#XXX: greate getters and setters, setters should send async updates
# also collect/update information about CMD's
setattr(newtype, 'CMDS', getattr(newtype, 'CMDS', {}))
for name in attrs:
if name.startswith('do'):
value = getattr(newtype, name)
if isinstance(value, types.MethodType):
argspec = inspect.getargspec(value)
if argspec[0] and argspec[0][0] == 'self':
del argspec[0][0]
newtype.CMDS[name] = CMD(value.get('__doc__', name), *argspec)
attrs['__constructed__'] = True
return newtype
# Basic device class
class Device(object):
"""Basic Device, doesn't do much"""
__metaclass__ = DeviceMeta
# CONFIG, PARAMS and CMDS are auto-merged upon subclassing
CONFIG = {}
PARAMS = {}
CMDS = {}
SERVER = None
def __init__(self, devname, serverobj, logger, cfgdict):
# remember the server object (for the async callbacks)
self.SERVER = serverobj
self.log = logger
self.name = devname
# check config for problems
# only accept config items specified in CONFIG
for k, v in cfgdict.items():
if k not in self.CONFIG:
raise ConfigError('Device %s:config Parameter %r not unterstood!' % (self.name, k))
# complain if a CONFIG entry has no default value and is not specified in cfgdict
for k, v in self.CONFIG.items():
if k not in cfgdict:
if 'default' not in v:
raise ConfigError('Config Parameter %r was not given and not default value exists!' % k)
cfgdict[k] = v['default'] # assume default value was given.
# now 'apply' config, passing values through the validators and store as attributes
for k, v in cfgdict.items():
# apply validator, complain if type does not fit
validator = self.CONFIG[k].validator
if validator is not None:
# only check if validator given
try:
v = validator(v)
except ValueError as e:
raise ConfigError("Device %s: config paramter %r:\n%r" % (self.name, k, e))
# XXX: with or without prefix?
setattr(self, 'config_' + k, v)
# set default parameter values as inital values
for k, v in self.PARAMS.items():
# apply validator, complain if type does not fit
validator = v.validator
value = v.default
if validator is not None:
# only check if validator given
value = validator(value)
setattr(self, k, v)
def init(self):
# may be overriden in other classes
pass
class Readable(Device):
"""Basic readable device, providing the RO parameter 'value' and 'status'"""
PARAMS = {
'value' : PARAM('current value of the device', readonly=True),
'status' : PARAM('current status of the device',
readonly=True),
}
def read_value(self, maxage=0):
raise NotImplementedError
def read_status(self):
return status.OK
class Driveable(Readable):
"""Basic Driveable device, providing a RW target parameter to those of a Readable"""
PARAMS = {
'target' : PARAM('target value of the device'),
}
def write_target(self, value):
raise NotImplementedError

323
src/devices/cryo.py Normal file
View File

@ -0,0 +1,323 @@
#!/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:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
# *****************************************************************************
"""playing implementation of a (simple) simulated cryostat"""
from math import atan
import time
import random
import threading
from devices.core import Driveable, CONFIG, PARAM
from protocol import status
from validators import floatrange, positive, mapping
from lib import clamp
hack = []
class Cryostat(Driveable):
"""simulated cryostat with heat capacity on the sample, cooling power and thermal transfer functions"""
CONFIG = dict(
jitter=CONFIG("amount of random noise on readout values",
validator=floatrange(0, 1), default=1,
),
T_start=CONFIG("starting temperature for simulation",
validator=positive, default=2,
),
looptime=CONFIG("timestep for simulation",
validator=positive, default=1, unit="s",
),
)
PARAMS = dict(
ramp=PARAM("ramping speed in K/min",
validator=floatrange(0, 1e3), default=1,
),
setpoint=PARAM("ramping speed in K/min",
validator=float, default=1, readonly=True,
),
maxpower=PARAM("Maximum heater power in W",
validator=float, default=0, readonly=True, unit="W",
),
heater=PARAM("current heater setting in %",
validator=float, default=0, readonly=True, unit="%",
),
heaterpower=PARAM("current heater power in W",
validator=float, default=0, readonly=True, unit="W",
),
target=PARAM("target temperature in K",
validator=float, default=0, unit="K",
),
p=PARAM("regulation coefficient 'p' in %/K",
validator=positive, default=40, unit="%/K",
),
i=PARAM("regulation coefficient 'i'",
validator=floatrange(0, 100), default=10,
),
d=PARAM("regulation coefficient 'd'",
validator=floatrange(0, 100), default=2,
),
mode=PARAM("mode of regulation",
validator=mapping('ramp', 'pid', 'openloop'), default='pid',
),
tolerance=PARAM("temperature range for stability checking",
validator=floatrange(0, 100), default=0.1, unit='K',
),
window=PARAM("time window for stability checking",
validator=floatrange(1, 900), default=30, unit='s',
),
timeout=PARAM("max waiting time for stabilisation check",
validator=floatrange(1, 36000), default=900, unit='s',
),
)
def init(self):
self._stopflag = False
self._thread = threading.Thread(target=self.thread)
self._thread.daemon = True
self._thread.start()
#XXX: hack!!! use a singleton as registry for the other devices to access this one...
hack.append(self)
def read_status(self):
# instead of asking a 'Hardware' take the value from the simulation thread
return self.status
def read_value(self, maxage=0):
# return regulation value (averaged regulation temp)
return self.regulationtemp + self.config_jitter * (0.5 - random.random())
def read_target(self, maxage=0):
return self.target
def write_target(self, value):
self.target = value
# next request will see this status, until the loop updates it
self.status = (status.BUSY, 'new target set')
def read_maxpower(self, maxage=0):
return self.maxpower
def write_maxpower(self, newpower):
# rescale heater setting in % to keep the power
self.heater = max(0, min(100, self.heater * self.maxpower / float(newpower)))
self.maxpower = newpower
def doStop(self):
# stop the ramp by setting current value as target
# XXX: there may be use case where setting the current temp may be better
self.write_target(self.setpoint)
#
# calculation helpers
#
def __coolerPower(self, temp):
"""returns cooling power in W at given temperature"""
# quadratic up to 42K, is linear from 40W@42K to 100W@600K
# return clamp((temp-2)**2 / 32., 0., 40.) + temp * 0.1
return clamp(15 * atan(temp * 0.01) ** 3, 0., 40.) + temp * 0.1 - 0.2
def __coolerCP(self, temp):
"""heat capacity of cooler at given temp"""
return 75 * atan(temp / 50)**2 + 1
def __heatLink(self, coolertemp, sampletemp):
"""heatflow from sample to cooler. may be negative..."""
flow = (sampletemp - coolertemp) * \
((coolertemp + sampletemp) ** 2)/400.
cp = clamp(self.__coolerCP(coolertemp) * self.__sampleCP(sampletemp),
1, 10)
return clamp(flow, -cp, cp)
def __sampleCP(self, temp):
return 3 * atan(temp / 30) + \
12 * temp / ((temp - 12.)**2 + 10) + 0.5
def __sampleLeak(self, temp):
return 0.02/temp
def thread(self):
self.sampletemp = self.config_T_start
self.regulationtemp = self.config_T_start
self.status = status.OK
while not self._stopflag:
try:
self.__sim()
except Exception as e:
self.log.exception(e)
self.status = status.ERROR, str(e)
def __sim(self):
# complex thread handling:
# a) simulation of cryo (heat flow, thermal masses,....)
# b) optional PID temperature controller with windup control
# c) generating status+updated value+ramp
# this thread is not supposed to exit!
# local state keeping:
regulation = self.regulationtemp
sample = self.sampletemp
window = [] # keep history values for stability check
timestamp = time.time()
heater = 0
lastflow = 0
last_heaters = (0, 0)
delta = 0
I = D = 0
lastD = 0
damper = 1
lastmode = self.mode
while not self._stopflag:
t = time.time()
h = t - timestamp
if h < self.looptime / damper:
time.sleep(clamp(self.looptime / damper - h, 0.1, 60))
continue
# a)
sample = self.sampletemp
regulation = self.regulationtemp
heater = self.heater
heatflow = self.__heatLink(regulation, sample)
self.log.debug('sample = %.5f, regulation = %.5f, heatflow = %.5g'
% (sample, regulation, heatflow))
newsample = max(0,
sample + (self.__sampleLeak(sample) - heatflow) /
self.__sampleCP(sample) * h)
# avoid instabilities due to too small CP
newsample = clamp(newsample, sample, regulation)
regdelta = (heater * 0.01 * self.maxpower + heatflow -
self.__coolerPower(regulation))
newregulation = max(0, regulation +
regdelta / self.__coolerCP(regulation) * h)
# b) see
# http://brettbeauregard.com/blog/2011/04/improving-the-beginners-pid-introduction/
if self.mode != 'openloop':
# fix artefacts due to too big timesteps
# actually i would prefer reducing looptime, but i have no
# good idea on when to increase it back again
if heatflow * lastflow != -100:
if (newregulation - newsample) * (regulation - sample) < 0:
# newregulation = (newregulation + regulation) / 2
# newsample = (newsample + sample) / 2
damper += 1
lastflow = heatflow
error = self.setpoint - newregulation
# use a simple filter to smooth delta a little
delta = (delta + regulation - newregulation) / 2.
kp = self.p / 10. # LakeShore P = 10*k_p
ki = kp * abs(self.i) / 500. # LakeShore I = 500/T_i
kd = kp * abs(self.d) / 2. # LakeShore D = 2*T_d
P = kp * error
I += ki * error * h
D = kd * delta / h
# avoid reset windup
I = clamp(I, 0., 100.) # I is in %
# avoid jumping heaterpower if switching back to pid mode
if lastmode != self.mode:
# adjust some values upon switching back on
I = self.heater - P - D
v = P + I + D
# in damping mode, use a weighted sum of old + new heaterpower
if damper > 1:
v = ((damper ** 2 - 1) * self.heater + v) / damper ** 2
# damp oscillations due to D switching signs
if D * lastD < -0.2:
v = (v + heater) / 2.
# clamp new heater power to 0..100%
heater = clamp(v, 0., 100.)
lastD = D
self.log.debug('PID: P = %.2f, I = %.2f, D = %.2f, '
'heater = %.2f' % (P, I, D, heater))
# check for turn-around points to detect oscillations ->
# increase damper
x, y = last_heaters
if (x + 0.1 < y and y > heater + 0.1) or \
(x > y + 0.1 and y + 0.1 < heater):
damper += 1
last_heaters = (y, heater)
else:
# self.heaterpower is set manually, not by pid
heater = self.heater
last_heaters = (0, 0)
heater = round(heater, 3)
sample = newsample
regulation = newregulation
lastmode = self.mode
# c)
if self.setpoint != self.target:
if self.ramp == 0:
maxdelta = 10000
else:
maxdelta = self.ramp / 60. * h
try:
self.setpoint = round(self.setpoint +
clamp(self.target - self.setpoint,
-maxdelta, maxdelta), 3)
self.log.debug('setpoint changes to %r (target %r)' %
(self.setpoint, self.target))
except (TypeError, ValueError):
# self.target might be None
pass
# temperature is stable when all recorded values in the window
# differ from setpoint by less than tolerance
currenttime = time.time()
window.append((currenttime, sample))
while window[0][0] < currenttime - self.window:
# remove old/stale entries
window.pop(0)
# obtain min/max
deviation = 0
for _, T in window:
if abs(T-self.target) > deviation:
deviation = abs(T-self.target)
if (len(window) < 3) or deviation > self.tolerance:
self.status = status.BUSY, 'unstable'
elif self.setpoint == self.target:
self.status = status.OK, 'at target'
damper -= (damper - 1) / 10. # max value for damper is 11
else:
self.status = status.BUSY, 'ramping setpoint'
damper -= (damper - 1) / 20.
self.regulationtemp = round(regulation, 3)
self.sampletemp = round(sample, 3)
self.heaterpower = round(heater * self.maxpower * 0.01, 3)
self.heater = heater
timestamp = t
def shutdown(self):
# should be called from server when the server is stopped
self._stopflag = True
if self._thread and self._thread.isAlive():
self._thread.join()

64
src/devices/test.py Normal file
View File

@ -0,0 +1,64 @@
#!/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:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
# *****************************************************************************
"""testing devices"""
import random
from devices.core import Readable, Driveable, CONFIG, PARAM
from validators import floatrange
class LN2(Readable):
"""Just a readable.
class name indicates it to be a sensor for LN2, but the implementation may do anything"""
def read_value(self, maxage=0):
return round(100*random.random(), 1)
class Heater(Driveable):
"""Just a driveable.
class name indicates it to be some heating element, but the implementation may do anything"""
CONFIG = {
'maxheaterpower' : CONFIG('maximum allowed heater power',
validator=floatrange(0, 100), unit='W'),
}
def read_value(self, maxage=0):
return round(100*random.random(), 1)
def write_target(self, target):
pass
class Temp(Driveable):
"""Just a driveable.
class name indicates it to be some temperature controller, but the implementation may do anything"""
CONFIG = {
'sensor' : CONFIG("Sensor number or calibration id",
validator=str),
}
def read_value(self, maxage=0):
return round(100*random.random(), 1)
def write_target(self, target):
pass

View File

@ -1,3 +1,4 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# ***************************************************************************** # *****************************************************************************
# #
@ -19,20 +20,13 @@
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de> # Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
# #
# ***************************************************************************** # *****************************************************************************
"""error class for our little framework"""
"""Define helpers""" class SECoPServerError(Exception):
pass
class attrdict(dict): class ConfigError(SECoPServerError):
def __getattr__(self, key): pass
return self[key]
def __setattr__(self, key, value):
self[key] = value
if __name__ == '__main__':
print "minimal testing: lib"
d = attrdict(a=1, b=2)
_ = d.a + d['b']
d.c = 9
d['d'] = 'c'
assert d[d.d] == 9
class ProgrammingError(SECoPServerError):
pass

76
src/lib/__init__.py Normal file
View File

@ -0,0 +1,76 @@
# -*- 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:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
#
# *****************************************************************************
"""Define helpers"""
import logging
from os import path
class attrdict(dict):
"""a normal dict, providing access also via attributes"""
def __getattr__(self, key):
return self[key]
def __setattr__(self, key, value):
self[key] = value
def clamp(_min, value, _max):
"""return the median of 3 values,
i.e. value if min <= value <= max, else min or max depending on which side
value lies outside the [min..max] interval
"""
# return median, i.e. clamp the the value between min and max
return sorted([_min, value, _max])[1]
def get_class(spec):
"""loads a class given by string in dotted notaion (as python would do)"""
modname, classname = spec.rsplit('.', 1)
import importlib
# module = importlib.import_module(modname)
module = __import__(spec)
return getattr(module, classname)
def make_logger(inst='server', name='', base_path='', loglevel=logging.INFO):
# XXX: rework this! (outsource to a logging module...)
if name:
inst = '%s %s' % (inst, name)
logging.basicConfig(level=loglevel, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(inst)
logger.setLevel(loglevel)
fh = logging.FileHandler(path.join(base_path, 'log', (name or inst) + '.log'))
fh.setLevel(loglevel)
logger.addHandler(fh)
return logger
# moved below definitions to break import cycle
from pidfile import *
from startup import *
if __name__ == '__main__':
print "minimal testing: lib"
d = attrdict(a=1, b=2)
_ = d.a + d['b']
d.c = 9
d['d'] = 'c'
assert d[d.d] == 9

53
src/lib/pidfile.py Normal file
View File

@ -0,0 +1,53 @@
# -*- 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:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
#
# *****************************************************************************
"""Define pidfile helpers"""
import os
import atexit
import psutil
def read_pidfile(pidfile):
"""read the given pidfile, return the pid as an int
or None upon errors (file not existing)"""
try:
with open(pidfile, 'r') as f:
return int(f.read())
except OSError:
return None
def remove_pidfile(pidfile):
"""remove the given pidfile, typically at end of the process"""
os.remove(pidfile)
def write_pidfile(pidfile, pid):
"""write the given pid to the given pidfile"""
with open(pidfile, 'w') as f:
f.write('%d\n' % pid)
atexit.register(remove_pidfile, pidfile)
def check_pidfile(pidfile):
"""check if the process from a given pidfile is still running"""
pid = read_pidfile(pidfile)
return False if pid is None else psutil.pid_exists(pid)

133
src/lib/startup.py Normal file
View File

@ -0,0 +1,133 @@
# -*- 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:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
#
# *****************************************************************************
"""Define helpers"""
import os
import psutil
import daemonize
import ConfigParser
from lib import read_pidfile, write_pidfile, get_class, make_logger
from server import DeviceServer as Server
from errors import ConfigError
__ALL__ = ['kill_server', 'start_server']
def kill_server(pidfile):
"""kill a server specified by a pidfile"""
pid = read_pidfile(pidfile)
if pid is None:
# already dead/not started yet
return
# get process for this pid
for proc in psutil.process_iter():
if proc.pid == pid:
break
proc.terminate()
proc.wait(3)
proc.kill()
def start_server(srvname, base_path, loglevel, daemon=False):
"""start a server, part1
handle the daemonizing and logging stuff and call the second step
"""
pidfile = os.path.join(base_path, 'pid', srvname + '.pid')
if daemon:
# dysfunctional :(
daemonproc = daemonize.Daemonize("server %s" % srvname,
pid=pidfile,
action=lambda: startup(srvname, base_path, loglevel),
)
daemonproc.start()
else:
write_pidfile(pidfile, os.getpid())
startup(srvname, base_path, loglevel) # blocks!
# unexported stuff here
def startup(srvname, base_path, loglevel):
"""really start a server (part2)
loads the config, initiate all objects, link them together
and finally start the interface server.
Never returns. (may raise)
"""
cfgfile = os.path.join(base_path, 'etc', srvname + '.cfg')
logger = make_logger('server', srvname, base_path=base_path, loglevel=loglevel)
logger.debug("parsing %r" % cfgfile)
parser = ConfigParser.SafeConfigParser()
if not parser.read([cfgfile]):
logger.error("Couldn't read cfg file !")
raise ConfigError("Couldn't read cfg file %r" % cfgfile)
# evaluate Server specific stuff
if not parser.has_section('server'):
logger.error("cfg file needs a 'server' section!")
raise ConfigError("cfg file %r needs a 'server' section!" % cfgfile)
serveropts = dict(item for item in parser.items('server'))
# check serveropts (init server)
# this raises if something wouldn't work
logger.debug("Creating device server")
server = Server(logger, serveropts)
# iterate over all sections, checking for devices
deviceopts = []
for section in parser.sections():
if section == "server":
continue # already handled, see above
if section.lower().startswith("device"):
# device section
devname = section[len('device '):] # omit leading 'device ' string
devopts = dict(item for item in parser.items(section))
if 'class' not in devopts:
logger.error("Device %s needs a class option!")
raise ConfigError("cfgfile %r: Device %s needs a class option!" % (cfgfile, devname))
# try to import the class, raise if this fails
devopts['class'] = get_class(devopts['class'])
# all went well so far
deviceopts.append([devname, devopts])
# check devices by creating them
devs = {}
for devname, devopts in deviceopts:
devclass = devopts.pop('class')
# create device
logger.debug("Creating Device %r" % devname)
devobj = devclass(devname, server, logger, devopts)
devs[devname] = devobj
# connect devices with server
for devname, devobj in devs.items():
logger.info("registering device %r" % devname)
server.register_device(devobj, devname)
# also call init on the devices
logger.debug("device.init()")
devobj.init()
# handle requests until stop is requsted
logger.info('startup done, handling transport messages')
server.serve_forever()

0
src/protocol/__init__.py Normal file
View File

View File

@ -25,19 +25,7 @@
also define helpers to derive properties of the device""" also define helpers to derive properties of the device"""
from lib import attrdict from lib import attrdict
from protocol import status
class Status(object):
"""Map Menaing of a devices status to some constants
which may be used for transport"""
OK = 100
MOVING = 200
WARN = 300
UNSTABLE = 350
ERROR = 400
UNKNOWN = 999
status = Status()
# XXX: deriving PARS/CMDS should be done in a suitable metaclass.... # XXX: deriving PARS/CMDS should be done in a suitable metaclass....

31
src/protocol/status.py Normal file
View File

@ -0,0 +1,31 @@
#!/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:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
#
# *****************************************************************************
"""Define Status constants"""
# could also be some objects
OK = 100
BUSY = 200
WARN = 300
UNSTABLE = 350
ERROR = 400
UNKNOWN = -1

View File

@ -136,9 +136,14 @@ class SECoPRequestHandler(SocketServer.BaseRequestHandler):
class SECoPServer(SocketServer.ThreadingTCPServer, DeviceServer): class SECoPServer(SocketServer.ThreadingTCPServer, DeviceServer):
daemon_threads = False daemon_threads = False
def __init__(self, logger, serveropts):
def startup_server(): bindto = serveropts.pop('bindto', 'localhost')
srv = SECoPServer(('localhost', DEF_PORT), SECoPRequestHandler, portnum = DEF_PORT
bind_and_activate=True) if ':' in bindto:
srv.serve_forever() bindto, _port = bindto.rsplit(':')
srv.server_close() portnum = int(_port)
logger.debug("binding to %s:%d" % (bindto, portnum))
super(SECoPServer, self).__init__((bindto, portnum),
SECoPRequestHandler, bind_and_activate=True)
logger.info("SECoPServer initiated")
logger.debug('serveropts remaining: %r' % serveropts)

View File

@ -22,23 +22,25 @@
"""Define basic SECoP DeviceServer""" """Define basic SECoP DeviceServer"""
import logging
import time import time
from messages import parse, ListDevicesRequest, ListDeviceParamsRequest, \ from protocol.messages import parse, ListDevicesRequest, ListDeviceParamsRequest, \
ReadParamRequest, ErrorReply, MessageHandler ReadParamRequest, ErrorReply, MessageHandler
class DeviceServer(MessageHandler): class DeviceServer(MessageHandler):
def __init__(self): def __init__(self, logger, serveropts):
self._devices = {} self._devices = {}
self.log = logging self.log = logger
# XXX: check serveropts and raise if problems exist
# mandatory serveropts: interface=tcpip, encoder=pickle, frame=eol
# XXX: remaining opts are checked by the corresponding interface server
self.log.basicConfig(level=logging.WARNING, def serve_forever(self):
format='%(asctime)s %(levelname)s %(message)s') self.log.error("Serving not yet implemented!")
def registerDevice(self, deviceobj, devicename): def register_device(self, deviceobj, devicename):
# make the server export a deviceobj under a given name. # make the server export a deviceobj under a given name.
# all exportet properties are taken from the device # all exportet properties are taken from the device
if devicename in self._devices: if devicename in self._devices:
@ -47,7 +49,7 @@ class DeviceServer(MessageHandler):
self._devices[devicename] = deviceobj self._devices[devicename] = deviceobj
deviceobj.name = devicename deviceobj.name = devicename
def unRegisterDevice(self, device_obj_or_name): def unregister_device(self, device_obj_or_name):
if not device_obj_or_name in self._devices: if not device_obj_or_name in self._devices:
self.log.error('IGN: Device %r not registered!' % self.log.error('IGN: Device %r not registered!' %
device_obj_or_name) device_obj_or_name)
@ -55,12 +57,12 @@ class DeviceServer(MessageHandler):
del self._devices[device_obj_or_name] del self._devices[device_obj_or_name]
# may need to do more # may need to do more
def getDevice(self, devname): def get_device(self, devname):
"""returns the requested deviceObj or None""" """returns the requested deviceObj or None"""
devobj = self._devices.get(devname, None) devobj = self._devices.get(devname, None)
return devobj return devobj
def listDevices(self): def list_devices(self):
return list(self._devices.keys()) return list(self._devices.keys())
def handle(self, msg): def handle(self, msg):
@ -85,7 +87,8 @@ class DeviceServer(MessageHandler):
if __name__ == '__main__': if __name__ == '__main__':
from device import Driveable, status from devices.core import Driveable
from protocol import status
class TestDevice(Driveable): class TestDevice(Driveable):
name = 'Unset' name = 'Unset'
unit = 'Oinks' unit = 'Oinks'
@ -119,8 +122,8 @@ if __name__ == '__main__':
print "minimal testing: server" print "minimal testing: server"
srv = DeviceServer() srv = DeviceServer()
srv.registerDevice(TestDevice(), 'dev1') srv.register_device(TestDevice(), 'dev1')
srv.registerDevice(TestDevice(), 'dev2') srv.register_device(TestDevice(), 'dev2')
devices = parse(srv.handle(ListDevicesRequest()))[2]['list_of_devices'] devices = parse(srv.handle(ListDevicesRequest()))[2]['list_of_devices']
print 'Srv exports these devices:', devices print 'Srv exports these devices:', devices
for dev in sorted(devices): for dev in sorted(devices):

71
src/validators.py Normal file
View File

@ -0,0 +1,71 @@
# -*- 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:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
#
# *****************************************************************************
"""Define validators."""
# a Validator validates a given object and raises an ValueError if it doesn't fit
# easy python validators: int(), float(), str()
class floatrange(object):
def __init__(self, lower, upper):
self.lower = float(lower)
self.upper = float(upper)
def __call__(self, value):
value = float(value)
if not self.lower <= value <= self.upper:
raise ValueError('Floatrange: value %r must be within %f and %f' % (value, self.lower, self.upper))
return value
def positive(obj):
if obj <= 0:
raise ValueError('Value %r must be positive!' % obj)
return obj
def nonnegative(obj):
if obj < 0:
raise ValueError('Value %r must be zero or positive!' % obj)
return obj
class mapping(object):
def __init__(self, *args, **kwds):
self.mapping = {}
# use given kwds directly
self.mapping.update(kwds)
# enumerate args
i = -1
args = list(args)
while args:
i += 1
if i in self.mapping:
continue
self.mapping[args.pop(0)] = i
# generate reverse mapping too for use by protocol
self.revmapping = {}
for k, v in self.mapping.items():
self.revmapping[v] = k
def __call__(self, obj):
if obj in self.mapping:
return obj
raise ValueError("%r should be one of %r" % (obj, list(self.mapping.keys())))