1 # Copyright (C) 2008-2011 W. Trevor King <wking@drexel.edu>
3 # This file is part of pypiezo.
5 # pypiezo is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by the
7 # Free Software Foundation, either version 3 of the License, or (at your
8 # option) any later version.
10 # pypiezo is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13 # General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with pypiezo. If not, see <http://www.gnu.org/licenses/>.
18 "Basic piezo control."
21 from time import sleep as _sleep
23 import numpy as _numpy
24 from scipy.stats import linregress as _linregress
27 import matplotlib as _matplotlib
28 import matplotlib.pyplot as _matplotlib_pyplot
29 import time as _time # for timestamping lines on plots
30 except (ImportError, RuntimeError), e:
32 _matplotlib_import_error = e
34 from pycomedi.constant import TRIG_SRC, SDF
35 from pycomedi.utility import inttrig_insn, Reader, Writer
37 from . import LOG as _LOG
38 from . import config as _config
39 from . import package_config as _package_config
42 def convert_bits_to_volts(config, data):
43 """Convert bit-valued data to volts.
45 >>> config = _config.ChannelConfig()
46 >>> config['conversion-coefficients'] = [1, 2, 3]
47 >>> config['conversion-origin'] = -1
48 >>> convert_bits_to_volts(config, -1)
50 >>> convert_bits_to_volts(
51 ... config, _numpy.array([-1, 0, 1, 2], dtype=_numpy.float))
52 array([ 1., 6., 17., 34.])
54 coefficients = config['conversion-coefficients']
55 origin = config['conversion-origin']
56 return _numpy.polyval(list(reversed(coefficients)), data-origin)
58 def convert_volts_to_bits(config, data):
59 """Convert bit-valued data to volts.
61 >>> config = _config.ChannelConfig()
62 >>> config['inverse-conversion-coefficients'] = [1, 2, 3]
63 >>> config['inverse-conversion-origin'] = -1
64 >>> convert_volts_to_bits(config, -1)
66 >>> convert_volts_to_bits(
67 ... config, _numpy.array([-1, 0, 1, 2], dtype=_numpy.float))
68 array([ 1., 6., 17., 34.])
70 Note that the inverse coeffiecient and offset are difficult to
71 derive from the forward coefficient and offset. The current
72 Comedilib conversion functions, `comedi_to_physical()` and
73 `comedi_from_physical()` take `comedi_polynomial_t` conversion
74 arguments. `comedi_polynomial_t` is defined in `comedilib.h`_,
75 and holds a polynomial of length
76 `COMEDI_MAX_NUM_POLYNOMIAL_COEFFICIENTS`, currently set to 4. The
77 inverse of this cubic polynomial might be another polynomial, but
78 it might also have a more complicated form. A general inversion
79 solution is considered too complicated, so when you're setting up
80 your configuration, you should use Comedilib to save both the
81 forward and inverse coefficients and offsets.
83 .. _comedilib.h: http://www.comedi.org/git?p=comedi/comedilib.git;a=blob;f=include/comedilib.h;hb=HEAD
85 origin = config['inverse-conversion-origin']
86 inverse_coefficients = config['inverse-conversion-coefficients']
87 if len(inverse_coefficients) == 0:
88 raise NotImplementedError('cubic polynomial inversion')
89 return _numpy.polyval(list(reversed(inverse_coefficients)), data-origin)
91 def convert_volts_to_meters(config, data):
92 """Convert volt-valued data to meters.
94 >>> config = _config.AxisConfig()
95 >>> config['gain'] = 20.0
96 >>> config['sensitivity'] = 8e-9
97 >>> convert_volts_to_meters(config, 1)
99 >>> convert_volts_to_meters(
100 ... config, _numpy.array([1, 6, 17, 34], dtype=_numpy.float))
101 ... # doctest: +ELLIPSIS
102 array([ 1.6...e-07, 9.6...e-07, 2.7...e-06,
105 return data * config['gain'] * config['sensitivity']
107 def convert_meters_to_volts(config, data):
108 """Convert bit-valued data to volts.
110 >>> config = _config.AxisConfig()
111 >>> config['gain'] = 20.0
112 >>> config['sensitivity'] = 8e-9
113 >>> convert_meters_to_volts(config, 1.6e-7)
115 >>> convert_meters_to_volts(
116 ... config, _numpy.array([1.6e-7, 9.6e-7, 2.72e-6, 5.44e-6],
117 ... dtype=_numpy.float))
118 array([ 1., 6., 17., 34.])
120 return data / (config['gain'] * config['sensitivity'])
122 def convert_bits_to_meters(axis_config, data):
123 """Convert bit-valued data to meters.
125 >>> channel_config = _config.ChannelConfig()
126 >>> channel_config['conversion-coefficients'] = [1, 2, 3]
127 >>> channel_config['conversion-origin'] = -1
128 >>> axis_config = _config.AxisConfig()
129 >>> axis_config['gain'] = 20.0
130 >>> axis_config['sensitivity'] = 8e-9
131 >>> axis_config['channel'] = channel_config
132 >>> convert_bits_to_meters(axis_config, 1)
133 ... # doctest: +ELLIPSIS
135 >>> convert_bits_to_meters(
136 ... axis_config, _numpy.array([-1, 0, 1, 2], dtype=_numpy.float))
137 ... # doctest: +ELLIPSIS
138 array([ 1.6...e-07, 9.6...e-07, 2.7...e-06,
141 data = convert_bits_to_volts(axis_config['channel'], data)
142 return convert_volts_to_meters(axis_config, data)
144 def convert_meters_to_bits(axis_config, data):
145 """Convert meter-valued data to volts.
147 >>> channel_config = _config.ChannelConfig()
148 >>> channel_config['inverse-conversion-coefficients'] = [1, 2, 3]
149 >>> channel_config['inverse-conversion-origin'] = -1
150 >>> axis_config = _config.AxisConfig()
151 >>> axis_config['gain'] = 20.0
152 >>> axis_config['sensitivity'] = 8e-9
153 >>> axis_config['channel'] = channel_config
154 >>> convert_meters_to_bits(axis_config, 1.6e-7)
156 >>> convert_meters_to_bits(
158 ... _numpy.array([1.6e-7, 9.6e-7, 2.72e-6, 5.44e-6],
159 ... dtype=_numpy.float))
160 array([ 17., 162., 1009., 3746.])
162 data = convert_meters_to_volts(axis_config, data)
163 return convert_volts_to_bits(axis_config['channel'], data)
165 def _setup_channel_config(config, channel):
166 """Initialize the `ChannelConfig` `config` using the
167 `AnalogChannel` `channel`.
169 config['device'] = channel.subdevice.device.filename
170 config['subdevice'] = channel.subdevice.index
171 config['channel'] = channel.index
172 config['maxdata'] = channel.get_maxdata()
173 config['range'] = channel.range.value
174 converter = channel.get_converter()
175 config['conversion-origin'
176 ] = converter.get_to_physical_expansion_origin()
177 config['conversion-coefficients'
178 ] = converter.get_to_physical_coefficients()
179 config['inverse-conversion-origin'
180 ] = converter.get_from_physical_expansion_origin()
181 config['inverse-conversion-coefficients'
182 ] = converter.get_from_physical_coefficients()
185 class PiezoAxis (object):
186 """A one-dimensional piezoelectric actuator.
188 If used, the montoring channel must (as of now) be on the same
189 device as the controlling channel.
191 >>> from pycomedi.device import Device
192 >>> from pycomedi.subdevice import StreamingSubdevice
193 >>> from pycomedi.channel import AnalogChannel
194 >>> from pycomedi.constant import (AREF, SUBDEVICE_TYPE, UNIT)
196 >>> d = Device('/dev/comedi0')
199 >>> s_in = d.find_subdevice_by_type(SUBDEVICE_TYPE.ai,
200 ... factory=StreamingSubdevice)
201 >>> s_out = d.find_subdevice_by_type(SUBDEVICE_TYPE.ao,
202 ... factory=StreamingSubdevice)
204 >>> axis_channel = s_out.channel(
205 ... 0, factory=AnalogChannel, aref=AREF.ground)
206 >>> monitor_channel = s_in.channel(
207 ... 0, factory=AnalogChannel, aref=AREF.diff)
208 >>> for chan in [axis_channel, monitor_channel]:
209 ... chan.range = chan.find_range(unit=UNIT.volt, min=-10, max=10)
211 >>> config = _config.AxisConfig()
212 >>> config.update({'gain':20, 'sensitivity':8e-9})
213 >>> config['channel'] = _config.OutputChannelConfig()
214 >>> config['monitor'] = _config.InputChannelConfig()
215 >>> config['monitor']['device'] = '/dev/comediX'
217 >>> p = PiezoAxis(config=config)
218 ... # doctest: +NORMALIZE_WHITESPACE
219 Traceback (most recent call last):
221 NotImplementedError: piezo axis control and monitor on different devices
222 (/dev/comedi0 and /dev/comediX)
224 >>> config['monitor']['device'] = config['channel']['device']
225 >>> p = PiezoAxis(config=config,
226 ... axis_channel=axis_channel, monitor_channel=monitor_channel)
229 >>> print(config['channel'].dump())
231 'conversion-coefficients': array([ -1.00000000e+01, 3.05180438e-04]),
232 'conversion-origin': 0.0,
233 'device': '/dev/comedi0',
234 'inverse-conversion-coefficients': array([ 0. , 3276.75]),
235 'inverse-conversion-origin': -10.0,
239 >>> print(config['monitor'].dump())
241 'conversion-coefficients': array([ -1.00000000e+01, 3.05180438e-04]),
242 'conversion-origin': 0.0,
243 'device': '/dev/comedi0',
244 'inverse-conversion-coefficients': array([ 0. , 3276.75]),
245 'inverse-conversion-origin': -10.0,
250 >>> convert_bits_to_meters(p.config, 0)
251 ... # doctest: +ELLIPSIS
256 def __init__(self, config, axis_channel=None, monitor_channel=None):
258 if (config['monitor'] and
259 config['channel']['device'] != config['monitor']['device']):
260 raise NotImplementedError(
261 ('piezo axis control and monitor on different devices '
263 config['channel']['device'],
264 config['monitor']['device']))
266 raise NotImplementedError(
267 'pypiezo not yet capable of opening its own axis channel')
268 #axis_channel = pycomedi...
269 self.axis_channel = axis_channel
270 if config['monitor'] and not monitor_channel:
271 raise NotImplementedError(
272 'pypiezo not yet capable of opening its own monitor channel')
273 #monitor_channel = pycomedi...
274 self.monitor_channel = monitor_channel
275 self.name = config['channel']['name']
277 def setup_config(self):
278 "Initialize the axis (and monitor) configs."
279 _setup_channel_config(self.config['channel'], self.axis_channel)
280 if self.monitor_channel:
281 _setup_channel_config(
282 self.config['monitor'], self.monitor_channel)
283 if self.config['minimum'] is None:
284 self.config['minimum'] = convert_bits_to_volts(
285 self.config['channel'], 0)
286 if self.config['maximum'] is None:
287 self.config['maximum'] = convert_bits_to_volts(
288 self.config['channel'], self.axis_channel.get_maxdata())
291 class InputChannel(object):
292 """An input channel monitoring some interesting parameter.
294 >>> from pycomedi.device import Device
295 >>> from pycomedi.subdevice import StreamingSubdevice
296 >>> from pycomedi.channel import AnalogChannel
297 >>> from pycomedi.constant import AREF, SUBDEVICE_TYPE, UNIT
299 >>> d = Device('/dev/comedi0')
302 >>> s = d.find_subdevice_by_type(SUBDEVICE_TYPE.ai,
303 ... factory=StreamingSubdevice)
305 >>> channel = s.channel(0, factory=AnalogChannel, aref=AREF.diff)
306 >>> channel.range = channel.find_range(unit=UNIT.volt, min=-10, max=10)
308 >>> channel_config = _config.InputChannelConfig()
310 >>> c = InputChannel(config=channel_config, channel=channel)
312 >>> print(channel_config.dump())
314 'conversion-coefficients': array([ -1.00000000e+01, 3.05180438e-04]),
315 'conversion-origin': 0.0,
316 'device': '/dev/comedi0',
317 'inverse-conversion-coefficients': array([ 0. , 3276.75]),
318 'inverse-conversion-origin': -10.0,
323 >>> convert_bits_to_volts(c.config, 0)
328 def __init__(self, config, channel=None):
331 raise NotImplementedError(
332 'pypiezo not yet capable of opening its own channel')
333 #channel = pycomedi...
334 self.channel = channel
335 self.name = config['name']
337 def setup_config(self):
338 _setup_channel_config(self.config, self.channel)
341 class Piezo (object):
342 """A piezo actuator-controlled experiment.
344 >>> from pprint import pprint
345 >>> from pycomedi.device import Device
346 >>> from pycomedi.subdevice import StreamingSubdevice
347 >>> from pycomedi.channel import AnalogChannel
348 >>> from pycomedi.constant import AREF, SUBDEVICE_TYPE, UNIT
350 >>> d = Device('/dev/comedi0')
353 >>> s_in = d.find_subdevice_by_type(SUBDEVICE_TYPE.ai,
354 ... factory=StreamingSubdevice)
355 >>> s_out = d.find_subdevice_by_type(SUBDEVICE_TYPE.ao,
356 ... factory=StreamingSubdevice)
358 >>> axis_channel = s_out.channel(
359 ... 0, factory=AnalogChannel, aref=AREF.ground)
360 >>> monitor_channel = s_in.channel(
361 ... 0, factory=AnalogChannel, aref=AREF.diff)
362 >>> input_channel = s_in.channel(1, factory=AnalogChannel, aref=AREF.diff)
363 >>> for chan in [axis_channel, monitor_channel, input_channel]:
364 ... chan.range = chan.find_range(unit=UNIT.volt, min=-10, max=10)
366 >>> axis_config = _config.AxisConfig()
367 >>> axis_config.update({'gain':20, 'sensitivity':8e-9})
368 >>> axis_config['channel'] = _config.OutputChannelConfig()
369 >>> axis_config['channel']['name'] = 'z'
370 >>> axis_config['monitor'] = _config.InputChannelConfig()
371 >>> input_config = _config.InputChannelConfig()
372 >>> input_config['name'] = 'some-input'
374 >>> a = PiezoAxis(config=axis_config, axis_channel=axis_channel,
375 ... monitor_channel=monitor_channel)
378 >>> c = InputChannel(config=input_config, channel=input_channel)
381 >>> p = Piezo(axes=[a], inputs=[c], name='Charlie')
382 >>> inputs = p.read_inputs()
383 >>> pprint(inputs) # doctest: +SKIP
384 {'some-input': 34494L, 'z-monitor': 32669L}
386 >>> pos = convert_volts_to_bits(p.config['axes'][0]['channel'], 0)
390 >>> p.last_output == {'z': int(pos)}
393 :meth:`ramp` raises an error if passed an invalid `data` `dtype`.
395 >>> output_data = _numpy.arange(0, int(pos), step=int(pos/10),
396 ... dtype=_numpy.float)
397 >>> output_data = output_data.reshape((len(output_data), 1))
398 >>> input_data = p.ramp(data=output_data, frequency=10,
399 ... output_names=['z'], input_names=['z-monitor', 'some-input'])
400 Traceback (most recent call last):
402 ValueError: output dtype float64 does not match expected <type 'numpy.uint16'>
403 >>> output_data = _numpy.arange(0, int(pos), step=int(pos/10),
404 ... dtype=p.channel_dtype('z', direction='output'))
405 >>> output_data = output_data.reshape((len(output_data), 1))
406 >>> input_data = p.ramp(data=output_data, frequency=10,
407 ... output_names=['z'], input_names=['z-monitor', 'some-input'])
408 >>> input_data # doctest: +SKIP
419 [32646, 22686]], dtype=uint16)
421 >>> p.last_output == {'z': output_data[-1]}
424 >>> data = p.named_ramp(data=output_data, frequency=10,
425 ... output_names=['z'], input_names=['z-monitor', 'some-input'])
426 >>> pprint(data) # doctest: +ELLIPSIS, +SKIP
427 {'some-input': array([21666, 20566, ..., 22395], dtype=uint16),
428 'z': array([ 0, 3276, ..., 32760], dtype=uint16),
429 'z-monitor': array([ 3102, 6384, ..., 32647], dtype=uint16)}
433 def __init__(self, axes, inputs, name=None):
436 self.config = _config.PiezoConfig()
438 self.config['name'] = name
439 self.config['axes'] = [x.config for x in axes]
440 self.config['inputs'] = [x.config for x in inputs]
441 self.last_output = {}
443 def axis_by_name(self, name):
444 "Get an axis by its name."
445 for axis in self.axes:
446 if axis.name == name:
448 raise ValueError(name)
450 def input_channel_by_name(self, name):
451 "Get an input channel by its name."
452 for input_channel in self.inputs:
453 if input_channel.name == name:
455 raise ValueError(name)
457 def channels(self, direction=None):
458 """Iterate through all `(name, channel)` tuples.
460 =========== ===================
461 `direction` Returned channels
462 =========== ===================
463 'input' all input channels
464 'output' all output channels
466 =========== ===================
468 if direction not in ('input', 'output', None):
469 raise ValueError(direction)
471 if direction != 'input':
472 yield (a.name, a.axis_channel)
473 if a.monitor_channel and direction != 'output':
474 yield ('%s-monitor' % a.name, a.monitor_channel)
475 if direction != 'output':
476 for c in self.inputs:
477 yield (c.name, c.channel)
479 def channel_by_name(self, name, direction=None):
480 """Get a channel by its name.
482 Setting `direction` (see :meth:`channels`) may allow a more
485 for n,channel in self.channels(direction=direction):
486 _LOG.critical('channel name: {}'.format(repr(n)))
489 raise ValueError(name)
491 def channel_dtype(self, channel_name, direction=None):
492 """Get a channel's data type by name.
494 Setting `direction` (see :meth:`channels`) may allow a more
497 channel = self.channel_by_name(name=channel_name, direction=direction)
498 return channel.subdevice.get_dtype()
500 def read_inputs(self):
501 "Read all inputs and return a `name`->`value` dictionary."
502 # There is no multi-channel read instruction, so preform reads
504 ret = dict([(n, c.data_read()) for n,c in self.channels('input')])
505 _LOG.debug('current position: %s' % ret)
508 def jump(self, axis_name, position):
509 "Move the output named `axis_name` to `position`."
510 _LOG.debug('jump %s to %s' % (axis_name, position))
511 position = int(position)
512 channel = self.channel_by_name(name=axis_name)
513 channel.data_write(position)
514 self.last_output[axis_name] = position
516 def ramp(self, data, frequency, output_names, input_names=()):
517 """Synchronized IO ramp writing `data` and reading `in_names`.
521 data : numpy array-like
522 Row for each cycle, column for each output channel.
524 Target cycle frequency in Hz.
525 output_names : list of strings
526 Names of output channels in the same order as the columns
528 input_names : list of strings
529 Names of input channels to monitor in the same order as
530 the columns of the returned array.
532 if len(data.shape) != 2:
534 'ramp data must be two dimensional, not %d' % len(data.shape))
535 if data.shape[1] != len(output_names):
537 'ramp data should have on column for each input, '
538 'but has %d columns for %d inputs'
539 % (data.shape[1], len(output_names)))
540 n_samps = data.shape[0]
541 log_string = 'ramp %d samples at %g Hz. out: %s, in: %s' % (
542 n_samps, frequency, output_names, input_names)
543 _LOG.debug(log_string) # _LOG on one line for easy commenting-out
545 output_channels = [self.channel_by_name(name=n, direction='output')
546 for n in output_names]
547 inputs = [self.channel_by_name(name=n, direction='input')
548 for n in input_names]
550 ao_subdevice = output_channels[0].subdevice
551 ai_subdevice = inputs[0].subdevice
552 device = ao_subdevice.device
554 output_dtype = ao_subdevice.get_dtype()
555 if data.dtype != output_dtype:
556 raise ValueError('output dtype %s does not match expected %s'
557 % (data.dtype, output_dtype))
558 input_data = _numpy.ndarray(
559 (n_samps, len(inputs)), dtype=ai_subdevice.get_dtype())
561 _LOG.debug('setup ramp commands')
562 scan_period_ns = int(1e9 / frequency)
563 ai_cmd = ai_subdevice.get_cmd_generic_timed(
564 len(inputs), scan_period_ns)
565 ao_cmd = ao_subdevice.get_cmd_generic_timed(
566 len(output_channels), scan_period_ns)
568 ai_cmd.start_src = TRIG_SRC.int
570 ai_cmd.stop_src = TRIG_SRC.count
571 ai_cmd.stop_arg = n_samps
572 ai_cmd.chanlist = inputs
573 #ao_cmd.start_src = TRIG_SRC.ext
574 #ao_cmd.start_arg = 18 # NI card AI_START1 internal AI start signal
575 ao_cmd.start_src = TRIG_SRC.int
577 ao_cmd.stop_src = TRIG_SRC.count
578 ao_cmd.stop_arg = n_samps-1
579 ao_cmd.chanlist = output_channels
581 ai_subdevice.cmd = ai_cmd
582 ao_subdevice.cmd = ao_cmd
584 rc = ai_subdevice.command_test()
586 _LOG.debug('analog input test %d: %s' % (i, rc))
588 rc = ao_subdevice.command_test()
590 _LOG.debug('analog output test %d: %s' % (i, rc))
592 _LOG.debug('lock subdevices for ramp')
597 _LOG.debug('load ramp commands')
598 ao_subdevice.command()
599 ai_subdevice.command()
601 writer = Writer(ao_subdevice, data)
603 reader = Reader(ai_subdevice, input_data)
606 _LOG.debug('arm analog output')
607 device.do_insn(inttrig_insn(ao_subdevice))
608 _LOG.debug('trigger ramp (via analog input)')
609 device.do_insn(inttrig_insn(ai_subdevice))
610 _LOG.debug('ramp running')
614 _LOG.debug('ramp complete')
616 #_LOG.debug('AI flags: %s' % ai_subdevice.get_flags())
617 ai_subdevice.cancel()
618 ai_subdevice.unlock()
620 # release busy flag, which seems to not be cleared
622 # http://groups.google.com/group/comedi_list/browse_thread/thread/4c7040989197abad/
623 #_LOG.debug('AO flags: %s' % ao_subdevice.get_flags())
624 ao_subdevice.cancel()
625 ao_subdevice.unlock()
626 _LOG.debug('unlocked subdevices after ramp')
628 for i,name in enumerate(output_names):
629 self.last_output[name] = data[-1,i]
631 if _package_config['matplotlib']:
633 raise _matplotlib_import_error
634 figure = _matplotlib_pyplot.figure()
635 axes = figure.add_subplot(1, 1, 1)
637 timestamp = _time.strftime('%H%M%S')
638 axes.set_title('piezo ramp %s' % timestamp)
639 for d,names in [(data, output_names),
640 (input_data, input_names)]:
641 for i,name in enumerate(names):
642 axes.plot(d[:,i], label=name)
646 def named_ramp(self, data, frequency, output_names, input_names=()):
647 input_data = self.ramp(
648 data=data, frequency=frequency, output_names=output_names,
649 input_names=input_names)
651 for i,name in enumerate(output_names):
652 ret[name] = data[:,i]
653 for i,name in enumerate(input_names):
654 ret[name] = input_data[:,i]