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):
488 raise ValueError(name)
490 def channel_dtype(self, channel_name, direction=None):
491 """Get a channel's data type by name.
493 Setting `direction` (see :meth:`channels`) may allow a more
496 channel = self.channel_by_name(name=channel_name, direction=direction)
497 return channel.subdevice.get_dtype()
499 def read_inputs(self):
500 "Read all inputs and return a `name`->`value` dictionary."
501 # There is no multi-channel read instruction, so preform reads
503 ret = dict([(n, c.data_read()) for n,c in self.channels('input')])
504 _LOG.debug('current position: %s' % ret)
507 def jump(self, axis_name, position):
508 "Move the output named `axis_name` to `position`."
509 _LOG.debug('jump %s to %s' % (axis_name, position))
510 position = int(position)
511 channel = self.channel_by_name(name=axis_name)
512 channel.data_write(position)
513 self.last_output[axis_name] = position
515 def ramp(self, data, frequency, output_names, input_names=()):
516 """Synchronized IO ramp writing `data` and reading `in_names`.
520 data : numpy array-like
521 Row for each cycle, column for each output channel.
523 Target cycle frequency in Hz.
524 output_names : list of strings
525 Names of output channels in the same order as the columns
527 input_names : list of strings
528 Names of input channels to monitor in the same order as
529 the columns of the returned array.
531 if len(data.shape) != 2:
533 'ramp data must be two dimensional, not %d' % len(data.shape))
534 if data.shape[1] != len(output_names):
536 'ramp data should have on column for each input, '
537 'but has %d columns for %d inputs'
538 % (data.shape[1], len(output_names)))
539 n_samps = data.shape[0]
540 log_string = 'ramp %d samples at %g Hz. out: %s, in: %s' % (
541 n_samps, frequency, output_names, input_names)
542 _LOG.debug(log_string) # _LOG on one line for easy commenting-out
544 output_channels = [self.channel_by_name(name=n, direction='output')
545 for n in output_names]
546 inputs = [self.channel_by_name(name=n, direction='input')
547 for n in input_names]
549 ao_subdevice = output_channels[0].subdevice
550 ai_subdevice = inputs[0].subdevice
551 device = ao_subdevice.device
553 output_dtype = ao_subdevice.get_dtype()
554 if data.dtype != output_dtype:
555 raise ValueError('output dtype %s does not match expected %s'
556 % (data.dtype, output_dtype))
557 input_data = _numpy.ndarray(
558 (n_samps, len(inputs)), dtype=ai_subdevice.get_dtype())
560 _LOG.debug('setup ramp commands')
561 scan_period_ns = int(1e9 / frequency)
562 ai_cmd = ai_subdevice.get_cmd_generic_timed(
563 len(inputs), scan_period_ns)
564 ao_cmd = ao_subdevice.get_cmd_generic_timed(
565 len(output_channels), scan_period_ns)
567 ai_cmd.start_src = TRIG_SRC.int
569 ai_cmd.stop_src = TRIG_SRC.count
570 ai_cmd.stop_arg = n_samps
571 ai_cmd.chanlist = inputs
572 #ao_cmd.start_src = TRIG_SRC.ext
573 #ao_cmd.start_arg = 18 # NI card AI_START1 internal AI start signal
574 ao_cmd.start_src = TRIG_SRC.int
576 ao_cmd.stop_src = TRIG_SRC.count
577 ao_cmd.stop_arg = n_samps-1
578 ao_cmd.chanlist = output_channels
580 ai_subdevice.cmd = ai_cmd
581 ao_subdevice.cmd = ao_cmd
583 rc = ai_subdevice.command_test()
585 _LOG.debug('analog input test %d: %s' % (i, rc))
587 rc = ao_subdevice.command_test()
589 _LOG.debug('analog output test %d: %s' % (i, rc))
591 _LOG.debug('lock subdevices for ramp')
596 _LOG.debug('load ramp commands')
597 ao_subdevice.command()
598 ai_subdevice.command()
600 writer = Writer(ao_subdevice, data)
602 reader = Reader(ai_subdevice, input_data)
605 _LOG.debug('arm analog output')
606 device.do_insn(inttrig_insn(ao_subdevice))
607 _LOG.debug('trigger ramp (via analog input)')
608 device.do_insn(inttrig_insn(ai_subdevice))
609 _LOG.debug('ramp running')
613 _LOG.debug('ramp complete')
615 #_LOG.debug('AI flags: %s' % ai_subdevice.get_flags())
616 ai_subdevice.cancel()
617 ai_subdevice.unlock()
619 # release busy flag, which seems to not be cleared
621 # http://groups.google.com/group/comedi_list/browse_thread/thread/4c7040989197abad/
622 #_LOG.debug('AO flags: %s' % ao_subdevice.get_flags())
623 ao_subdevice.cancel()
624 ao_subdevice.unlock()
625 _LOG.debug('unlocked subdevices after ramp')
627 for i,name in enumerate(output_names):
628 self.last_output[name] = data[-1,i]
630 if _package_config['matplotlib']:
632 raise _matplotlib_import_error
633 figure = _matplotlib_pyplot.figure()
634 axes = figure.add_subplot(1, 1, 1)
636 timestamp = _time.strftime('%H%M%S')
637 axes.set_title('piezo ramp %s' % timestamp)
638 for d,names in [(data, output_names),
639 (input_data, input_names)]:
640 for i,name in enumerate(names):
641 axes.plot(d[:,i], label=name)
645 def named_ramp(self, data, frequency, output_names, input_names=()):
646 input_data = self.ramp(
647 data=data, frequency=frequency, output_names=output_names,
648 input_names=input_names)
650 for i,name in enumerate(output_names):
651 ret[name] = data[:,i]
652 for i,name in enumerate(input_names):
653 ret[name] = input_data[:,i]