persistent params / trinamic motor

for the trinamic motor we need persistent parameters

Change-Id: Id509b87f8368ea5ba1aca71951f79433b0b4b79f
Reviewed-on: https://forge.frm2.tum.de/review/c/sine2020/secop/playground/+/26405
Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de>
Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch>
This commit is contained in:
zolliker 2021-07-16 14:59:36 +02:00
parent f3978385b4
commit 7bd166a8a1
4 changed files with 559 additions and 3 deletions

View File

@ -37,3 +37,4 @@ from secop.poller import AUTO, DYNAMIC, REGULAR, SLOW
from secop.properties import Property
from secop.proxy import Proxy, SecNode, proxy_class
from secop.io import HasIodev, StringIO, BytesIO
from secop.persistent import PersistentMixin, PersistentParam

142
secop/persistent.py Normal file
View File

@ -0,0 +1,142 @@
# -*- 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 keeping parameters persistent
For hardware not keeping parameters persistent, we might want to store them in Frappy.
The following example will make 'param1' and 'param2' persistent, i.e. whenever
one of the parameters is changed, either by a change command or when reading back
from the hardware, it is saved to a file, and reloaded after
a power down / power up cycle. In order to make this work properly, there is a
mechanism needed to detect power down (i.e. a reading a hardware parameter
taking a special value on power up).
An additional use might be the example of a motor with an encoder which looses
the counts of how many turns already happened on power down.
This can be solved by comparing the loaded encoder value self.encoder with a
fresh value from the hardware and then adjusting the zero point accordingly.
class MyClass(PersistentMixin, ...):
param1 = PersistentParam(...)
param2 = PersistentParam(...)
encoder = PersistentParam(...)
...
def read_encoder(self):
encoder = <get encoder from hardware>
if <power down/power up cycle detected>:
self.loadParameters()
<fix encoder turns by comparing loaded self.encoder with encoder from hw>
else:
self.saveParameters()
"""
import os
import json
from secop.lib import getGeneralConfig
from secop.params import Parameter, Property, BoolType, Command
from secop.modules import HasAccessibles
class PersistentParam(Parameter):
persistent = Property('persistence flag', BoolType(), default=True)
class PersistentMixin(HasAccessibles):
def __init__(self, *args, **kwds):
super().__init__(*args, **kwds)
persistentdir = os.path.join(getGeneralConfig()['logdir'], 'persistent')
os.makedirs(persistentdir, exist_ok=True)
self.persistentFile = os.path.join(persistentdir, '%s.%s.json' % (self.DISPATCHER.equipment_id, self.name))
self.initData = {}
for pname in self.parameters:
pobj = self.parameters[pname]
if not pobj.readonly and getattr(pobj, 'persistent', False):
self.initData[pname] = pobj.value
self.writeDict.update(self.loadParameters(write=False))
def loadParameters(self, write=True):
"""load persistent parameters
:return: persistent parameters which have to be written
is called upon startup and may be called from a module
when a hardware powerdown is detected
"""
try:
with open(self.persistentFile, 'r') as f:
self.persistentData = json.load(f)
except FileNotFoundError:
self.persistentData = {}
writeDict = {}
for pname in self.parameters:
pobj = self.parameters[pname]
if getattr(pobj, 'persistent', False) and pname in self.persistentData:
try:
value = pobj.datatype.import_value(self.persistentData[pname])
pobj.value = value
if not pobj.readonly:
writeDict[pname] = value
except Exception as e:
self.log.warning('can not restore %r to %r (%r)' % (pname, value, e))
if write:
self.writeDict.update(writeDict)
self.writeInitParams()
return writeDict
def saveParameters(self):
"""save persistent parameters
- to be called regularely explicitly by the module
- the caller has to make sure that this is not called after
a power down of the connected hardware before loadParameters
"""
if self.writeDict:
# do not save before all values are written to the hw, as potentially
# factory default values were read in the mean time
return
data = {k: v.export_value() for k, v in self.parameters.items()
if getattr(v, 'persistent', False)}
if data != self.persistentData:
self.persistentData = data
persistentdir = os.path.basename(self.persistentFile)
tmpfile = self.persistentFile + '.tmp'
if not os.path.isdir(persistentdir):
os.makedirs(persistentdir, exist_ok=True)
try:
with open(tmpfile, 'w') as f:
json.dump(self.persistentData, f, indent=2)
f.write('\n')
os.rename(tmpfile, self.persistentFile)
finally:
try:
os.remove(tmpfile)
except FileNotFoundError:
pass
@Command()
def factory_reset(self):
self.writeDict.update(self.initData)
self.writeInitParams()

View File

@ -110,9 +110,6 @@ class Property:
self.name = name
if self.export and not self.extname:
self.extname = '_' + name
if self.description == '_':
# the programmer indicates, that the name is already speaking for itself
self.description = name.replace('_', ' ')
def __repr__(self):
extras = ['default=%s' % repr(self.default)]

416
secop_psi/trinamic.py Normal file
View File

@ -0,0 +1,416 @@
# -*- 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>
#
# *****************************************************************************
"""drivers for trinamic PD-1161 motors"""
import time
import struct
from math import log10
from secop.core import BoolType, Command, EnumType, FloatRange, IntRange, \
HasIodev, Parameter, Property, Drivable, PersistentMixin, PersistentParam
from secop.io import BytesIO
from secop.errors import CommunicationFailedError, HardwareError, BadValueError, IsBusyError
MOTOR_STOP = 3
MOVE = 4
SET_AXIS_PAR = 5
GET_AXIS_PAR = 6
SET_GLOB_PAR = 9
GET_GLOB_PAR = 10
# STORE_GLOB_PAR = 11
BAUDRATES = [9600, 0, 19200, 0, 38400, 57600, 0, 115200]
FULL_STEP = 1.8
ANGLE_SCALE = FULL_STEP/256
# assume factory settings for pulse and ramp divisors:
SPEED_SCALE = 1E6 / 2 ** 15 * ANGLE_SCALE
MAX_SPEED = 2047 * SPEED_SCALE
ACCEL_SCALE = 1E12 / 2 ** 31 * ANGLE_SCALE
MAX_ACCEL = 2047 * ACCEL_SCALE
CURRENT_SCALE = 2.8/250
ENCODER_RESOLUTION = 360 / 1024
class HwParam(PersistentParam):
adr = Property('parameter address', IntRange(0, 255), export=False)
scale = Property('scale factor (physical value / unit)', FloatRange(), export=False)
def __init__(self, description, datatype, adr, scale=1, poll=True,
readonly=True, persistent=None, **kwds):
"""hardware parameter"""
if persistent is None:
persistent = not readonly
if isinstance(datatype, FloatRange) and datatype.fmtstr == '%g':
datatype.fmtstr = '%%.%df' % max(0, 1 - int(log10(scale) + 0.01))
super().__init__(description, datatype, poll=poll, adr=adr, scale=scale,
persistent=persistent, readonly=readonly, **kwds)
def copy(self):
res = HwParam(self.description, self.datatype.copy(), self.adr)
res.name = self.name
res.init(self.propertyValues)
return res
class Motor(PersistentMixin, HasIodev, Drivable):
address = Property('module address', IntRange(0, 255), default=1)
value = Parameter('motor position', FloatRange(unit='deg', fmtstr='%.3f'))
zero = PersistentParam('zero point', FloatRange(unit='$'), readonly=False, default=0)
encoder = HwParam('encoder reading', FloatRange(unit='$', fmtstr='%.1f'),
209, ANGLE_SCALE, readonly=True, initwrite=False, persistent=True)
steppos = HwParam('position from motor steps', FloatRange(unit='$'),
1, ANGLE_SCALE, readonly=True, initwrite=False)
target = Parameter('', FloatRange(unit='$'), default=0)
movelimit = Parameter('max. angle to drive in one go', FloatRange(unit='$'),
readonly=False, default=360, group='more')
tolerance = Parameter('positioning tolerance', FloatRange(unit='$'),
readonly=False, default=0.9)
encoder_tolerance = HwParam('the allowed deviation between steppos and encoder\n\nmust be > tolerance',
FloatRange(0, 360., unit='$'),
212, ANGLE_SCALE, readonly=False, group='more')
speed = HwParam('max. speed', FloatRange(0, MAX_SPEED, unit='$/sec'),
4, SPEED_SCALE, readonly=False)
minspeed = HwParam('min. speed', FloatRange(0, MAX_SPEED, unit='$/sec'),
130, SPEED_SCALE, readonly=False, default=SPEED_SCALE, group='motorparam')
currentspeed = HwParam('current speed', FloatRange(-MAX_SPEED, MAX_SPEED, unit='$/sec'),
3, SPEED_SCALE, readonly=True, group='motorparam')
maxcurrent = HwParam('', FloatRange(0, 2.8, unit='A'),
6, CURRENT_SCALE, readonly=False, group='motorparam')
standby_current = HwParam('', FloatRange(0, 2.8, unit='A'),
7, CURRENT_SCALE, readonly=False, group='motorparam')
acceleration = HwParam('', FloatRange(4.6 * ACCEL_SCALE, MAX_ACCEL, unit='deg/s^2'),
5, ACCEL_SCALE, readonly=False, group='motorparam')
target_reached = HwParam('', BoolType(), 8, group='hwstatus')
move_status = HwParam('', IntRange(0, 3),
207, readonly=True, group='hwstatus')
error_bits = HwParam('', IntRange(0, 255),
208, readonly=True, group='hwstatus')
# the doc says msec, but I believe the scale is 10 msec
free_wheeling = HwParam('', FloatRange(0, 60., unit='sec'),
204, 0.01, default=0.1, readonly=False, group='motorparam')
power_down_delay = HwParam('', FloatRange(0, 60., unit='sec'),
214, 0.01, default=0.1, readonly=False, group='motorparam')
baudrate = Parameter('', EnumType({'%d' % v: i for i, v in enumerate(BAUDRATES)}),
readonly=False, default=0, poll=True, visibility=3, group='more')
pollinterval = Parameter(group='more')
iodevClass = BytesIO
fast_pollfactor = 0.001 # poll as fast as possible when busy
_started = 0
_calcTimeout = True
_need_reset = None
def comm(self, cmd, adr, value=0, bank=0):
"""set or get a parameter
:param adr: parameter number
:param cmd: SET command (in the GET case, 1 is added to this)
:param bank: the bank
:param value: if given, the parameter is written, else it is returned
:return: the returned value
"""
if self._calcTimeout:
self._calcTimeout = False
baudrate = getattr(self._iodev._conn.connection, 'baudrate', None)
if baudrate:
if baudrate not in BAUDRATES:
raise CommunicationFailedError('unsupported baud rate: %d' % baudrate)
self._iodev.timeout = 0.03 + 200 / baudrate
exc = None
byt = struct.pack('>BBBBi', self.address, cmd, adr, bank, round(value))
byt += bytes([sum(byt) & 0xff])
for itry in range(3,0,-1):
try:
reply = self._iodev.communicate(byt, 9)
if sum(reply[:-1]) & 0xff != reply[-1]:
raise CommunicationFailedError('checksum error')
# will try again
except Exception as e:
if itry == 1:
raise
exc = e
continue
break
if exc:
self.log.warning('tried %d times after %s', itry, exc)
radr, modadr, status, rcmd, result = struct.unpack('>BBBBix', reply)
if status != 100:
self.log.warning('bad status from cmd %r %s: %d', cmd, adr, status)
if radr != 2 or modadr != self.address or cmd != rcmd:
raise CommunicationFailedError('bad reply %r to command %s %d' % (reply, cmd, adr))
return result
def get(self, pname):
"""get parameter"""
pobj = self.parameters[pname]
value = self.comm(GET_AXIS_PAR, pobj.adr)
# do not apply scale when 1 (datatype might not be float)
return value if pobj.scale == 1 else value * pobj.scale
def set(self, pname, value, check=True):
"""set parameter and check result"""
pobj = self.parameters[pname]
scale = pobj.scale
rawvalue = round(value / scale)
self.comm(SET_AXIS_PAR, pobj.adr, rawvalue)
if check:
result = self.comm(GET_AXIS_PAR, pobj.adr)
if result != rawvalue:
raise HardwareError('result does not match %d != %d' % (result, rawvalue))
value = result * scale
return value
def startModule(self, started_callback):
# get encoder value from motor. at this stage self.encoder contains the persistent value
encoder = self.get('encoder')
encoder += self.zero
self.fix_encoder(encoder)
super().startModule(started_callback)
def fix_encoder(self, encoder_from_hw):
"""fix encoder value
:param encoder_from_hw: the encoder value read from the HW
self.encoder is assumed to contain the last known (persistent) value
if encoder has not changed modulo 360, adjust by an integer multiple of 360
set status to error when encoder has changed be more than encoder_tolerance
"""
# calculate nearest, most probable value
adjusted_encoder = encoder_from_hw + round((self.encoder - encoder_from_hw) / 360.) * 360
if abs(self.encoder - adjusted_encoder) >= self.encoder_tolerance:
# encoder module0 360 has changed
self.log.error('saved encoder value (%.2f) does not match reading (%.2f %.2f)',
self.encoder, encoder_from_hw, adjusted_encoder)
if adjusted_encoder != encoder_from_hw:
self.log.info('take next closest encoder value (%.2f)' % adjusted_encoder)
self._need_reset = True
self.status = self.Status.ERROR, 'saved encoder value does not match reading'
self.set('encoder', adjusted_encoder - self.zero, check=False)
def read_value(self):
encoder = self.read_encoder()
steppos = self.read_steppos()
initialized = self.comm(GET_GLOB_PAR, 255, bank=2)
if initialized: # no power loss
self.saveParameters()
else: # just powered up
# get persistent values
writeDict = self.loadParameters()
self.log.info('set to previous saved values %r', writeDict)
# self.encoder now contains the last known (persistent) value
if self._need_reset is None:
if self.status[0] == self.Status.IDLE:
# server started, power cycled and encoder value matches last one
self.reset()
else:
self.fix_encoder(encoder)
self._need_reset = True
self.status = self.Status.ERROR, 'power loss'
# or should we just fix instead of error status?
# self.set('steppos', self.steppos - self.zero, check=False)
self.comm(SET_GLOB_PAR, 255, 1, bank=2) # set initialized flag
self._started = 0
return encoder if abs(encoder - steppos) > self.tolerance else steppos
def read_status(self):
oldpos = self.steppos
self.read_value() # make sure encoder and steppos are fresh
if not self._started:
if abs(self.encoder - self.steppos) > self.encoder_tolerance:
self._need_reset = True
if self.status[0] != self.Status.ERROR:
self.log.error('encoder (%.2f) does not match internal pos (%.2f)', self.encoder, self.steppos)
return self.Status.ERROR, 'encoder does not match internal pos'
return self.status
if oldpos != self.steppos or not (self.read_target_reached() or self.read_move_status()
or self.read_error_bits()):
return self.Status.BUSY, 'moving'
diff = self.target - self.encoder
if abs(diff) <= self.tolerance:
self._started = 0
return self.Status.IDLE, ''
self.log.error('out of tolerance by %.3g', diff)
self._started = 0
return self.Status.ERROR, 'out of tolerance'
def write_target(self, target):
self.read_value() # make sure encoder and steppos are fresh
if abs(target - self.encoder) > self.movelimit:
raise BadValueError('can not move more than %s deg' % self.movelimit)
diff = self.encoder - self.steppos
if self._need_reset:
raise HardwareError('need reset (%s)' % self.status[1])
if abs(diff) > self.tolerance:
if abs(diff) > self.encoder_tolerance:
self._need_reset = True
self.status = self.Status.ERROR, 'encoder does not match internal pos'
raise HardwareError('need reset (encoder does not match internal pos)')
self.set('steppos', self.encoder - self.zero)
self._started = time.time()
self.log.info('move to %.1f', target)
self.comm(MOVE, 0, (target - self.zero) / ANGLE_SCALE)
self.status = self.Status.BUSY, 'changed target'
return target
def write_zero(self, value):
diff = value - self.zero
self.encoder += diff
self.steppos += diff
self.value += diff
return value
def read_encoder(self):
return self.get('encoder') + self.zero
def read_steppos(self):
return self.get('steppos') + self.zero
def read_encoder_tolerance(self):
return self.get('encoder_tolerance')
def write_encoder_tolerance(self, value):
return self.set('encoder_tolerance', value)
def read_target_reached(self):
return self.get('target_reached')
def read_speed(self):
return self.get('speed')
def write_speed(self, value):
return self.set('speed', value)
def read_minspeed(self):
return self.get('minspeed')
def write_minspeed(self, value):
return self.set('minspeed', value)
def read_currentspeed(self):
return self.get('currentspeed')
def read_acceleration(self):
return self.get('acceleration')
def write_acceleration(self, value):
return self.set('acceleration', value)
def read_maxcurrent(self):
return self.get('maxcurrent')
def write_maxcurrent(self, value):
return self.set('maxcurrent', value)
def read_standby_current(self):
return self.get('standby_current')
def write_standby_current(self, value):
return self.set('standby_current', value)
def read_free_wheeling(self):
return self.get('free_wheeling')
def write_free_wheeling(self, value):
return self.set('free_wheeling', value)
def read_power_down_delay(self):
return self.get('power_down_delay')
def write_power_down_delay(self, value):
return self.set('power_down_delay', value)
def read_move_status(self):
return self.get('move_status')
def read_error_bits(self):
return self.get('error_bits')
@Command(FloatRange())
def set_zero(self, value):
self.write_zero(value - self.read_value())
def read_baudrate(self):
return self.comm(GET_GLOB_PAR, 65)
def write_baudrate(self, value):
self.comm(SET_GLOB_PAR, 65, int(value))
@Command()
def reset(self):
"""set steppos to encoder value, if not within tolerance"""
if self._started:
raise IsBusyError('can not reset while moving')
tol = ENCODER_RESOLUTION * 1.1
for itry in range(10):
diff = self.read_encoder() - self.read_steppos()
if abs(diff) <= tol:
self._need_reset = False
self.status = self.Status.IDLE, 'ok'
return
self.set('steppos', self.encoder - self.zero, check=False)
self.comm(MOVE, 0, (self.encoder - self.zero) / ANGLE_SCALE)
time.sleep(0.1)
if itry > 5:
tol = self.tolerance
self.status = self.Status.ERROR, 'reset failed'
return
@Command()
def stop(self):
"""stop motor immediately"""
self.comm(MOTOR_STOP, 0)
self.status = self.Status.IDLE, 'stopped'
self._started = 0
@Command()
def step_forward(self):
"""move one full step forwards
for quick tests
"""
self.comm(MOVE, 1, FULL_STEP / ANGLE_SCALE)
@Command()
def step_back(self):
"""move one full step backwards
for quick tests
"""
self.comm(MOVE, 1, - FULL_STEP / ANGLE_SCALE)
@Command(IntRange(), result=IntRange())
def get_axis_par(self, adr):
"""get arbitrary motor parameter"""
return self.comm(GET_AXIS_PAR, adr)
@Command((IntRange(), FloatRange()), result=IntRange())
def set_axis_par(self, adr, value):
"""set arbitrary motor parameter"""
return self.comm(SET_AXIS_PAR, adr, value)