state on dilsc as of 2022-10-03

vector field, but no new state machine yet
This commit is contained in:
2022-11-21 14:51:02 +01:00
parent 485e81bfb0
commit 9636dc9cea
13 changed files with 1086 additions and 310 deletions

View File

@@ -39,7 +39,7 @@ class HasOffset(Feature):
implementation to be done in the subclass
"""
offset = PersistentParam('offset (physical value + offset = HW value)',
FloatRange(unit='deg'), readonly=False, default=0)
FloatRange(unit='$'), readonly=False, default=0)
def write_offset(self, value):
self.offset = value
@@ -62,9 +62,9 @@ class HasLimits(Feature):
except for the offset
"""
abslimits = Property('abs limits (raw values)', default=(-9e99, 9e99), extname='abslimits', export=True,
datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg')))
datatype=TupleOf(FloatRange(unit='$'), FloatRange(unit='$')))
limits = PersistentParam('user limits', readonly=False, default=(-9e99, 9e99),
datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg')))
datatype=TupleOf(FloatRange(unit='$'), FloatRange(unit='$')))
_limits = None
def apply_offset(self, sign, *values):

View File

@@ -30,7 +30,7 @@ The created state object may hold variables needed for the state.
A state function may return either:
- a function for the next state to transition to
- Retry(<delay>) to keep the state and call the
- or `None` for finishing
- or `Finish` for finishing
Initialisation Code
@@ -44,33 +44,23 @@ def state_x(stateobj):
... further code ...
Cleanup Function
----------------
Error Handler
-------------
cleanup=<cleanup function> as argument in StateMachine.__init__ or .start
defines a cleanup function to be called whenever the machine is stopped or
an error is raised in a state function. A cleanup function may return
either None for finishing or a further state function for continuing.
In case of stop or restart, this return value is ignored.
State Specific Cleanup Code
---------------------------
To execute state specific cleanup, the cleanup may examine the current state
(stateobj.state) in order to decide what to be done.
If a need arises, a future extension to this library may support specific
cleanup functions by means of a decorator adding the specific cleanup function
as an attribute to the state function.
handler=<handler object> as argument in StateMachine.__init__ or .start
defines a handler object to be called whenever the machine is stopped,
restarted. finished or an error is raised in a state function.
<handler>.on_error may return either None for finishing or a further state
function for continuing. The other handler methods always return None,
as there is no useful follow up state.
Threaded Use
------------
On start, a thread is started, which is waiting for a trigger event when the
machine is not active. For test purposes or special needs, the thread creation
may be disabled. :meth:`cycle` must be called periodically in this case.
machine is not active. In case the thread creation is disabled. :meth:`cycle`
must be called periodically for running the state machine.
"""
import time
@@ -80,6 +70,7 @@ from logging import getLogger
from secop.lib import mkthread, UniqueObject
Finish = UniqueObject('Finish')
Stop = UniqueObject('Stop')
Restart = UniqueObject('Restart')
@@ -89,6 +80,52 @@ class Retry:
self.delay = delay
class StateHandler:
"""default handlers
may be used as base class or mixin for implementing custom handlers
"""
def on_error(self, statemachine, exc):
"""called on error
:param statemachine: the state machine object
:param exc: the exception
:return: None or a state function to be executed for handling the error state
"""
statemachine.log.warning('%r raised in state %r', exc, statemachine.status_string)
def on_transition(self, statemachine, newstate):
"""called when state is changed
:param statemachine: the statemachine
:param newstate: the new state function
this method will not be called when the state is changed to None,
e.g. on finish, restart, stop or when None is returned from the error handler
"""
def on_restart(self, statemachine):
"""called on restart
:param statemachine: the state machine object
"""
def on_stop(self, statemachine):
"""called when stopped
:param statemachine: the state machine object
"""
def on_finish(self, statemachine):
"""called on finish
:param statemachine: the state machine object
"""
default_handler = StateHandler()
class StateMachine:
"""a simple, but powerful state machine"""
# class attributes are not allowed to be overriden by kwds of __init__ or :meth:`start`
@@ -98,7 +135,7 @@ class StateMachine:
now = None
init = True
stopped = False
last_error = None # last exception raised or Stop or Restart
restarted = False
_last_time = 0
def __init__(self, state=None, logger=None, threaded=True, **kwds):
@@ -109,9 +146,9 @@ class StateMachine:
:param threaded: whether a thread should be started (default: True)
:param kwds: any attributes for the state object
"""
self.default_delay = 0.25 # default delay when returning None
self.default_delay = 0.25 # default delay when returning Retry(None)
self.now = time.time() # avoid calling time.time several times per state
self.cleanup = self.default_cleanup # default cleanup: finish on error
self.handler = default_handler
self.log = logger or getLogger('dummy')
self._update_attributes(kwds)
self._lock = threading.RLock()
@@ -120,23 +157,9 @@ class StateMachine:
self._thread_queue = queue.Queue()
self._idle_event = threading.Event()
self._thread = None
self._restart = None
if state:
self.start(state)
@staticmethod
def default_cleanup(state):
"""default cleanup
:param self: the state object
:return: None (for custom cleanup functions this might be a new state)
"""
if state.stopped: # stop or restart
verb = 'stopped' if state.stopped is Stop else 'restarted'
state.log.debug('%s in state %r', verb, state.status_string)
else:
state.log.warning('%r raised in state %r', state.last_error, state.status_string)
def _update_attributes(self, kwds):
"""update allowed attributes"""
cls = type(self)
@@ -168,10 +191,12 @@ class StateMachine:
def _new_state(self, state):
self.state = state
self.init = True
self.now = time.time()
self.transition_time = self.now
self.log.debug('state: %s', self.status_string)
if state:
self.handler.on_transition(self, state)
self.init = True
self.now = time.time()
self.transition_time = self.now
def cycle(self):
"""do one cycle in the thread loop
@@ -187,18 +212,26 @@ class StateMachine:
ret = self.state(self)
self.init = False
if self.stopped:
self.last_error = self.stopped
self.cleanup(self)
self.log.debug('stopped in state %r', self.status_string)
self.handler.on_stop(self)
self.stopped = False
ret = None
elif self.restarted:
self.log.debug('restarted in state %r', self.status_string)
self.handler.on_restart(self)
self.restarted = False
ret = None
except Exception as e:
self.last_error = e
ret = self.cleanup(self)
self.log.debug('called %r %sexc=%r', self.cleanup,
'ret=%r ' % ret if ret else '', e)
if ret is None:
self.log.debug('state: None after cleanup')
self.state = None
try:
ret = self.handler.on_error(self, e)
self.log.debug('called on_error with exc=%r%s', e,
' ret=%r' % ret if ret else '')
except Exception as ee:
self.log.exception('%r raised in on_error(state, %r)', ee, e)
if ret is Finish:
self.log.debug('finish in state %r', self.status_string)
self.handler.on_finish(self)
self._new_state(None)
self._idle_event.set()
return None
if callable(ret):
@@ -210,12 +243,13 @@ class StateMachine:
if ret.delay is None:
return self.default_delay
return ret.delay
self.last_error = RuntimeError('return value must be callable, Retry(...) or finish')
self.handler.on_error(self, RuntimeError(
'return value must be callable, Retry(...) or Finish, not %r' % ret))
break
else:
self.last_error = RuntimeError('too many states chained - probably infinite loop')
self.cleanup(self)
self.state = None
self.handler.on_error(self, RuntimeError(
'too many states chained - probably infinite loop'))
self._new_state(None)
return None
def trigger(self, delay=0):
@@ -238,9 +272,8 @@ class StateMachine:
delay = self.cycle()
def _start(self, state, **kwds):
self._restart = None
self._idle_event.clear()
self.last_error = None
self.restarted = False
self.stopped = False
self._update_attributes(kwds)
self._new_state(state)
@@ -259,19 +292,18 @@ class StateMachine:
"""start with a new state
and interrupt the current state
the cleanup function will be called with state.stopped=Restart
the cleanup function will be called with state.restarted = True
:param state: the first state
:param kwds: items to put as attributes on the state machine
"""
self.log.debug('start %r', kwds)
if self.state:
self.stopped = Restart
self.restarted = True
with self._lock: # wait for running cycle finished
if self.stopped: # cleanup is not yet done
self.last_error = self.stopped
self.cleanup(self) # ignore return state on restart
self.stopped = False
if self.restarted: # on_restart is not yet called
self.handler.on_restart(self)
self.restarted = False
self._start(state, **kwds)
else:
self._start(state, **kwds)
@@ -279,16 +311,15 @@ class StateMachine:
def stop(self):
"""stop machine, go to idle state
the cleanup function will be called with state.stopped=Stop
the cleanup function will be called with state.stopped = True
"""
self.log.debug('stop')
self.stopped = Stop
self.stopped = True
with self._lock:
if self.stopped: # cleanup is not yet done
self.last_error = self.stopped
self.cleanup(self) # ignore return state on restart
if self.stopped: # on_stop is not yet called
self.handler.on_stop(self)
self.stopped = False
self.state = None
self._new_state(None)
def wait(self, timeout=None):
"""wait for state machine being idle"""

110
secop/states.py Normal file
View File

@@ -0,0 +1,110 @@
#!/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:
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""mixin for modules with a statemachine"""
from secop.lib.statemachine import StateMachine, Finish, Retry, StateHandler
from secop.core import BUSY, IDLE, ERROR, Parameter
from secop.errors import ProgrammingError
class status_code:
"""decorator for state methods"""
def __init__(self, code, text=None):
self.code = code
self.text = text
def __set_name__(self, owner, name):
if not issubclass(owner, HasStates):
raise ProgrammingError('when using decorator "status_code", %s must inherit HasStates' % owner.__name__)
self.cls = owner
self.name = name
if 'statusMap' not in owner.__dict__:
# we need a copy on each inheritance level
owner.statusMap = dict(owner.statusMap)
owner.statusMap[name] = self.code, name.replace('_', ' ') if self.text is None else self.text
setattr(owner, name, self.func)
def __call__(self, func):
self.func = func
return self
class HasStates(StateHandler):
status = Parameter() # make sure this is a parameter
skip_consecutive_status_changes = False
_state_machine = None
_next_cycle = 0
statusMap = {}
def init_state_machine(self, fullstatus=True, **kwds):
self._state_machine = StateMachine(
logger=self.log,
threaded=False,
handler=self,
default_delay=1e-3, # small but not zero (avoid infinite loop)
**kwds)
def initModule(self):
super().initModule()
self.init_state_machine()
def on_error(self, statemachine, exc):
"""called on error"""
error = '%r in %s' % (exc, statemachine.status_string)
self.log.error('%s', error)
return self.final_status(ERROR, error)
def on_transition(self, statemachine, newstate):
if not self.skip_consecutive_status_changes:
self.set_status_from_state(newstate)
def set_status_from_state(self, newstate):
name = newstate.__name__
status = self.statusMap.get(name)
if status is None:
status = BUSY, name.replace('_', ' ')
if status != self.status:
self.status = status
def doPoll(self):
super().doPoll()
now = self.pollInfo.last_main
if now > self._next_cycle:
delay = self._state_machine.cycle()
if delay is None:
self._next_cycle = 0
else:
if self.skip_consecutive_status_changes:
self.set_status_from_state(self._state_machine.state)
self._next_cycle = now + delay
def start_state(self, start_state, fast_poll=True, **kwds):
self._state_machine.start(start_state, **kwds)
if fast_poll is not None:
self.setFastPoll(fast_poll)
def final_status(self, code=IDLE, text='', fast_poll=False):
self.status = code, text
if fast_poll is not None:
self.setFastPoll(fast_poll)
return Finish