'enumerate' is not subscriptable in the soft jump code.
[pypiezo.git] / pypiezo / base.py
1 # Copyright (C) 2011-2012 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of pypiezo.
4 #
5 # pypiezo is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
8 # version.
9 #
10 # pypiezo is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along with
15 # pypiezo.  If not, see <http://www.gnu.org/licenses/>.
16
17 """Basic piezo control.
18
19 Several of the classes defined in this module are simple wrappers for
20 combining a `pycomedi` class instance (e.g. `Channel`) with the
21 appropriate config data (e.g. `InputChannelConfig`).  The idea is that
22 the `h5config`-based config data will make it easy for you to save
23 your hardware configuration to disk (so that you have a record of what
24 you did).  It should also make it easy to load your configuration from
25 the disk (so that you can do the same thing again).  Because this
26 `h5config` <-> `pycomedi` communication works both ways, you have two
27 options when you're initializing a class:
28
29 1) On your first run, it's probably easiest to setup your channels and
30    such using the usual `pycomedi` interface
31    (`device.find_subdevice_by_type`, etc.)  After you have setup your
32    channels, you can initialize them with a stock config instance, and
33    call the `setup_config` method to copy the channel configuration
34    into the config file.  Now the config instance is ready to be saved
35    to disk.
36 2) On later runs, you have the option of loading the `pycomedi`
37    objects from your old configuration.  After loading the config data
38    from disk, initialize your class by passing in a `devices` list,
39    but without passing in the `Channel` instances.  The class will
40    take care of setting up the channel instances internally,
41    recreating your earlier setup.
42
43 For examples of how to apply either approach to a particular class,
44 see that class' docstring.
45 """
46
47 import math as _math
48 from time import sleep as _sleep
49
50 import numpy as _numpy
51 from scipy.stats import linregress as _linregress
52
53 try:
54     import matplotlib as _matplotlib
55     import matplotlib.pyplot as _matplotlib_pyplot
56     import time as _time  # for timestamping lines on plots
57 except (ImportError, RuntimeError), e:
58     _matplotlib = None
59     _matplotlib_import_error = e
60
61 from pycomedi.device import Device
62 from pycomedi.subdevice import StreamingSubdevice
63 from pycomedi.channel import AnalogChannel
64 from pycomedi.constant import AREF, TRIG_SRC, SDF, SUBDEVICE_TYPE, UNIT
65 from pycomedi.utility import inttrig_insn, Reader, Writer
66
67 from . import LOG as _LOG
68 from . import config as _config
69 from . import package_config as _package_config
70
71
72 def convert_bits_to_volts(config, data):
73     """Convert bit-valued data to volts.
74
75     >>> config = _config.ChannelConfig()
76     >>> config['conversion-coefficients'] = [1, 2, 3]
77     >>> config['conversion-origin'] = -1
78     >>> convert_bits_to_volts(config, -1)
79     1
80     >>> convert_bits_to_volts(
81     ...     config, _numpy.array([-1, 0, 1, 2], dtype=_numpy.float))
82     array([  1.,   6.,  17.,  34.])
83     """
84     coefficients = config['conversion-coefficients']
85     origin = config['conversion-origin']
86     return _numpy.polyval(list(reversed(coefficients)), data-origin)
87
88 def convert_volts_to_bits(config, data):
89     """Convert bit-valued data to volts.
90
91     >>> config = _config.ChannelConfig()
92     >>> config['inverse-conversion-coefficients'] = [1, 2, 3]
93     >>> config['inverse-conversion-origin'] = -1
94     >>> convert_volts_to_bits(config, -1)
95     1
96     >>> convert_volts_to_bits(
97     ...     config, _numpy.array([-1, 0, 1, 2], dtype=_numpy.float))
98     array([  1.,   6.,  17.,  34.])
99
100     Note that the inverse coeffiecient and offset are difficult to
101     derive from the forward coefficient and offset.  The current
102     Comedilib conversion functions, `comedi_to_physical()` and
103     `comedi_from_physical()` take `comedi_polynomial_t` conversion
104     arguments.  `comedi_polynomial_t` is defined in `comedilib.h`_,
105     and holds a polynomial of length
106     `COMEDI_MAX_NUM_POLYNOMIAL_COEFFICIENTS`, currently set to 4.  The
107     inverse of this cubic polynomial might be another polynomial, but
108     it might also have a more complicated form.  A general inversion
109     solution is considered too complicated, so when you're setting up
110     your configuration, you should use Comedilib to save both the
111     forward and inverse coefficients and offsets.
112
113     .. _comedilib.h: http://www.comedi.org/git?p=comedi/comedilib.git;a=blob;f=include/comedilib.h;hb=HEAD
114     """
115     origin = config['inverse-conversion-origin']
116     inverse_coefficients = config['inverse-conversion-coefficients']
117     if len(inverse_coefficients) == 0:
118         raise NotImplementedError('cubic polynomial inversion')
119     return _numpy.polyval(list(reversed(inverse_coefficients)), data-origin)
120
121 def convert_volts_to_meters(config, data):
122     """Convert volt-valued data to meters.
123
124     >>> config = _config.AxisConfig()
125     >>> config['gain'] = 20.0
126     >>> config['sensitivity'] = 8e-9
127     >>> convert_volts_to_meters(config, 1)
128     1.6e-07
129     >>> convert_volts_to_meters(
130     ...     config, _numpy.array([1, 6, 17, 34], dtype=_numpy.float))
131     ... # doctest: +ELLIPSIS
132     array([  1.6...e-07,   9.6...e-07,   2.7...e-06,
133              5.4...e-06])
134     """
135     return data * config['gain'] * config['sensitivity']
136
137 def convert_meters_to_volts(config, data):
138     """Convert bit-valued data to volts.
139
140     >>> config = _config.AxisConfig()
141     >>> config['gain'] = 20.0
142     >>> config['sensitivity'] = 8e-9
143     >>> convert_meters_to_volts(config, 1.6e-7)
144     1.0
145     >>> convert_meters_to_volts(
146     ...     config, _numpy.array([1.6e-7, 9.6e-7, 2.72e-6, 5.44e-6],
147     ...                          dtype=_numpy.float))
148     array([  1.,   6.,  17.,  34.])
149     """
150     return data / (config['gain'] * config['sensitivity'])
151
152 def convert_bits_to_meters(axis_config, data):
153     """Convert bit-valued data to meters.
154
155     >>> channel_config = _config.ChannelConfig()
156     >>> channel_config['conversion-coefficients'] = [1, 2, 3]
157     >>> channel_config['conversion-origin'] = -1
158     >>> axis_config = _config.AxisConfig()
159     >>> axis_config['gain'] = 20.0
160     >>> axis_config['sensitivity'] = 8e-9
161     >>> axis_config['channel'] = channel_config
162     >>> convert_bits_to_meters(axis_config, 1)
163     ... # doctest: +ELLIPSIS
164     2.7...e-06
165     >>> convert_bits_to_meters(
166     ...     axis_config, _numpy.array([-1, 0, 1, 2], dtype=_numpy.float))
167     ... # doctest: +ELLIPSIS
168     array([  1.6...e-07,   9.6...e-07,   2.7...e-06,
169              5.4...e-06])
170     """
171     data = convert_bits_to_volts(axis_config['channel'], data)
172     return convert_volts_to_meters(axis_config, data)
173
174 def convert_meters_to_bits(axis_config, data):
175     """Convert meter-valued data to volts.
176
177     >>> channel_config = _config.ChannelConfig()
178     >>> channel_config['inverse-conversion-coefficients'] = [1, 2, 3]
179     >>> channel_config['inverse-conversion-origin'] = -1
180     >>> axis_config = _config.AxisConfig()
181     >>> axis_config['gain'] = 20.0
182     >>> axis_config['sensitivity'] = 8e-9
183     >>> axis_config['channel'] = channel_config
184     >>> convert_meters_to_bits(axis_config, 1.6e-7)
185     17.0
186     >>> convert_meters_to_bits(
187     ...     axis_config,
188     ...     _numpy.array([1.6e-7, 9.6e-7, 2.72e-6, 5.44e-6],
189     ...                  dtype=_numpy.float))
190     array([   17.,   162.,  1009.,  3746.])
191     """
192     data = convert_meters_to_volts(axis_config, data)
193     return convert_volts_to_bits(axis_config['channel'], data)
194
195 def get_axis_name(axis_config):
196     """Return the name of an axis from the `AxisConfig`
197
198     This is useful, for example, with
199     `Config.select_config(get_attribute=get_axis_name)`.
200     """
201     channel_config = axis_config['channel']
202     return channel_config['name']
203
204 def load_device(filename, devices):
205     """Return an open device from `devices` which has a given `filename`.
206
207     Sometimes a caller will already have the required `Device`, in
208     which case we just pull that instance out of `devices`, check that
209     it's open, and return it.  Other times, the caller may want us to
210     open the device ourselves, so if we can't find an appropriate
211     device in `devices`, we create a new one, append it to `devices`
212     (so the caller can close it later), and return it.
213
214     You will have to open the `Device` yourself, though, because the
215     open device instance should not be held by a particular
216     `PiezoAxis` instance.  If you don't want to open devices yourself,
217     you can pass in a blank list of devices, and the initialization
218     routine will append any necessary-but-missing devices to it.
219
220     >>> from pycomedi.device import Device
221
222     >>> devices = [Device('/dev/comedi0')]
223     >>> device = load_device(filename='/dev/comedi0', devices=devices)
224     >>> device.filename
225     '/dev/comedi0'
226     >>> device.file is not None
227     True
228     >>> device.close()
229
230     >>> devices = []
231     >>> device = load_device(filename='/dev/comedi0', devices=devices)
232     >>> devices == [device]
233     True
234     >>> device.filename
235     '/dev/comedi0'
236     >>> device.file is not None
237     True
238     >>> device.close()
239
240     We try and return helpful errors when things go wrong:
241
242     >>> device = load_device(filename='/dev/comedi0', devices=None)
243     Traceback (most recent call last):
244       ...
245     TypeError: 'NoneType' object is not iterable
246     >>> device = load_device(filename='/dev/comedi0', devices=tuple())
247     Traceback (most recent call last):
248       ...
249     ValueError: none of the available devices ([]) match /dev/comedi0, and we cannot append to ()
250     >>> device = load_device(filename='/dev/comediX', devices=[])
251     Traceback (most recent call last):
252       ...
253     PyComediError: comedi_open (/dev/comediX): No such file or directory (None)
254     """
255     try:
256         matching_devices = [d for d in devices if d.filename == filename]
257     except TypeError:
258         _LOG.error('non-iterable devices? ({})'.format(devices))
259         raise
260     if matching_devices:
261         device = matching_devices[0]
262         if device.file is None:
263             device.open()
264     else:
265         device = Device(filename)
266         device.open()
267         try:
268             devices.append(device)  # pass new device back to caller
269         except AttributeError:
270             device.close()
271             raise ValueError(
272                 ('none of the available devices ({}) match {}, and we '
273                  'cannot append to {}').format(
274                     [d.filename for d in devices], filename, devices))
275     return device
276
277 def _load_channel_from_config(channel, devices, subdevice_type):
278     c = channel.config  # reduce verbosity
279     if not channel.channel:
280         device = load_device(filename=c['device'], devices=devices)
281         if c['subdevice'] < 0:
282             subdevice = device.find_subdevice_by_type(
283                 subdevice_type, factory=StreamingSubdevice)
284         else:
285             subdevice = device.subdevice(
286                 index=c['subdevice'], factory=StreamingSubdevice)
287         channel.channel = subdevice.channel(
288             index=c['channel'], factory=AnalogChannel,
289             aref=c['analog-reference'])
290         channel.channel.range = channel.channel.get_range(index=c['range'])
291     channel.name = c['name']
292
293 def _setup_channel_config(config, channel):
294     """Initialize the `ChannelConfig` `config` using the
295     `AnalogChannel` `channel`.
296     """
297     config['device'] = channel.subdevice.device.filename
298     config['subdevice'] = channel.subdevice.index
299     config['channel'] = channel.index
300     config['maxdata'] = channel.get_maxdata()
301     config['range'] = channel.range.value
302     config['analog-reference'] = AREF.index_by_value(channel.aref.value)
303     converter = channel.get_converter()
304     config['conversion-origin'
305            ] = converter.get_to_physical_expansion_origin()
306     config['conversion-coefficients'
307            ] = converter.get_to_physical_coefficients()
308     config['inverse-conversion-origin'
309            ] = converter.get_from_physical_expansion_origin()
310     config['inverse-conversion-coefficients'
311            ] = converter.get_from_physical_coefficients()
312
313
314 class PiezoAxis (object):
315     """A one-dimensional piezoelectric actuator.
316
317     If used, the montoring channel must (as of now) be on the same
318     device as the controlling channel.
319
320     >>> from pycomedi.device import Device
321     >>> from pycomedi.subdevice import StreamingSubdevice
322     >>> from pycomedi.channel import AnalogChannel
323     >>> from pycomedi.constant import (AREF, SUBDEVICE_TYPE, UNIT)
324
325     >>> d = Device('/dev/comedi0')
326     >>> d.open()
327
328     >>> s_in = d.find_subdevice_by_type(SUBDEVICE_TYPE.ai,
329     ...     factory=StreamingSubdevice)
330     >>> s_out = d.find_subdevice_by_type(SUBDEVICE_TYPE.ao,
331     ...     factory=StreamingSubdevice)
332
333     >>> axis_channel = s_out.channel(
334     ...     0, factory=AnalogChannel, aref=AREF.ground)
335     >>> monitor_channel = s_in.channel(
336     ...     0, factory=AnalogChannel, aref=AREF.diff)
337     >>> for chan in [axis_channel, monitor_channel]:
338     ...     chan.range = chan.find_range(unit=UNIT.volt, min=-10, max=10)
339
340     >>> config = _config.AxisConfig()
341     >>> config.update({'gain':20, 'sensitivity':8e-9})
342     >>> config['channel'] = _config.OutputChannelConfig()
343     >>> config['monitor'] = _config.InputChannelConfig()
344     >>> config['monitor']['device'] = '/dev/comediX'
345
346     >>> p = PiezoAxis(config=config)
347     ... # doctest: +NORMALIZE_WHITESPACE
348     Traceback (most recent call last):
349       ...
350     NotImplementedError: piezo axis control and monitor on different devices
351      (/dev/comedi0 and /dev/comediX)
352
353     >>> config['monitor']['device'] = config['channel']['device']
354     >>> p = PiezoAxis(config=config,
355     ...     axis_channel=axis_channel, monitor_channel=monitor_channel)
356
357     >>> p.setup_config()
358     >>> print(config['channel'].dump())
359     name: 
360     device: /dev/comedi0
361     subdevice: 1
362     channel: 0
363     maxdata: 65535
364     range: 0
365     analog-reference: ground
366     conversion-coefficients: -10.0, 0.000305180437934
367     conversion-origin: 0.0
368     inverse-conversion-coefficients: 0.0, 3276.75
369     inverse-conversion-origin: -10.0
370     >>> print(config['monitor'].dump())
371     name: 
372     device: /dev/comedi0
373     subdevice: 0
374     channel: 0
375     maxdata: 65535
376     range: 0
377     analog-reference: diff
378     conversion-coefficients: -10.0, 0.000305180437934
379     conversion-origin: 0.0
380     inverse-conversion-coefficients: 0.0, 3276.75
381     inverse-conversion-origin: -10.0
382
383     >>> convert_bits_to_meters(p.config, 0)
384     ... # doctest: +ELLIPSIS
385     -1.6...e-06
386
387     Opening from the config alone:
388
389     >>> p = PiezoAxis(config=config, devices=[d])
390     >>> p.axis_channel  # doctest: +ELLIPSIS
391     <pycomedi.channel.AnalogChannel object at 0x...>
392     >>> p.monitor_channel  # doctest: +ELLIPSIS
393     <pycomedi.channel.AnalogChannel object at 0x...>
394
395     >>> d.close()
396     """
397     def __init__(self, config, axis_channel=None, monitor_channel=None,
398                  devices=None):
399         self.config = config
400         self.axis_channel = axis_channel
401         self.monitor_channel = monitor_channel
402         self.load_from_config(devices=devices)
403
404     def load_from_config(self, devices):
405         c = self.config  # reduce verbosity
406         if (c['monitor'] and
407             c['channel']['device'] != c['monitor']['device']):
408             raise NotImplementedError(
409                 ('piezo axis control and monitor on different devices '
410                  '({} and {})').format(
411                     c['channel']['device'], c['monitor']['device']))
412         if not self.axis_channel:
413             self.axis_channel = OutputChannel(
414                 config=c['channel'], devices=devices).channel
415         if c['monitor'] and not self.monitor_channel:
416             self.monitor_channel = InputChannel(
417                 config=c['monitor'], devices=devices).channel
418         self.name = c['channel']['name']
419
420     def setup_config(self):
421         "Initialize the axis (and monitor) configs."
422         _setup_channel_config(self.config['channel'], self.axis_channel)
423         if self.monitor_channel:
424             _setup_channel_config(
425                 self.config['monitor'], self.monitor_channel)
426         if self.config['minimum'] is None:
427             self.config['minimum'] = convert_bits_to_volts(
428                 self.config['channel'], 0)
429         if self.config['maximum'] is None:
430             self.config['maximum'] = convert_bits_to_volts(
431                 self.config['channel'], self.axis_channel.get_maxdata())
432
433
434
435 class OutputChannel(object):
436     """An input channel monitoring some interesting parameter.
437
438     >>> from pycomedi.device import Device
439     >>> from pycomedi.subdevice import StreamingSubdevice
440     >>> from pycomedi.channel import AnalogChannel
441     >>> from pycomedi.constant import AREF, SUBDEVICE_TYPE, UNIT
442
443     >>> d = Device('/dev/comedi0')
444     >>> d.open()
445
446     >>> s = d.find_subdevice_by_type(SUBDEVICE_TYPE.ao,
447     ...     factory=StreamingSubdevice)
448
449     >>> channel = s.channel(0, factory=AnalogChannel, aref=AREF.diff)
450     >>> channel.range = channel.find_range(unit=UNIT.volt, min=-10, max=10)
451
452     >>> channel_config = _config.OutputChannelConfig()
453
454     >>> c = OutputChannel(config=channel_config, channel=channel)
455     >>> c.setup_config()
456     >>> print(channel_config.dump())
457     name: 
458     device: /dev/comedi0
459     subdevice: 1
460     channel: 0
461     maxdata: 65535
462     range: 0
463     analog-reference: diff
464     conversion-coefficients: -10.0, 0.000305180437934
465     conversion-origin: 0.0
466     inverse-conversion-coefficients: 0.0, 3276.75
467     inverse-conversion-origin: -10.0
468
469     >>> convert_volts_to_bits(c.config, -10)
470     0.0
471
472     Opening from the config alone:
473
474     >>> c = OutputChannel(config=channel_config, devices=[d])
475     >>> c.channel  # doctest: +ELLIPSIS
476     <pycomedi.channel.AnalogChannel object at 0x...>
477
478     >>> d.close()
479     """
480     def __init__(self, config, channel=None, devices=None):
481         self.config = config
482         self.channel = channel
483         self.load_from_config(devices=devices)
484
485     def load_from_config(self, devices):
486         _load_channel_from_config(
487             channel=self, devices=devices, subdevice_type=SUBDEVICE_TYPE.ao)
488
489     def setup_config(self):
490         _setup_channel_config(self.config, self.channel)
491
492
493 class InputChannel(object):
494     """An input channel monitoring some interesting parameter.
495
496     >>> from pycomedi.device import Device
497     >>> from pycomedi.subdevice import StreamingSubdevice
498     >>> from pycomedi.channel import AnalogChannel
499     >>> from pycomedi.constant import AREF, SUBDEVICE_TYPE, UNIT
500
501     >>> d = Device('/dev/comedi0')
502     >>> d.open()
503
504     >>> s = d.find_subdevice_by_type(SUBDEVICE_TYPE.ai,
505     ...     factory=StreamingSubdevice)
506
507     >>> channel = s.channel(0, factory=AnalogChannel, aref=AREF.diff)
508     >>> channel.range = channel.find_range(unit=UNIT.volt, min=-10, max=10)
509
510     >>> channel_config = _config.InputChannelConfig()
511
512     >>> c = InputChannel(config=channel_config, channel=channel)
513     >>> c.setup_config()
514     >>> print(channel_config.dump())
515     name: 
516     device: /dev/comedi0
517     subdevice: 0
518     channel: 0
519     maxdata: 65535
520     range: 0
521     analog-reference: diff
522     conversion-coefficients: -10.0, 0.000305180437934
523     conversion-origin: 0.0
524     inverse-conversion-coefficients: 0.0, 3276.75
525     inverse-conversion-origin: -10.0
526
527     >>> convert_bits_to_volts(c.config, 0)
528     -10.0
529
530     Opening from the config alone:
531
532     >>> c = InputChannel(config=channel_config, devices=[d])
533     >>> c.channel  # doctest: +ELLIPSIS
534     <pycomedi.channel.AnalogChannel object at 0x...>
535
536     >>> d.close()
537     """
538     def __init__(self, config, channel=None, devices=None):
539         self.config = config
540         self.channel = channel
541         self.load_from_config(devices=devices)
542
543     def load_from_config(self, devices):
544         _load_channel_from_config(
545             channel=self, devices=devices, subdevice_type=SUBDEVICE_TYPE.ai)
546
547     def setup_config(self):
548         _setup_channel_config(self.config, self.channel)
549
550
551 class Piezo (object):
552     """A piezo actuator-controlled experiment.
553
554     >>> from pprint import pprint
555     >>> from pycomedi.device import Device
556     >>> from pycomedi.subdevice import StreamingSubdevice
557     >>> from pycomedi.channel import AnalogChannel
558     >>> from pycomedi.constant import AREF, SUBDEVICE_TYPE, UNIT
559
560     >>> d = Device('/dev/comedi0')
561     >>> d.open()
562
563     >>> axis_config = _config.AxisConfig()
564     >>> axis_config['gain'] = 20.0
565     >>> axis_config['sensitivity'] = 8e-9
566     >>> axis_config['channel'] = _config.OutputChannelConfig()
567     >>> axis_config['channel']['analog-reference'] = AREF.ground
568     >>> axis_config['channel']['name'] = 'z'
569     >>> axis_config['monitor'] = _config.InputChannelConfig()
570     >>> axis_config['monitor']['analog-reference'] = AREF.diff
571     >>> a = PiezoAxis(config=axis_config, devices=[d])
572     >>> a.setup_config()
573
574     >>> input_config = _config.InputChannelConfig()
575     >>> input_config['analog-reference'] = AREF.diff
576     >>> input_config['name'] = 'some-input'
577     >>> c = InputChannel(config=input_config, devices=[d])
578     >>> c.setup_config()
579
580     >>> config = _config.PiezoConfig()
581     >>> config['name'] = 'Charlie'
582
583     >>> p = Piezo(config=config, axes=[a], inputs=[c])
584     >>> p.setup_config()
585     >>> inputs = p.read_inputs()
586     >>> pprint(inputs)  # doctest: +SKIP
587     {'some-input': 34494L, 'z-monitor': 32669L}
588
589     >>> pos = convert_volts_to_bits(p.config['axes'][0]['channel'], 0)
590     >>> pos
591     32767.5
592     >>> p.jump('z', pos)
593     >>> p.last_output == {'z': int(pos)}
594     True
595
596     :meth:`ramp` raises an error if passed an invalid `data` `dtype`.
597
598     >>> output_data = _numpy.arange(0, int(pos), step=int(pos/10),
599     ...     dtype=_numpy.float)
600     >>> output_data = output_data.reshape((len(output_data), 1))
601     >>> input_data = p.ramp(data=output_data, frequency=10,
602     ...     output_names=['z'], input_names=['z-monitor', 'some-input'])
603     Traceback (most recent call last):
604       ...
605     ValueError: output dtype float64 does not match expected <type 'numpy.uint16'>
606     >>> output_data = _numpy.arange(0, int(pos), step=int(pos/10),
607     ...     dtype=p.channel_dtype('z', direction='output'))
608     >>> output_data = output_data.reshape((len(output_data), 1))
609     >>> input_data = p.ramp(data=output_data, frequency=10,
610     ...     output_names=['z'], input_names=['z-monitor', 'some-input'])
611     >>> input_data  # doctest: +SKIP
612     array([[    0, 25219],
613            [ 3101, 23553],
614            [ 6384, 22341],
615            [ 9664, 21465],
616            [12949, 20896],
617            [16232, 20614],
618            [19516, 20588],
619            [22799, 20801],
620            [26081, 21233],
621            [29366, 21870],
622            [32646, 22686]], dtype=uint16)
623
624     >>> p.last_output == {'z': output_data[-1]}
625     True
626
627     >>> data = p.named_ramp(data=output_data, frequency=10,
628     ...     output_names=['z'], input_names=['z-monitor', 'some-input'])
629     >>> pprint(data)  # doctest: +ELLIPSIS, +SKIP
630     {'some-input': array([21666, 20566, ..., 22395], dtype=uint16),
631      'z': array([    0,  3276,  ..., 32760], dtype=uint16),
632      'z-monitor': array([ 3102,  6384,  ..., 32647], dtype=uint16)}
633
634     Opening from the config alone:
635
636     >>> p = Piezo(config=config, devices=[d])
637     >>> for axis in p.axes:
638     ...     print(axis.axis_channel)
639     ...     print(axis.monitor_channel)
640     ... # doctest: +ELLIPSIS
641     <pycomedi.channel.AnalogChannel object at 0x...>
642     <pycomedi.channel.AnalogChannel object at 0x...>
643     >>> for input in p.inputs:
644     ...     print(input.channel)
645     ... # doctest: +ELLIPSIS
646     <pycomedi.channel.AnalogChannel object at 0x...>
647
648     >>> d.close()
649     """
650     def __init__(self, config, axes=None, inputs=None, devices=None):
651         self.config=config
652         self.axes = axes
653         self.inputs = inputs
654         self.last_output = {}
655         self.load_from_config(devices=devices)
656
657     def load_from_config(self, devices):
658         if not self.axes:
659             self.axes = []
660             for config in self.config['axes']:
661                 self.axes.append(PiezoAxis(config=config, devices=devices))
662             self.last_output.clear()
663         if not self.inputs:
664             self.inputs = []
665             for config in self.config['inputs']:
666                 self.inputs.append(
667                     InputChannel(config=config, devices=devices))
668         self.name = self.config['name']
669
670     def setup_config(self):
671         "Initialize the axis and input configs."
672         for x in self.axes + self.inputs:
673             x.setup_config()
674         self.config['axes'] = [x.config for x in self.axes]
675         self.config['inputs'] = [x.config for x in self.inputs]
676
677     def axis_by_name(self, name):
678         "Get an axis by its name."
679         for axis in self.axes:
680             if axis.name == name:
681                 return axis
682         raise ValueError(name)
683
684     def input_channel_by_name(self, name):
685         "Get an input channel by its name."
686         for input_channel in self.inputs:
687             if input_channel.name == name:
688                 return input_channel
689         raise ValueError(name)
690
691     def channels(self, direction=None):
692         """Iterate through all `(name, channel)` tuples.
693
694         ===========  ===================
695         `direction`  Returned channels
696         ===========  ===================
697         'input'      all input channels
698         'output'     all output channels
699         None         all channels
700         ===========  ===================
701         """
702         if direction not in ('input', 'output', None):
703             raise ValueError(direction)
704         for a in self.axes:
705             if direction != 'input':
706                 yield (a.name, a.axis_channel)
707             if a.monitor_channel and direction != 'output':
708                 yield ('%s-monitor' % a.name, a.monitor_channel)
709         if direction != 'output':
710             for c in self.inputs:
711                 yield (c.name, c.channel)
712
713     def channel_by_name(self, name, direction=None):
714         """Get a channel by its name.
715
716         Setting `direction` (see :meth:`channels`) may allow a more
717         efficient search.
718         """
719         for n,channel in self.channels(direction=direction):
720             if n == name:
721                 return channel
722         raise ValueError(name)
723
724     def channel_dtype(self, channel_name, direction=None):
725         """Get a channel's data type by name.
726
727         Setting `direction` (see :meth:`channels`) may allow a more
728         efficient search.
729         """
730         channel = self.channel_by_name(name=channel_name, direction=direction)
731         return channel.subdevice.get_dtype()
732
733     def read_inputs(self):
734         "Read all inputs and return a `name`->`value` dictionary."
735         # There is no multi-channel read instruction, so preform reads
736         # sequentially.
737         ret = dict([(n, c.data_read())
738                     for n,c in self.channels(direction='input')])
739         _LOG.debug('current position: %s' % ret)
740         return ret
741
742     def jump(self, axis_name, position, steps=1, sleep=None):
743         "Move the output named `axis_name` to `position`."
744         _LOG.debug('jump {} to {} in {} steps'.format(
745                 axis_name, position, steps))
746         if steps > 1:
747             try:
748                 orig_pos = self.last_output[axis_name]
749             except KeyError, e:
750                 _LOG.warn(
751                     ("cannot make a soft jump to {} because we don't have a "
752                      'last-output position for {}').format(
753                         position, axis_name))
754                 return self.jump(axis_name=axis_name, position=position)
755             else:
756                 for i,pos in enumerate(_numpy.linspace(
757                         orig_pos, position, steps+1)[1:]):
758                     _LOG.debug('jump {} to {} ({} of {} steps)'.format(
759                             axis_name, pos, i, steps))
760                     self.jump(axis_name=axis_name, position=pos)
761                     if sleep:
762                         _sleep(sleep)
763                 return
764         position = int(position)
765         channel = self.channel_by_name(name=axis_name)
766         channel.data_write(position)
767         self.last_output[axis_name] = position
768
769     def ramp(self, data, frequency, output_names, input_names=()):
770         """Synchronized IO ramp writing `data` and reading `in_names`.
771
772         Parameters
773         ----------
774         data : numpy array-like
775             Row for each cycle, column for each output channel.
776         frequency : float
777             Target cycle frequency in Hz.
778         output_names : list of strings
779             Names of output channels in the same order as the columns
780             of `data`.
781         input_names : list of strings
782             Names of input channels to monitor in the same order as
783             the columns of the returned array.
784         """
785         if len(data.shape) != 2:
786             raise ValueError(
787                 'ramp data must be two dimensional, not %d' % len(data.shape))
788         if data.shape[1] != len(output_names):
789             raise ValueError(
790                 'ramp data should have on column for each input, '
791                 'but has %d columns for %d inputs'
792                 % (data.shape[1], len(output_names)))
793         n_samps = data.shape[0]
794         log_string = 'ramp %d samples at %g Hz.  out: %s, in: %s' % (
795             n_samps, frequency, output_names, input_names)
796         _LOG.debug(log_string)  # _LOG on one line for easy commenting-out
797         # TODO: check range?
798         output_channels = [self.channel_by_name(name=n, direction='output')
799                            for n in output_names]
800         inputs = [self.channel_by_name(name=n, direction='input')
801                           for n in input_names]
802
803         ao_subdevice = output_channels[0].subdevice
804         ai_subdevice = inputs[0].subdevice
805         device = ao_subdevice.device
806
807         output_dtype = ao_subdevice.get_dtype()
808         if data.dtype != output_dtype:
809             raise ValueError('output dtype %s does not match expected %s'
810                              % (data.dtype, output_dtype))
811         input_data = _numpy.ndarray(
812             (n_samps, len(inputs)), dtype=ai_subdevice.get_dtype())
813
814         _LOG.debug('setup ramp commands')
815         scan_period_ns = int(1e9 / frequency)
816         ai_cmd = ai_subdevice.get_cmd_generic_timed(
817             len(inputs), scan_period_ns)
818         ao_cmd = ao_subdevice.get_cmd_generic_timed(
819             len(output_channels), scan_period_ns)
820
821         ai_cmd.start_src = TRIG_SRC.int
822         ai_cmd.start_arg = 0
823         ai_cmd.stop_src = TRIG_SRC.count
824         ai_cmd.stop_arg = n_samps
825         ai_cmd.chanlist = inputs
826         #ao_cmd.start_src = TRIG_SRC.ext
827         #ao_cmd.start_arg = 18  # NI card AI_START1 internal AI start signal
828         ao_cmd.start_src = TRIG_SRC.int
829         ao_cmd.start_arg = 0
830         ao_cmd.stop_src = TRIG_SRC.count
831         ao_cmd.stop_arg = n_samps-1
832         ao_cmd.chanlist = output_channels
833
834         ai_subdevice.cmd = ai_cmd
835         ao_subdevice.cmd = ao_cmd
836         for i in range(3):
837             rc = ai_subdevice.command_test()
838             if rc is None: break
839             _LOG.debug('analog input test %d: %s' % (i, rc))
840         for i in range(3):
841             rc = ao_subdevice.command_test()
842             if rc is None: break
843             _LOG.debug('analog output test %d: %s' % (i, rc))
844
845         _LOG.debug('lock subdevices for ramp')
846         ao_subdevice.lock()
847         try:
848             ai_subdevice.lock()
849             try:
850                 _LOG.debug('load ramp commands')
851                 ao_subdevice.command()
852                 ai_subdevice.command()
853
854                 writer = Writer(ao_subdevice, data)
855                 writer.start()
856                 reader = Reader(ai_subdevice, input_data)
857                 reader.start()
858
859                 _LOG.debug('arm analog output')
860                 device.do_insn(inttrig_insn(ao_subdevice))
861                 _LOG.debug('trigger ramp (via analog input)')
862                 device.do_insn(inttrig_insn(ai_subdevice))
863                 _LOG.debug('ramp running')
864
865                 writer.join()
866                 reader.join()
867                 _LOG.debug('ramp complete')
868             finally:
869                 #_LOG.debug('AI flags: %s' % ai_subdevice.get_flags())
870                 ai_subdevice.cancel()
871                 ai_subdevice.unlock()
872         finally:
873             # release busy flag, which seems to not be cleared
874             # automatically.  See
875             #   http://groups.google.com/group/comedi_list/browse_thread/thread/4c7040989197abad/
876             #_LOG.debug('AO flags: %s' % ao_subdevice.get_flags())
877             ao_subdevice.cancel()
878             ao_subdevice.unlock()
879             _LOG.debug('unlocked subdevices after ramp')
880
881         for i,name in enumerate(output_names):
882             self.last_output[name] = data[-1,i]
883
884         if _package_config['matplotlib']:
885             if not _matplotlib:
886                 raise _matplotlib_import_error
887             figure = _matplotlib_pyplot.figure()
888             axes = figure.add_subplot(1, 1, 1)
889             axes.hold(True)
890             timestamp = _time.strftime('%H%M%S')
891             axes.set_title('piezo ramp %s' % timestamp)
892             for d,names in [(data, output_names),
893                             (input_data, input_names)]:
894                 for i,name in enumerate(names):
895                     axes.plot(d[:,i], label=name)
896             figure.show()
897         return input_data
898
899     def named_ramp(self, data, frequency, output_names, input_names=()):
900         input_data = self.ramp(
901             data=data, frequency=frequency, output_names=output_names,
902             input_names=input_names)
903         ret = {}
904         for i,name in enumerate(output_names):
905             ret[name] = data[:,i]
906         for i,name in enumerate(input_names):
907             ret[name] = input_data[:,i]
908         return ret