Only call figure.show if it exits.
[pypiezo.git] / pypiezo / base.py
1 # Copyright (C) 2011-2012 W. Trevor King <wking@tremily.us>
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)
390     >>> p.load_from_config(devices=[d])
391     >>> p.axis_channel  # doctest: +ELLIPSIS
392     <pycomedi.channel.AnalogChannel object at 0x...>
393     >>> p.monitor_channel  # doctest: +ELLIPSIS
394     <pycomedi.channel.AnalogChannel object at 0x...>
395
396     >>> d.close()
397     """
398     def __init__(self, config, axis_channel=None, monitor_channel=None):
399         self.config = config
400         self.axis_channel = axis_channel
401         self.monitor_channel = monitor_channel
402
403     def load_from_config(self, devices):
404         c = self.config  # reduce verbosity
405         if (c['monitor'] and
406             c['channel']['device'] != c['monitor']['device']):
407             raise NotImplementedError(
408                 ('piezo axis control and monitor on different devices '
409                  '({} and {})').format(
410                     c['channel']['device'], c['monitor']['device']))
411         if not self.axis_channel:
412             output = OutputChannel(config=c['channel'])
413             output.load_from_config(devices=devices)
414             self.axis_channel = output.channel
415         if c['monitor'] and not self.monitor_channel:
416             monitor = InputChannel(config=c['monitor'])
417             monitor.load_from_config(devices=devices)
418             self.monitor_channel = monitor.channel
419         self.name = c['channel']['name']
420
421     def setup_config(self):
422         "Initialize the axis (and monitor) configs."
423         _setup_channel_config(self.config['channel'], self.axis_channel)
424         if self.monitor_channel:
425             _setup_channel_config(
426                 self.config['monitor'], self.monitor_channel)
427         if self.config['minimum'] is None:
428             self.config['minimum'] = convert_bits_to_volts(
429                 self.config['channel'], 0)
430         if self.config['maximum'] is None:
431             self.config['maximum'] = convert_bits_to_volts(
432                 self.config['channel'], self.axis_channel.get_maxdata())
433
434
435
436 class OutputChannel(object):
437     """An input channel monitoring some interesting parameter.
438
439     >>> from pycomedi.device import Device
440     >>> from pycomedi.subdevice import StreamingSubdevice
441     >>> from pycomedi.channel import AnalogChannel
442     >>> from pycomedi.constant import AREF, SUBDEVICE_TYPE, UNIT
443
444     >>> d = Device('/dev/comedi0')
445     >>> d.open()
446
447     >>> s = d.find_subdevice_by_type(SUBDEVICE_TYPE.ao,
448     ...     factory=StreamingSubdevice)
449
450     >>> channel = s.channel(0, factory=AnalogChannel, aref=AREF.diff)
451     >>> channel.range = channel.find_range(unit=UNIT.volt, min=-10, max=10)
452
453     >>> channel_config = _config.OutputChannelConfig()
454
455     >>> c = OutputChannel(config=channel_config, channel=channel)
456     >>> c.setup_config()
457     >>> print(channel_config.dump())
458     name: 
459     device: /dev/comedi0
460     subdevice: 1
461     channel: 0
462     maxdata: 65535
463     range: 0
464     analog-reference: diff
465     conversion-coefficients: -10.0, 0.000305180437934
466     conversion-origin: 0.0
467     inverse-conversion-coefficients: 0.0, 3276.75
468     inverse-conversion-origin: -10.0
469
470     >>> convert_volts_to_bits(c.config, -10)
471     0.0
472
473     Opening from the config alone:
474
475     >>> c = OutputChannel(config=channel_config)
476     >>> c.load_from_config(devices=[d])
477     >>> c.channel  # doctest: +ELLIPSIS
478     <pycomedi.channel.AnalogChannel object at 0x...>
479
480     >>> d.close()
481     """
482     def __init__(self, config, channel=None):
483         self.config = config
484         self.channel = channel
485
486     def load_from_config(self, devices):
487         _load_channel_from_config(
488             channel=self, devices=devices, subdevice_type=SUBDEVICE_TYPE.ao)
489
490     def setup_config(self):
491         _setup_channel_config(self.config, self.channel)
492
493
494 class InputChannel(object):
495     """An input channel monitoring some interesting parameter.
496
497     >>> from pycomedi.device import Device
498     >>> from pycomedi.subdevice import StreamingSubdevice
499     >>> from pycomedi.channel import AnalogChannel
500     >>> from pycomedi.constant import AREF, SUBDEVICE_TYPE, UNIT
501
502     >>> d = Device('/dev/comedi0')
503     >>> d.open()
504
505     >>> s = d.find_subdevice_by_type(SUBDEVICE_TYPE.ai,
506     ...     factory=StreamingSubdevice)
507
508     >>> channel = s.channel(0, factory=AnalogChannel, aref=AREF.diff)
509     >>> channel.range = channel.find_range(unit=UNIT.volt, min=-10, max=10)
510
511     >>> channel_config = _config.InputChannelConfig()
512
513     >>> c = InputChannel(config=channel_config, channel=channel)
514     >>> c.setup_config()
515     >>> print(channel_config.dump())
516     name: 
517     device: /dev/comedi0
518     subdevice: 0
519     channel: 0
520     maxdata: 65535
521     range: 0
522     analog-reference: diff
523     conversion-coefficients: -10.0, 0.000305180437934
524     conversion-origin: 0.0
525     inverse-conversion-coefficients: 0.0, 3276.75
526     inverse-conversion-origin: -10.0
527
528     >>> convert_bits_to_volts(c.config, 0)
529     -10.0
530
531     Opening from the config alone:
532
533     >>> c = InputChannel(config=channel_config)
534     >>> c.load_from_config(devices=[d])
535     >>> c.channel  # doctest: +ELLIPSIS
536     <pycomedi.channel.AnalogChannel object at 0x...>
537
538     >>> d.close()
539     """
540     def __init__(self, config, channel=None):
541         self.config = config
542         self.channel = channel
543
544     def load_from_config(self, devices):
545         _load_channel_from_config(
546             channel=self, devices=devices, subdevice_type=SUBDEVICE_TYPE.ai)
547
548     def setup_config(self):
549         _setup_channel_config(self.config, self.channel)
550
551
552 class Piezo (object):
553     """A piezo actuator-controlled experiment.
554
555     >>> from pprint import pprint
556     >>> from pycomedi.device import Device
557     >>> from pycomedi.subdevice import StreamingSubdevice
558     >>> from pycomedi.channel import AnalogChannel
559     >>> from pycomedi.constant import AREF, SUBDEVICE_TYPE, UNIT
560
561     >>> d = Device('/dev/comedi0')
562     >>> d.open()
563
564     >>> axis_config = _config.AxisConfig()
565     >>> axis_config['gain'] = 20.0
566     >>> axis_config['sensitivity'] = 8e-9
567     >>> axis_config['channel'] = _config.OutputChannelConfig()
568     >>> axis_config['channel']['analog-reference'] = AREF.ground
569     >>> axis_config['channel']['name'] = 'z'
570     >>> axis_config['monitor'] = _config.InputChannelConfig()
571     >>> axis_config['monitor']['analog-reference'] = AREF.diff
572     >>> a = PiezoAxis(config=axis_config)
573     >>> a.load_from_config(devices=[d])
574     >>> a.setup_config()
575
576     >>> input_config = _config.InputChannelConfig()
577     >>> input_config['analog-reference'] = AREF.diff
578     >>> input_config['name'] = 'some-input'
579     >>> c = InputChannel(config=input_config)
580     >>> c.load_from_config(devices=[d])
581     >>> c.setup_config()
582
583     >>> config = _config.PiezoConfig()
584     >>> config['name'] = 'Charlie'
585
586     >>> p = Piezo(config=config, axes=[a], inputs=[c])
587     >>> p.setup_config()
588     >>> inputs = p.read_inputs()
589     >>> pprint(inputs)  # doctest: +SKIP
590     {'some-input': 34494L, 'z-monitor': 32669L}
591
592     >>> pos = convert_volts_to_bits(p.config['axes'][0]['channel'], 0)
593     >>> pos
594     32767.5
595     >>> p.jump('z', pos)
596     >>> p.last_output == {'z': int(pos)}
597     True
598
599     :meth:`ramp` raises an error if passed an invalid `data` `dtype`.
600
601     >>> output_data = _numpy.arange(0, int(pos), step=int(pos/10),
602     ...     dtype=_numpy.float)
603     >>> output_data = output_data.reshape((len(output_data), 1))
604     >>> input_data = p.ramp(data=output_data, frequency=10,
605     ...     output_names=['z'], input_names=['z-monitor', 'some-input'])
606     Traceback (most recent call last):
607       ...
608     ValueError: output dtype float64 does not match expected <type 'numpy.uint16'>
609     >>> output_data = _numpy.arange(0, int(pos), step=int(pos/10),
610     ...     dtype=p.channel_dtype('z', direction='output'))
611     >>> output_data = output_data.reshape((len(output_data), 1))
612     >>> input_data = p.ramp(data=output_data, frequency=10,
613     ...     output_names=['z'], input_names=['z-monitor', 'some-input'])
614     >>> input_data  # doctest: +SKIP
615     array([[    0, 25219],
616            [ 3101, 23553],
617            [ 6384, 22341],
618            [ 9664, 21465],
619            [12949, 20896],
620            [16232, 20614],
621            [19516, 20588],
622            [22799, 20801],
623            [26081, 21233],
624            [29366, 21870],
625            [32646, 22686]], dtype=uint16)
626
627     >>> p.last_output == {'z': output_data[-1]}
628     True
629
630     >>> data = p.named_ramp(data=output_data, frequency=10,
631     ...     output_names=['z'], input_names=['z-monitor', 'some-input'])
632     >>> pprint(data)  # doctest: +ELLIPSIS, +SKIP
633     {'some-input': array([21666, 20566, ..., 22395], dtype=uint16),
634      'z': array([    0,  3276,  ..., 32760], dtype=uint16),
635      'z-monitor': array([ 3102,  6384,  ..., 32647], dtype=uint16)}
636
637     Opening from the config alone:
638
639     >>> p = Piezo(config=config)
640     >>> p.load_from_config(devices=[d])
641     >>> for axis in p.axes:
642     ...     print(axis.axis_channel)
643     ...     print(axis.monitor_channel)
644     ... # doctest: +ELLIPSIS
645     <pycomedi.channel.AnalogChannel object at 0x...>
646     <pycomedi.channel.AnalogChannel object at 0x...>
647     >>> for input in p.inputs:
648     ...     print(input.channel)
649     ... # doctest: +ELLIPSIS
650     <pycomedi.channel.AnalogChannel object at 0x...>
651
652     >>> d.close()
653     """
654     def __init__(self, config, axes=None, inputs=None):
655         self.config=config
656         self.axes = axes
657         self.inputs = inputs
658         self.last_output = {}
659
660     def load_from_config(self, devices):
661         if not self.axes:
662             self.axes = []
663             for config in self.config['axes']:
664                 axis = PiezoAxis(config=config)
665                 axis.load_from_config(devices=devices)
666                 self.axes.append(axis)
667             self.last_output.clear()
668         if not self.inputs:
669             self.inputs = []
670             for config in self.config['inputs']:
671                 input = InputChannel(config=config)
672                 input.load_from_config(devices=devices)
673                 self.inputs.append(input)
674         self.name = self.config['name']
675
676     def setup_config(self):
677         "Initialize the axis and input configs."
678         for x in self.axes + self.inputs:
679             x.setup_config()
680         self.config['axes'] = [x.config for x in self.axes]
681         self.config['inputs'] = [x.config for x in self.inputs]
682
683     def axis_by_name(self, name):
684         "Get an axis by its name."
685         for axis in self.axes:
686             if axis.name == name:
687                 return axis
688         raise ValueError(name)
689
690     def input_channel_by_name(self, name):
691         "Get an input channel by its name."
692         for input_channel in self.inputs:
693             if input_channel.name == name:
694                 return input_channel
695         raise ValueError(name)
696
697     def channels(self, direction=None):
698         """Iterate through all `(name, channel)` tuples.
699
700         ===========  ===================
701         `direction`  Returned channels
702         ===========  ===================
703         'input'      all input channels
704         'output'     all output channels
705         None         all channels
706         ===========  ===================
707         """
708         if direction not in ('input', 'output', None):
709             raise ValueError(direction)
710         for a in self.axes:
711             if direction != 'input':
712                 yield (a.name, a.axis_channel)
713             if a.monitor_channel and direction != 'output':
714                 yield ('%s-monitor' % a.name, a.monitor_channel)
715         if direction != 'output':
716             for c in self.inputs:
717                 yield (c.name, c.channel)
718
719     def channel_by_name(self, name, direction=None):
720         """Get a channel by its name.
721
722         Setting `direction` (see :meth:`channels`) may allow a more
723         efficient search.
724         """
725         for n,channel in self.channels(direction=direction):
726             if n == name:
727                 return channel
728         raise ValueError(name)
729
730     def channel_dtype(self, channel_name, direction=None):
731         """Get a channel's data type by name.
732
733         Setting `direction` (see :meth:`channels`) may allow a more
734         efficient search.
735         """
736         channel = self.channel_by_name(name=channel_name, direction=direction)
737         return channel.subdevice.get_dtype()
738
739     def read_inputs(self):
740         "Read all inputs and return a `name`->`value` dictionary."
741         # There is no multi-channel read instruction, so preform reads
742         # sequentially.
743         ret = dict([(n, c.data_read())
744                     for n,c in self.channels(direction='input')])
745         _LOG.debug('current position: %s' % ret)
746         return ret
747
748     def jump(self, axis_name, position, steps=1, sleep=None):
749         "Move the output named `axis_name` to `position`."
750         _LOG.debug('jump {} to {} in {} steps'.format(
751                 axis_name, position, steps))
752         if steps > 1:
753             try:
754                 orig_pos = self.last_output[axis_name]
755             except KeyError, e:
756                 _LOG.warn(
757                     ("cannot make a soft jump to {} because we don't have a "
758                      'last-output position for {}').format(
759                         position, axis_name))
760                 return self.jump(axis_name=axis_name, position=position)
761             else:
762                 for i,pos in enumerate(_numpy.linspace(
763                         orig_pos, position, steps+1)[1:]):
764                     _LOG.debug('jump {} to {} ({} of {} steps)'.format(
765                             axis_name, pos, i, steps))
766                     self.jump(axis_name=axis_name, position=pos)
767                     if sleep:
768                         _sleep(sleep)
769                 return
770         position = int(position)
771         channel = self.channel_by_name(name=axis_name)
772         channel.data_write(position)
773         self.last_output[axis_name] = position
774
775     def ramp(self, data, frequency, output_names, input_names=()):
776         """Synchronized IO ramp writing `data` and reading `in_names`.
777
778         Parameters
779         ----------
780         data : numpy array-like
781             Row for each cycle, column for each output channel.
782         frequency : float
783             Target cycle frequency in Hz.
784         output_names : list of strings
785             Names of output channels in the same order as the columns
786             of `data`.
787         input_names : list of strings
788             Names of input channels to monitor in the same order as
789             the columns of the returned array.
790         """
791         if len(data.shape) != 2:
792             raise ValueError(
793                 'ramp data must be two dimensional, not %d' % len(data.shape))
794         if data.shape[1] != len(output_names):
795             raise ValueError(
796                 'ramp data should have on column for each input, '
797                 'but has %d columns for %d inputs'
798                 % (data.shape[1], len(output_names)))
799         n_samps = data.shape[0]
800         log_string = 'ramp %d samples at %g Hz.  out: %s, in: %s' % (
801             n_samps, frequency, output_names, input_names)
802         _LOG.debug(log_string)  # _LOG on one line for easy commenting-out
803         # TODO: check range?
804         output_channels = [self.channel_by_name(name=n, direction='output')
805                            for n in output_names]
806         inputs = [self.channel_by_name(name=n, direction='input')
807                           for n in input_names]
808
809         ao_subdevice = output_channels[0].subdevice
810         ai_subdevice = inputs[0].subdevice
811         device = ao_subdevice.device
812
813         output_dtype = ao_subdevice.get_dtype()
814         if data.dtype != output_dtype:
815             raise ValueError('output dtype %s does not match expected %s'
816                              % (data.dtype, output_dtype))
817         input_data = _numpy.ndarray(
818             (n_samps, len(inputs)), dtype=ai_subdevice.get_dtype())
819
820         _LOG.debug('setup ramp commands')
821         scan_period_ns = int(1e9 / frequency)
822         ai_cmd = ai_subdevice.get_cmd_generic_timed(
823             len(inputs), scan_period_ns)
824         ao_cmd = ao_subdevice.get_cmd_generic_timed(
825             len(output_channels), scan_period_ns)
826
827         ai_cmd.start_src = TRIG_SRC.int
828         ai_cmd.start_arg = 0
829         ai_cmd.stop_src = TRIG_SRC.count
830         ai_cmd.stop_arg = n_samps
831         ai_cmd.chanlist = inputs
832         #ao_cmd.start_src = TRIG_SRC.ext
833         #ao_cmd.start_arg = 18  # NI card AI_START1 internal AI start signal
834         ao_cmd.start_src = TRIG_SRC.int
835         ao_cmd.start_arg = 0
836         ao_cmd.stop_src = TRIG_SRC.count
837         ao_cmd.stop_arg = n_samps-1
838         ao_cmd.chanlist = output_channels
839
840         ai_subdevice.cmd = ai_cmd
841         ao_subdevice.cmd = ao_cmd
842         for i in range(3):
843             rc = ai_subdevice.command_test()
844             if rc is None: break
845             _LOG.debug('analog input test %d: %s' % (i, rc))
846         for i in range(3):
847             rc = ao_subdevice.command_test()
848             if rc is None: break
849             _LOG.debug('analog output test %d: %s' % (i, rc))
850
851         _LOG.debug('lock subdevices for ramp')
852         ao_subdevice.lock()
853         try:
854             ai_subdevice.lock()
855             try:
856                 _LOG.debug('load ramp commands')
857                 ao_subdevice.command()
858                 ai_subdevice.command()
859
860                 writer = Writer(ao_subdevice, data)
861                 writer.start()
862                 reader = Reader(ai_subdevice, input_data)
863                 reader.start()
864
865                 _LOG.debug('arm analog output')
866                 device.do_insn(inttrig_insn(ao_subdevice))
867                 _LOG.debug('trigger ramp (via analog input)')
868                 device.do_insn(inttrig_insn(ai_subdevice))
869                 _LOG.debug('ramp running')
870
871                 writer.join()
872                 reader.join()
873                 _LOG.debug('ramp complete')
874             finally:
875                 #_LOG.debug('AI flags: %s' % ai_subdevice.get_flags())
876                 ai_subdevice.cancel()
877                 ai_subdevice.unlock()
878         finally:
879             # release busy flag, which seems to not be cleared
880             # automatically.  See
881             #   http://groups.google.com/group/comedi_list/browse_thread/thread/4c7040989197abad/
882             #_LOG.debug('AO flags: %s' % ao_subdevice.get_flags())
883             ao_subdevice.cancel()
884             ao_subdevice.unlock()
885             _LOG.debug('unlocked subdevices after ramp')
886
887         for i,name in enumerate(output_names):
888             self.last_output[name] = data[-1,i]
889
890         if _package_config['matplotlib']:
891             if not _matplotlib:
892                 raise _matplotlib_import_error
893             figure = _matplotlib_pyplot.figure()
894             axes = figure.add_subplot(1, 1, 1)
895             axes.hold(True)
896             timestamp = _time.strftime('%H%M%S')
897             axes.set_title('piezo ramp %s' % timestamp)
898             for d,names in [(data, output_names),
899                             (input_data, input_names)]:
900                 for i,name in enumerate(names):
901                     axes.plot(d[:,i], label=name)
902             figure.canvas.draw()
903             if hasattr(figure, 'show'):
904                 figure.show()
905             if not _matplotlib.is_interactive():
906                 _matplotlib_pyplot.show()
907         return input_data
908
909     def named_ramp(self, data, frequency, output_names, input_names=()):
910         input_data = self.ramp(
911             data=data, frequency=frequency, output_names=output_names,
912             input_names=input_names)
913         ret = {}
914         for i,name in enumerate(output_names):
915             ret[name] = data[:,i]
916         for i,name in enumerate(input_names):
917             ret[name] = input_data[:,i]
918         return ret
919
920     def zero(self, axis_names=None, **kwargs):
921         zeros = []
922         if axis_names is None:
923             axis_names = [axis.name for axis in self.axes]
924         for axis_name in axis_names:
925             axis = self.axis_by_name(axis_name)
926             config = self.config.select_config(
927                 'axes', axis_name, get_attribute=get_axis_name)['channel']
928             zero = convert_volts_to_bits(config, 0)
929             zeros.append(zero)
930             self.jump(axis_name, zero)
931         return zeros