From e4c1f35e4a8589e8cb62b3aee71839c29692e3ec Mon Sep 17 00:00:00 2001 From: l_samenv Date: Fri, 2 Dec 2022 08:54:26 +0100 Subject: [PATCH 1/5] add attocube --- secop_psi/attocube.py | 243 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 secop_psi/attocube.py diff --git a/secop_psi/attocube.py b/secop_psi/attocube.py new file mode 100644 index 0000000..2959827 --- /dev/null +++ b/secop_psi/attocube.py @@ -0,0 +1,243 @@ +# ***************************************************************************** +# 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 +# ***************************************************************************** + +import sys +import time +from secop.core import Drivable, Parameter, Command, Property, ERROR, BUSY, IDLE, Done, nopoll +from secop.features import HasLimits +from secop.datatypes import IntRange, FloatRange, StringType, BoolType +from secop.errors import ConfigError, BadValueError +sys.path.append('/home/l_samenv/Documents/anc350/Linux64/userlib/lib') +from PyANC350v4 import Positioner + + +DIRECTION_NAME = {1: 'forward', -1: 'backward'} + + +class FreezeStatus: + """freeze status for some time + + hardware quite often does not treat status correctly: on a target change it + may take some time to return the 'busy' status correctly. + + in classes with this mixin, within :meth:`write_target` call + + self.freeze_status(0.5, BUSY, 'changed target') + + a wrapper around read_status will take care that the status will be the given value, + for at least the given delay. This does NOT cover the case when self.status is set + directly from an other method. + """ + __freeze_status_until = 0 + + def __init_subclass__(cls): + def wrapped(self, inner=cls.read_status): + if time.time() < self.__freeze_status_until: + return Done + return inner(self) + + cls.read_status = wrapped + super().__init_subclass__() + + def freeze_status(self, delay, code=BUSY, text='changed target'): + """freezze status to the given value for the given delay""" + self.__freeze_status_until = time.time() + delay + self.status = code, text + + +class Axis(HasLimits, FreezeStatus, Drivable): + axis = Property('axis number', IntRange(0, 2), 0) + value = Parameter('axis position', FloatRange(unit='deg')) + frequency = Parameter('frequency', FloatRange(1, unit='Hz'), readonly=False) + amplitude = Parameter('amplitude', FloatRange(0, unit='V'), readonly=False) + gear = Parameter('gear factor', FloatRange(), readonly=False, default=1, initwrite=True) + tolerance = Parameter('positioning tolerance', FloatRange(0, unit='$'), readonly=False, default=0.01) + output = Parameter('enable output', BoolType(), readonly=False) + info = Parameter('axis info', StringType()) + statusbits = Parameter('status bits', StringType()) + + _hw = Positioner() + _scale = 1 # scale for custom units + _move_steps = 0 # number of steps to move (used by move command) + SCALES = {'deg': 1, 'm': 1, 'mm': 1000, 'um': 1000000, 'µm': 1000000} + _direction = 1 # move direction + _idle_status = IDLE, '' + _error_state = '' # empty string: no error + _history = None + _check_sensor = False + _try_count = 0 + + def __init__(self, name, logger, opts, srv): + unit = opts.pop('unit', 'deg') + opts['value.unit'] = unit + try: + self._scale = self.SCALES[unit] * opts.get('gear', 1) + except KeyError as e: + print(repr(e)) + raise ConfigError('unsupported unit: %s' % unit) + super().__init__(name, logger, opts, srv) + + def write_gear(self, value): + self._scale = self.SCALES[self.parameters['value'].datatype.unit] * self.gear + return value + + def startModule(self, start_events): + super().startModule(start_events) + start_events.queue(self.read_info) + + def check_value(self, value): + """check if value allows moving in current driection""" + if self._direction > 0: + if value > self.target_limits[1]: + raise BadValueError('above upper limit') + elif value < self.target_limits[0]: + raise BadValueError('below lower limit') + + def read_value(self): + pos = self._hw.getPosition(self.axis) * self._scale + if self.isBusy(): + try: + self.check_value(pos) + except BadValueError as e: + self._stop() + self._idle_status = ERROR, str(e) + return pos + + def read_frequency(self): + return self._hw.getFrequency(self.axis) + + def write_frequency(self, value): + self._hw.setFrequency(self.axis, value) + return self._hw.getFrequency(self.axis) + + def read_amplitude(self): + return self._hw.getAmplitude(self.axis) + + def write_amplitude(self, value): + self._hw.setAmplitude(self.axis, value) + return self._hw.getAmplitude(self.axis) + + def write_tolerance(self, value): + self._hw.setTargetRange(self.axis, value / self._scale) + return value + + def write_output(self, value): + self._hw.setAxisOutput(self.axis, enable=value, autoDisable=0) + + def read_status(self): + statusbits = self._hw.getAxisStatus(self.axis) + sensor, self.output, moving, attarget, eot_fwd, eot_bwd, sensor_error = statusbits + self.statusbits = ''.join((k for k, v in zip('SOMTFBE', statusbits) if v)) + if self._move_steps: + if not (eot_fwd or eot_bwd): + return BUSY, 'moving by steps' + if not sensor: + self._error_state = 'no sensor connected' + elif sensor_error: + self._error_state = 'sensor error' + elif eot_fwd: + self._error_state = 'end of travel forward' + elif eot_bwd: + self._error_state = 'end of travel backward' + else: + if self._error_state and not DIRECTION_NAME[self._direction] in self._error_state: + self._error_state = '' + status_text = 'moving' if self._try_count == 0 else 'moving (retry %d)' % self._try_count + if moving and self._history is not None: # history None: moving by steps + self._history.append(self.value) + if len(self._history) < 5: + return BUSY, status_text + beg = self._history.pop(0) + if abs(beg - self.target) < self.tolerance: + # reset normal tolerance + self._hw.setTargetRange(self.axis, self.tolerance / self._scale) + if (self.value - beg) * self._direction > 0: + return BUSY, status_text + self._idle_status = ERROR, 'no progress' + if self._error_state: + self._try_count += 1 + if self._try_count < 10 and self._history is not None: + self.write_target(self.target) + return Done + self._idle_status = ERROR, self._error_state + if self.status[0] != IDLE: + self._stop() + return self._idle_status + + def write_target(self, value): + if value == self.read_value(): + return value + self.check_limits(value) + self._try_count = 0 + self._direction = 1 if value > self.value else -1 + # if self._error_state and DIRECTION_NAME[-self._direction] not in self._error_state: + # raise BadValueError('can not move (%s)' % self._error_state) + self._move_steps = 0 + self.write_output(1) + # try first with 50 % of tolerance + self._hw.setTargetRange(self.axis, self.tolerance * 0.5 / self._scale) + self._hw.setTargetPosition(self.axis, value / self._scale) + self._hw.startAutoMove(self.axis, enable=1, relative=0) + self._history = [self.value] + self._idle_status = IDLE, '' + self.freeze_status(1, BUSY, 'changed target') + self.setFastPoll(True, 1) + return value + + def doPoll(self): + if self._move_steps == 0: + super().doPoll() + return + self._hw.startSingleStep(self.axis, self._direction < 0) + self._move_steps -= self._direction + if self._move_steps % int(self.frequency) == 0: # poll value and status every second + super().doPoll() + + @nopoll + def read_info(self): + """read info from controller""" + cap = self._hw.measureCapacitance(self.axis) * 1e9 + axistype = ['linear', 'gonio', 'rotator'][self._hw.getActuatorType(self.axis)] + return '%s %s %.3gnF' % (self._hw.getActuatorName(self.axis), axistype, cap) + + def _stop(self): + self._move_steps = 0 + self._history = None + self._hw.startAutoMove(self.axis, enable=0, relative=0) + self._hw.setTargetRange(self.axis, self.tolerance / self._scale) + self.setFastPoll(False) + + @Command() + def stop(self): + self._idle_status = IDLE, 'stopped' if self.isBusy() else '' + self._stop() + self.status = self._idle_status + + @Command(IntRange()) + def move(self, value): + """relative move by number of steps""" + self._direction = 1 if value > 0 else -1 + self.check_value(self.value) + self._history = None + if DIRECTION_NAME[self._direction] in self._error_state: + raise BadValueError('can not move (%s)' % self._error_state) + self._move_steps = value + self._idle_status = IDLE, '' + self.read_status() + self.setFastPoll(True, 1/self.frequency) From d71a512454e889bb4941462e0fb1c2ce59346894 Mon Sep 17 00:00:00 2001 From: l_samenv Date: Fri, 2 Dec 2022 09:07:20 +0100 Subject: [PATCH 2/5] new features HasTargetLimits and HasSimpleOffset according to standard --- secop/features.py | 168 ++++++++-------------------------------------- 1 file changed, 29 insertions(+), 139 deletions(-) diff --git a/secop/features.py b/secop/features.py index ef27224..fd21288 100644 --- a/secop/features.py +++ b/secop/features.py @@ -31,7 +31,32 @@ from secop.errors import BadValueError, ConfigError from secop.lib import clamp -# --- proposals, to be used at SINQ (not agreed as standard yet) --- +class HasSimpleOffset(Feature): + """has a client side offset parameter + + this is just a storage! + """ + offset = PersistentParam('offset (physical value + offset = HW value)', + FloatRange(unit='deg'), readonly=False, default=0) + + +class HasTargetLimits(Feature): + """user limits + + implementation to be done in the subclass + according to standard + """ + target_limits = PersistentParam('user limits', readonly=False, default=(-9e99, 9e99), + datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg'))) + + def check_limits(self, value): + """check if value is valid""" + min_, max_ = self.target_limits + if not min_ <= value <= max_: + raise BadValueError('limits violation: %g outside [%g, %g]' % (value, min_, max_)) + + +# --- legacy mixins, not agreed as standard --- class HasOffset(Feature): """has an offset parameter @@ -39,7 +64,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 +87,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): @@ -110,138 +135,3 @@ class HasLimits(Feature): if not min_ <= value <= max_: raise BadValueError('limits violation: %g outside [%g, %g]' % (value, min_, max_)) - -# --- not used, not tested yet --- - -class HAS_PID(Feature): - # note: implementors should either use p,i,d or pid, but ECS must be handle both cases - # note: if both p,i,d and pid are implemented, it MUST NOT matter which one gets a change, the final result should be the same - # note: if there are additional custom accessibles with the same name as an element of the struct, the above applies - # note: (i would still but them in the same group, though) - # note: if extra elements are implemented in the pid struct they MUST BE - # properly described in the description of the pid Parameter - - # parameters - use_pid = Parameter('use the pid mode', datatype=EnumType(openloop=0, pid_control=1), ) - # pylint: disable=invalid-name - p = Parameter('proportional part of the regulation', datatype=FloatRange(0), ) - i = Parameter('(optional) integral part', datatype=FloatRange(0), optional=True) - d = Parameter('(optional) derivative part', datatype=FloatRange(0), optional=True) - base_output = Parameter('(optional) minimum output value', datatype=FloatRange(0), optional=True) - pid = Parameter('(optional) Struct of p,i,d, minimum output value', - datatype=StructOf(p=FloatRange(0), - i=FloatRange(0), - d=FloatRange(0), - base_output=FloatRange(0), - ), optional=True, - ) # note: struct may be extended with custom elements (names should be prefixed with '_') - output = Parameter('(optional) output of pid-control', datatype=FloatRange(0), optional=True, readonly=False) - - -class Has_PIDTable(HAS_PID): - - # parameters - use_pidtable = Parameter('use the zoning mode', datatype=EnumType(fixed_pid=0, zone_mode=1)) - pidtable = Parameter('Table of pid-values vs. target temperature', datatype=ArrayOf(TupleOf(FloatRange(0), - StructOf(p=FloatRange(0), - i=FloatRange(0), - d=FloatRange(0), - _heater_range=FloatRange(0), - _base_output=FloatRange(0),),),), optional=True) # struct may include 'heaterrange' - - -class HAS_Persistent(Feature): - #extra_Status { - # 'decoupled' : Status.IDLE+1, # to be discussed. - # 'coupling' : Status.BUSY+1, # to be discussed. - # 'coupled' : Status.BUSY+2, # to be discussed. - # 'decoupling' : Status.BUSY+3, # to be discussed. - #} - - # parameters - persistent_mode = Parameter('Use persistent mode', - datatype=EnumType(off=0,on=1), - default=0, readonly=False) - is_persistent = Parameter('current state of persistence', - datatype=BoolType(), optional=True) - # stored_value = Parameter('current persistence value, often used as the modules value', - # datatype='main', unit='$', optional=True) - # driven_value = Parameter('driven value (outside value, syncs with stored_value if non-persistent)', - # datatype='main', unit='$' ) - - -class HAS_Tolerance(Feature): - # detects IDLE status by checking if the value lies in a given window: - # tolerance is the maximum allowed deviation from target, value must lie in this interval - # for at least ´timewindow´ seconds. - - # parameters - tolerance = Parameter('Half height of the Window', - datatype=FloatRange(0), default=1, unit='$') - timewindow = Parameter('Length of the timewindow to check', - datatype=FloatRange(0), default=30, unit='s', - optional=True) - - -class HAS_Timeout(Feature): - - # parameters - timeout = Parameter('timeout for movement', - datatype=FloatRange(0), default=0, unit='s') - - -class HAS_Pause(Feature): - # just a proposal, can't agree on it.... - - @Command(argument=None, result=None) - def pause(self): - """pauses movement""" - - @Command(argument=None, result=None) - def go(self): - """continues movement or start a new one if target was change since the last pause""" - - -class HAS_Ramp(Feature): - - # parameters - ramp =Parameter('speed of movement', unit='$/min', - datatype=FloatRange(0)) - use_ramp = Parameter('use the ramping of the setpoint, or jump', - datatype=EnumType(disable_ramp=0, use_ramp=1), - optional=True) - setpoint = Parameter('currently active setpoint', - datatype=FloatRange(0), unit='$', - readonly=True, ) - - -class HAS_Speed(Feature): - - # parameters - speed = Parameter('(maximum) speed of movement (of the main value)', - unit='$/s', datatype=FloatRange(0)) - - -class HAS_Accel(HAS_Speed): - - # parameters - accel = Parameter('acceleration of movement', unit='$/s^2', - datatype=FloatRange(0)) - decel = Parameter('deceleration of movement', unit='$/s^2', - datatype=FloatRange(0), optional=True) - - -class HAS_MotorCurrents(Feature): - - # parameters - movecurrent = Parameter('Current while moving', - datatype=FloatRange(0)) - idlecurrent = Parameter('Current while idle', - datatype=FloatRange(0), optional=True) - - -class HAS_Curve(Feature): - # proposed, not yet agreed upon! - - # parameters - curve = Parameter('Calibration curve', datatype=StringType(), default='') From 3483c3374d9670d8f66e228e98ab886dc0b6ba61 Mon Sep 17 00:00:00 2001 From: l_samenv Date: Fri, 2 Dec 2022 09:10:07 +0100 Subject: [PATCH 3/5] allow pollinterval = 0 --- secop/modules.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/secop/modules.py b/secop/modules.py index 78f5b13..5d52955 100644 --- a/secop/modules.py +++ b/secop/modules.py @@ -722,7 +722,10 @@ class Module(HasAccessibles): for mobj in modules: pinfo = mobj.pollInfo if now > pinfo.last_main + pinfo.interval: - pinfo.last_main = (now // pinfo.interval) * pinfo.interval + if pinfo.interval: + pinfo.last_main = (now // pinfo.interval) * pinfo.interval + else: + pinfo.last_main = now mobj.callPollFunc(mobj.doPoll) now = time.time() # find ONE due slow poll and call it From e668b6a439725fd809e3c8f579c3382e3c1932e6 Mon Sep 17 00:00:00 2001 From: l_samenv Date: Fri, 2 Dec 2022 09:12:51 +0100 Subject: [PATCH 4/5] attocube: add offset and change to HasTargetLimits --- secop_psi/attocube.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/secop_psi/attocube.py b/secop_psi/attocube.py index 2959827..ee6a9a5 100644 --- a/secop_psi/attocube.py +++ b/secop_psi/attocube.py @@ -20,7 +20,7 @@ import sys import time from secop.core import Drivable, Parameter, Command, Property, ERROR, BUSY, IDLE, Done, nopoll -from secop.features import HasLimits +from secop.features import HasTargetLimits, HasSimpleOffset from secop.datatypes import IntRange, FloatRange, StringType, BoolType from secop.errors import ConfigError, BadValueError sys.path.append('/home/l_samenv/Documents/anc350/Linux64/userlib/lib') @@ -61,7 +61,7 @@ class FreezeStatus: self.status = code, text -class Axis(HasLimits, FreezeStatus, Drivable): +class Axis(HasTargetLimits, HasSimpleOffset, FreezeStatus, Drivable): axis = Property('axis number', IntRange(0, 2), 0) value = Parameter('axis position', FloatRange(unit='deg')) frequency = Parameter('frequency', FloatRange(1, unit='Hz'), readonly=False) From a0d14c30beb37c78c60155447435dcf03ec89dd5 Mon Sep 17 00:00:00 2001 From: l_samenv Date: Fri, 2 Dec 2022 10:37:45 +0100 Subject: [PATCH 5/5] attocube: more robust moving - try up to 10 times when end of travel or no more progress - issue WARN instead of ERROR when moving did not work --- secop_psi/attocube.py | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/secop_psi/attocube.py b/secop_psi/attocube.py index ee6a9a5..d85d8c4 100644 --- a/secop_psi/attocube.py +++ b/secop_psi/attocube.py @@ -19,7 +19,7 @@ import sys import time -from secop.core import Drivable, Parameter, Command, Property, ERROR, BUSY, IDLE, Done, nopoll +from secop.core import Drivable, Parameter, Command, Property, ERROR, WARN, BUSY, IDLE, Done, nopoll from secop.features import HasTargetLimits, HasSimpleOffset from secop.datatypes import IntRange, FloatRange, StringType, BoolType from secop.errors import ConfigError, BadValueError @@ -61,7 +61,7 @@ class FreezeStatus: self.status = code, text -class Axis(HasTargetLimits, HasSimpleOffset, FreezeStatus, Drivable): +class Axis(HasTargetLimits, FreezeStatus, Drivable): axis = Property('axis number', IntRange(0, 2), 0) value = Parameter('axis position', FloatRange(unit='deg')) frequency = Parameter('frequency', FloatRange(1, unit='Hz'), readonly=False) @@ -89,7 +89,6 @@ class Axis(HasTargetLimits, HasSimpleOffset, FreezeStatus, Drivable): try: self._scale = self.SCALES[unit] * opts.get('gear', 1) except KeyError as e: - print(repr(e)) raise ConfigError('unsupported unit: %s' % unit) super().__init__(name, logger, opts, srv) @@ -102,7 +101,7 @@ class Axis(HasTargetLimits, HasSimpleOffset, FreezeStatus, Drivable): start_events.queue(self.read_info) def check_value(self, value): - """check if value allows moving in current driection""" + """check if value allows moving in current direction""" if self._direction > 0: if value > self.target_limits[1]: raise BadValueError('above upper limit') @@ -139,6 +138,7 @@ class Axis(HasTargetLimits, HasSimpleOffset, FreezeStatus, Drivable): def write_output(self, value): self._hw.setAxisOutput(self.axis, enable=value, autoDisable=0) + return value def read_status(self): statusbits = self._hw.getAxisStatus(self.axis) @@ -166,16 +166,24 @@ class Axis(HasTargetLimits, HasSimpleOffset, FreezeStatus, Drivable): beg = self._history.pop(0) if abs(beg - self.target) < self.tolerance: # reset normal tolerance - self._hw.setTargetRange(self.axis, self.tolerance / self._scale) + self._stop() + self._idle_status = IDLE, 'in tolerance' + return self._idle_status + # self._hw.setTargetRange(self.axis, self.tolerance / self._scale) if (self.value - beg) * self._direction > 0: return BUSY, status_text - self._idle_status = ERROR, 'no progress' + self._try_count += 1 + if self._try_count < 10: + self.log.warn('no progress retry %d', self._try_count) + return BUSY, status_text + self._idle_status = WARN, 'no progress' if self._error_state: self._try_count += 1 if self._try_count < 10 and self._history is not None: + self.log.warn('end of travel retry %d', self._try_count) self.write_target(self.target) return Done - self._idle_status = ERROR, self._error_state + self._idle_status = WARN, self._error_state if self.status[0] != IDLE: self._stop() return self._idle_status @@ -192,8 +200,14 @@ class Axis(HasTargetLimits, HasSimpleOffset, FreezeStatus, Drivable): self.write_output(1) # try first with 50 % of tolerance self._hw.setTargetRange(self.axis, self.tolerance * 0.5 / self._scale) - self._hw.setTargetPosition(self.axis, value / self._scale) - self._hw.startAutoMove(self.axis, enable=1, relative=0) + for itry in range(5): + try: + self._hw.setTargetPosition(self.axis, value / self._scale) + self._hw.startAutoMove(self.axis, enable=1, relative=0) + except Exception as e: + if itry == 4: + raise + self.log.warn('%r', e) self._history = [self.value] self._idle_status = IDLE, '' self.freeze_status(1, BUSY, 'changed target') @@ -219,7 +233,14 @@ class Axis(HasTargetLimits, HasSimpleOffset, FreezeStatus, Drivable): def _stop(self): self._move_steps = 0 self._history = None - self._hw.startAutoMove(self.axis, enable=0, relative=0) + for _ in range(5): + try: + self._hw.startAutoMove(self.axis, enable=0, relative=0) + break + except Exception as e: + if itry == 4: + raise + self.log.warn('%r', e) self._hw.setTargetRange(self.axis, self.tolerance / self._scale) self.setFastPoll(False)