fix statemachine

- fix: calling state.start(<new state>) on restart must ensure
  that the function <new state> is called before state.start()
  returns.
- modify slighly behaviour of cleanup function

Change-Id: I483a3aefa6af4712b3cf13f62c86d4c06edd1d8d
Reviewed-on: https://forge.frm2.tum.de/review/c/sine2020/secop/playground/+/28020
Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de>
Reviewed-by: Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch>
This commit is contained in:
2022-03-28 15:14:31 +02:00
committed by l_samenv
parent 9152ea1d26
commit d5924567da
3 changed files with 123 additions and 83 deletions

View File

@ -51,6 +51,7 @@ cleanup=<cleanup function> as argument in StateMachine.__init__ or .start
defines a cleanup function to be called whenever the machine is stopped or 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 an error is raised in a state function. A cleanup function may return
either None for finishing or a further state function for continuing. 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 State Specific Cleanup Code
@ -74,18 +75,13 @@ may be disabled. :meth:`cycle` must be called periodically in this case.
import time import time
import threading import threading
import queue
from logging import getLogger from logging import getLogger
from secop.lib import mkthread from secop.lib import mkthread, UniqueObject
class Stop(Exception): Stop = UniqueObject('Stop')
"""exception indicating that StateMachine.stop was called""" Restart = UniqueObject('Restart')
class Restart(Stop):
"""exception indicating that StateMachine.start was called
while the state machine was active"""
class Retry: class Retry:
@ -96,13 +92,13 @@ class Retry:
class StateMachine: class StateMachine:
"""a simple, but powerful state machine""" """a simple, but powerful state machine"""
# class attributes are not allowed to be overriden by kwds of __init__ or :meth:`start` # class attributes are not allowed to be overriden by kwds of __init__ or :meth:`start`
last_error = None # last exception
stop_exc = None # after a stop or restart, Stop or Restart
start_time = None # the time of last start start_time = None # the time of last start
transition_time = None # the last time when the state changed transition_time = None # the last time when the state changed
state = None # the current state state = None # the current state
now = None now = None
init = True init = True
stopped = False
last_error = None # last exception raised or Stop or Restart
_last_time = 0 _last_time = 0
def __init__(self, state=None, logger=None, threaded=True, **kwds): def __init__(self, state=None, logger=None, threaded=True, **kwds):
@ -115,19 +111,31 @@ class StateMachine:
""" """
self.default_delay = 0.25 # default delay when returning None self.default_delay = 0.25 # default delay when returning None
self.now = time.time() # avoid calling time.time several times per state self.now = time.time() # avoid calling time.time several times per state
self.cleanup = lambda *args: None # default cleanup: finish on error self.cleanup = self.default_cleanup # default cleanup: finish on error
self.log = logger or getLogger('dummy') self.log = logger or getLogger('dummy')
self._update_attributes(kwds) self._update_attributes(kwds)
self._lock = threading.Lock() self._lock = threading.RLock()
self._stop_flag = False
self._trigger = threading.Event()
self._idle_event = threading.Event()
self._threaded = threaded self._threaded = threaded
if threaded:
self._thread_queue = queue.Queue()
self._idle_event = threading.Event()
self._thread = None self._thread = None
self._restart = None self._restart = None
if state: if state:
self.start(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
state.log.debug('%sed in state %r', repr(state.stopped).lower(), state.status_string)
else:
state.log.warning('%r raised in state %r', state.last_error, state.status_string)
def _update_attributes(self, kwds): def _update_attributes(self, kwds):
"""update allowed attributes""" """update allowed attributes"""
cls = type(self) cls = type(self)
@ -162,6 +170,7 @@ class StateMachine:
self.init = True self.init = True
self.now = time.time() self.now = time.time()
self.transition_time = self.now self.transition_time = self.now
self.log.debug('state: %s', self.status_string)
def cycle(self): def cycle(self):
"""do one cycle in the thread loop """do one cycle in the thread loop
@ -170,91 +179,115 @@ class StateMachine:
""" """
if self.state is None: if self.state is None:
return None return None
for _ in range(999): with self._lock:
self.now = time.time() for _ in range(999):
try: self.now = time.time()
ret = self.state(self) try:
self.init = False ret = self.state(self)
if self.stop_exc: self.init = False
raise self.stop_exc if self.stopped:
except Exception as e: self.last_error = self.stopped
# Stop and Restart are not unusual -> no warning self.cleanup(self)
log = self.log.debug if isinstance(e, Stop) else self.log.warning self.stopped = False
log('%r raised in state %r', e, self.status_string) ret = None
self.last_error = e except Exception as e:
ret = self.cleanup(self) self.last_error = e
self.log.debug('cleanup %r %r %r', self.cleanup, self.last_error, ret) ret = self.cleanup(self)
if ret is None: self.log.debug('called %r %sexc=%r', self.cleanup,
if self._restart: 'ret=%r ' % ret if ret else '', e)
self._start(**self._restart) if ret is None:
else: self.log.debug('state: None')
self.state = None self.state = None
self._idle_event.set() self._idle_event.set()
return None return None
if callable(ret): if callable(ret):
self._new_state(ret) self._new_state(ret)
continue
if isinstance(ret, Retry):
if ret.delay == 0:
continue continue
if ret.delay is None: if isinstance(ret, Retry):
return self.default_delay if ret.delay == 0:
return ret.delay continue
self.last_error = RuntimeError('return value must be callable, Retry(...) or finish') if ret.delay is None:
break return self.default_delay
else: return ret.delay
self.last_error = RuntimeError('too many states chained - probably infinite loop') self.last_error = RuntimeError('return value must be callable, Retry(...) or finish')
self.state = None break
return None else:
self.last_error = RuntimeError('too many states chained - probably infinite loop')
self.cleanup(self)
self.state = None
return None
def _run(self): def trigger(self, delay=0):
"""thread loop"""
while True:
delay = self.cycle()
self._trigger.wait(delay) # when delay is None, waiting infinitely (or until triggered)
self._trigger.clear()
def _start(self, state, **kwds):
if self._threaded: if self._threaded:
if self._thread is None or not self._thread.is_alive(): self._thread_queue.put(delay)
# restart thread if dead (may happen when cleanup failed)
self._thread = mkthread(self._run) def _run(self, delay):
"""thread loop
:param delay: delay before first state is called
"""
while True:
try:
ret = self._thread_queue.get(timeout=delay)
if ret is not None:
delay = ret
continue
except queue.Empty:
pass
delay = self.cycle()
def _start(self, state, first_delay, **kwds):
self._restart = None self._restart = None
self._idle_event.clear() self._idle_event.clear()
self._trigger.set() # wake up from idle state
self.last_error = None self.last_error = None
self.stop_exc = None self.stopped = False
self._update_attributes(kwds) self._update_attributes(kwds)
self._new_state(state) self._new_state(state)
self.start_time = self.now self.start_time = self.now
self._last_time = self.now self._last_time = self.now
if self._threaded:
if self._thread is None or not self._thread.is_alive():
# restart thread if dead (may happen when cleanup failed)
self._thread = mkthread(self._run, first_delay)
else:
self.trigger(first_delay)
def start(self, state, **kwds): def start(self, state, **kwds):
"""start with a new state """start with a new state
and interrupt the current state and interrupt the current state
the cleanup function will be called with last_error=Restart() the cleanup function will be called with state.stopped=Restart
:param state: the first state :param state: the first state
:param kwds: items to put as attributes on the state machine :param kwds: items to put as attributes on the state machine
""" """
self.log.debug('start %r', kwds) self.log.debug('start %r', kwds)
if self.state: if self.state:
self.stop_exc = Restart() self.stopped = Restart
self._trigger.set() with self._lock: # wait for running cycle finished
kwds['state'] = state if self.stopped: # cleanup is not yet done
self._restart = kwds self.last_error = self.stopped
return self.cleanup(self) # ignore return state on restart
self._start(state, **kwds) self.stopped = False
delay = self.cycle()
self._start(state, delay, **kwds)
else:
delay = self.cycle() # important: call once (e.g. set status to busy)
self._start(state, delay, **kwds)
def stop(self): def stop(self):
"""stop machine, go to idle state """stop machine, go to idle state
the cleanup function will be called with exc=Stop() the cleanup function will be called with state.stopped=Stop
""" """
self.log.debug('stop') self.log.debug('stop')
self.stop_exc = Stop() self.stopped = Stop
self._trigger.set() with self._lock:
if self.stopped: # cleanup is not yet done
self.last_error = self.stopped
self.cleanup(self) # ignore return state on restart
self.stopped = False
self.state = None
def wait(self, timeout=None): def wait(self, timeout=None):
"""wait for state machine being idle""" """wait for state machine being idle"""

View File

@ -20,8 +20,8 @@
# #
# ***************************************************************************** # *****************************************************************************
from secop.core import Parameter, FloatRange, BUSY, IDLE, WARN, ERROR from secop.core import Parameter, FloatRange, BUSY, IDLE, WARN
from secop.lib.statemachine import StateMachine, Retry from secop.lib.statemachine import StateMachine, Retry, Stop
class HasConvergence: class HasConvergence:
@ -51,17 +51,25 @@ class HasConvergence:
then the timeout event happens after this time + <settling_time> + <timeout>. then the timeout event happens after this time + <settling_time> + <timeout>.
''', FloatRange(0, unit='sec'), readonly=False, default=3600) ''', FloatRange(0, unit='sec'), readonly=False, default=3600)
status = Parameter('status determined from convergence checks', default=(IDLE, '')) status = Parameter('status determined from convergence checks', default=(IDLE, ''))
convergence_state = None
def earlyInit(self): def earlyInit(self):
super().earlyInit() super().earlyInit()
self.convergence_state = StateMachine(threaded=False, logger=self.log, spent_inside=0) self.convergence_state = StateMachine(threaded=False, logger=self.log,
cleanup=self.cleanup, spent_inside=0)
def cleanup(self, state):
state.default_cleanup(state)
if self.stopped:
if self.stopped is Stop: # and not Restart
self.status = WARN, 'stopped'
else:
self.status = WARN, repr(state.last_error)
def doPoll(self): def doPoll(self):
super().doPoll() super().doPoll()
state = self.convergence_state state = self.convergence_state
state.cycle() state.cycle()
if not state.is_active and state.last_error is not None:
self.status = ERROR, repr(state.last_error)
def get_min_slope(self, dif): def get_min_slope(self, dif):
slope = getattr(self, 'workingramp', 0) or getattr(self, 'ramp', 0) slope = getattr(self, 'workingramp', 0) or getattr(self, 'ramp', 0)
@ -80,7 +88,6 @@ class HasConvergence:
def start_state(self): def start_state(self):
"""to be called from write_target""" """to be called from write_target"""
self.convergence_state.start(self.state_approach) self.convergence_state.start(self.state_approach)
self.convergence_state.cycle()
def state_approach(self, state): def state_approach(self, state):
"""approaching, checking progress (busy)""" """approaching, checking progress (busy)"""
@ -105,8 +112,6 @@ class HasConvergence:
def state_inside(self, state): def state_inside(self, state):
"""inside tolerance, still busy""" """inside tolerance, still busy"""
if state.init:
self.status = BUSY, 'inside tolerance'
dif, tol = self.get_dif_tol() dif, tol = self.get_dif_tol()
if dif > tol: if dif > tol:
return self.state_outside return self.state_outside
@ -114,18 +119,20 @@ class HasConvergence:
if state.spent_inside > self.settling_time: if state.spent_inside > self.settling_time:
self.status = IDLE, 'reached target' self.status = IDLE, 'reached target'
return self.state_stable return self.state_stable
if state.init:
self.status = BUSY, 'inside tolerance'
return Retry() return Retry()
def state_outside(self, state): def state_outside(self, state):
"""temporarely outside tolerance, busy""" """temporarely outside tolerance, busy"""
if state.init:
self.status = BUSY, 'outside tolerance'
dif, tol = self.get_dif_tol() dif, tol = self.get_dif_tol()
if dif < tol: if dif < tol:
return self.state_inside return self.state_inside
if state.now > state.timeout_base + self.settling_time + self.timeout: if state.now > state.timeout_base + self.settling_time + self.timeout:
self.status = WARN, 'settling timeout' self.status = WARN, 'settling timeout'
return self.state_instable return self.state_instable
if state.init:
self.status = BUSY, 'outside tolerance'
# do not reset the settling time on occasional outliers, count backwards instead # do not reset the settling time on occasional outliers, count backwards instead
state.spent_inside = max(0.0, state.spent_inside - state.delta()) state.spent_inside = max(0.0, state.spent_inside - state.delta())
return Retry() return Retry()

View File

@ -112,7 +112,7 @@ def test_stop():
s.cycle() s.cycle()
s.stop() s.stop()
s.cycle() s.cycle()
assert isinstance(s.last_error, Stop) assert s.last_error is Stop
assert s.state is None assert s.state is None