diff --git a/.gitignore b/.gitignore index 73bd1e5..2d77925 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ +log/* html/* *.pyc +pid/* + diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..6d2d51a --- /dev/null +++ b/.pylintrc @@ -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*(# )??$ + +# 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 diff --git a/bin/server.py b/bin/server.py index cb3da99..996acc8 100755 --- a/bin/server.py +++ b/bin/server.py @@ -25,8 +25,83 @@ import os import sys 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 "================================" diff --git a/doc/index.md b/doc/index.md index fe5ac1b..aeed6f6 100644 --- a/doc/index.md +++ b/doc/index.md @@ -1,6 +1,8 @@ Markdown docu to be generated ============================= +[Notes](notes.html) + TODO's ====== diff --git a/doc/notes.md b/doc/notes.md new file mode 100644 index 0000000..384de7d --- /dev/null +++ b/doc/notes.md @@ -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! # + diff --git a/doc/todo.md b/doc/todo.md index 6e2ab5e..e1d06ee 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -6,6 +6,7 @@ * src/server for everything server related * src/client for everything client related (ProxyDevice!) * src/protocol for protocol specific things + * need subtree for different implementations to play with * src/lib for helpers and other stuff * possibly a parallel src tree for cpp version @@ -20,16 +21,31 @@ ## A Server ## - * evaluate config.ini - * handle cmdline args (specify different server.ini) + * get daemonizing working + * handle -d (nodaemon) and -D (default, daemonize) cmd line args * support Async data units * 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 ## * 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 + diff --git a/etc/config.ini b/etc/test.cfg similarity index 67% rename from etc/config.ini rename to etc/test.cfg index 921213b..b3f307b 100644 --- a/etc/config.ini +++ b/etc/test.cfg @@ -3,14 +3,14 @@ bindto=localhost bindport=10767 protocol=pickle -[device "LN2"] +[device LN2] class=devices.test.LN2 -[device "heater"] +[device heater] class=devices.test.Heater maxheaterpower=10 -[device "T1"] -class=devices.test.temp +[device T1] +class=devices.test.Temp sensor="X34598T7" diff --git a/requirements.txt b/requirements.txt index 2f3f949..ad46ddb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,9 @@ # for generating docu markdown>=2.6 +# general stuff +psutil +# daemonizing not yet functional (logging problems) +#daemonize # for zmq #pyzmq>=13.1.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/client/__init__.py b/src/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/devices/__init__.py b/src/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/devices/core.py b/src/devices/core.py new file mode 100644 index 0000000..e081e87 --- /dev/null +++ b/src/devices/core.py @@ -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 +# +# ***************************************************************************** + +"""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 + diff --git a/src/devices/cryo.py b/src/devices/cryo.py new file mode 100644 index 0000000..9bd96d4 --- /dev/null +++ b/src/devices/cryo.py @@ -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 +# ***************************************************************************** + +"""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() + diff --git a/src/devices/test.py b/src/devices/test.py new file mode 100644 index 0000000..d4339af --- /dev/null +++ b/src/devices/test.py @@ -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 +# ***************************************************************************** + +"""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 + + diff --git a/src/lib.py b/src/errors.py similarity index 74% rename from src/lib.py rename to src/errors.py index 3c3ecf3..5360681 100644 --- a/src/lib.py +++ b/src/errors.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- # ***************************************************************************** # @@ -19,20 +20,13 @@ # Enrico Faulhaber # # ***************************************************************************** +"""error class for our little framework""" -"""Define helpers""" +class SECoPServerError(Exception): + pass -class attrdict(dict): - def __getattr__(self, key): - 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 ConfigError(SECoPServerError): + pass +class ProgrammingError(SECoPServerError): + pass diff --git a/src/lib/__init__.py b/src/lib/__init__.py new file mode 100644 index 0000000..07bc3a5 --- /dev/null +++ b/src/lib/__init__.py @@ -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 +# +# ***************************************************************************** + +"""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 + diff --git a/src/lib/pidfile.py b/src/lib/pidfile.py new file mode 100644 index 0000000..4aac509 --- /dev/null +++ b/src/lib/pidfile.py @@ -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 +# +# ***************************************************************************** + +"""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) + diff --git a/src/lib/startup.py b/src/lib/startup.py new file mode 100644 index 0000000..4708919 --- /dev/null +++ b/src/lib/startup.py @@ -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 +# +# ***************************************************************************** + +"""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() diff --git a/src/protocol/__init__.py b/src/protocol/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/device.py b/src/protocol/device.py similarity index 92% rename from src/device.py rename to src/protocol/device.py index 5eeef94..f324ed7 100644 --- a/src/device.py +++ b/src/protocol/device.py @@ -25,19 +25,7 @@ also define helpers to derive properties of the device""" from lib import attrdict - -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() +from protocol import status # XXX: deriving PARS/CMDS should be done in a suitable metaclass.... diff --git a/src/messages.py b/src/protocol/messages.py similarity index 100% rename from src/messages.py rename to src/protocol/messages.py diff --git a/src/protocol/status.py b/src/protocol/status.py new file mode 100644 index 0000000..9bca559 --- /dev/null +++ b/src/protocol/status.py @@ -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 +# +# ***************************************************************************** +"""Define Status constants""" + +# could also be some objects +OK = 100 +BUSY = 200 +WARN = 300 +UNSTABLE = 350 +ERROR = 400 +UNKNOWN = -1 + diff --git a/src/transport.py b/src/protocol/transport.py similarity index 89% rename from src/transport.py rename to src/protocol/transport.py index 772dc79..f9be5c9 100644 --- a/src/transport.py +++ b/src/protocol/transport.py @@ -136,9 +136,14 @@ class SECoPRequestHandler(SocketServer.BaseRequestHandler): class SECoPServer(SocketServer.ThreadingTCPServer, DeviceServer): daemon_threads = False - -def startup_server(): - srv = SECoPServer(('localhost', DEF_PORT), SECoPRequestHandler, - bind_and_activate=True) - srv.serve_forever() - srv.server_close() + def __init__(self, logger, serveropts): + bindto = serveropts.pop('bindto', 'localhost') + portnum = DEF_PORT + if ':' in bindto: + bindto, _port = bindto.rsplit(':') + 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) diff --git a/src/server.py b/src/server.py index 9fc4419..c89cce5 100644 --- a/src/server.py +++ b/src/server.py @@ -22,23 +22,25 @@ """Define basic SECoP DeviceServer""" -import logging import time -from messages import parse, ListDevicesRequest, ListDeviceParamsRequest, \ +from protocol.messages import parse, ListDevicesRequest, ListDeviceParamsRequest, \ ReadParamRequest, ErrorReply, MessageHandler class DeviceServer(MessageHandler): - def __init__(self): + def __init__(self, logger, serveropts): 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, - format='%(asctime)s %(levelname)s %(message)s') + def serve_forever(self): + 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. # all exportet properties are taken from the device if devicename in self._devices: @@ -47,7 +49,7 @@ class DeviceServer(MessageHandler): self._devices[devicename] = deviceobj 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: self.log.error('IGN: Device %r not registered!' % device_obj_or_name) @@ -55,12 +57,12 @@ class DeviceServer(MessageHandler): del self._devices[device_obj_or_name] # may need to do more - def getDevice(self, devname): + def get_device(self, devname): """returns the requested deviceObj or None""" devobj = self._devices.get(devname, None) return devobj - def listDevices(self): + def list_devices(self): return list(self._devices.keys()) def handle(self, msg): @@ -85,7 +87,8 @@ class DeviceServer(MessageHandler): if __name__ == '__main__': - from device import Driveable, status + from devices.core import Driveable + from protocol import status class TestDevice(Driveable): name = 'Unset' unit = 'Oinks' @@ -119,8 +122,8 @@ if __name__ == '__main__': print "minimal testing: server" srv = DeviceServer() - srv.registerDevice(TestDevice(), 'dev1') - srv.registerDevice(TestDevice(), 'dev2') + srv.register_device(TestDevice(), 'dev1') + srv.register_device(TestDevice(), 'dev2') devices = parse(srv.handle(ListDevicesRequest()))[2]['list_of_devices'] print 'Srv exports these devices:', devices for dev in sorted(devices): diff --git a/src/validators.py b/src/validators.py new file mode 100644 index 0000000..7aed820 --- /dev/null +++ b/src/validators.py @@ -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 +# +# ***************************************************************************** +"""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()))) +