File: instrument.rst

package info (click to toggle)
python-pymeasure 0.14.0-2
  • links: PTS, VCS
  • area: main
  • in suites: sid, trixie
  • size: 8,788 kB
  • sloc: python: 47,201; makefile: 155
file content (302 lines) | stat: -rw-r--r-- 15,826 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
File structure
==============

Your new instrument should be placed in the directory corresponding to the manufacturer of the instrument. For example, if you are going to add an "Extreme 5000" instrument you should add the following files assuming "Extreme" is the manufacturer. Use lowercase for all filenames to distinguish packages from CamelCase Python classes.

.. code-block:: none

    pymeasure/pymeasure/instruments/extreme/
        |--> __init__.py
        |--> extreme5000.py

Updating the init file
**********************

The :code:`__init__.py` file in the manufacturer directory should import all of the instruments that correspond to the manufacturer, to allow the files to be easily imported.

Add test files
**************

Test files (pytest) for each instrument are highly encouraged, as they help verify the code and implement changes. Testing new code parts with a test (Test Driven Development) is a good way for fast and good programming, as you catch errors early on.

.. code-block:: none

    pymeasure/tests/instruments/extreme/
        |--> test_extreme5000.py


Adding documentation
********************

Documentation for each instrument is required, and helps others understand the features you have implemented. Add a new reStructuredText file to the documentation.

.. code-block:: none

    pymeasure/docs/api/instruments/extreme/
        |--> index.rst
        |--> extreme5000.rst

Copy an existing instrument documentation file, which will automatically generate the documentation for the instrument. The :code:`index.rst` file should link to the :code:`extreme5000` file. For a new manufacturer, the manufacturer should be also linked in :code:`pymeasure/docs/api/instruments/index.rst`.

Instrument file
===============

All standard instruments should be child class of :class:`Instrument <pymeasure.instruments.Instrument>`. This provides the basic functionality for working with :class:`Adapters <pymeasure.adapters.Adapter>`, which perform the actual communication. 

The most basic instrument, for our "Extreme 5000" example starts like this:

.. testsetup::

    # Behind the scene, replace Instrument with FakeInstrument to enable
    # doctesting simple usage cases (default doctest group)
    from pymeasure.instruments.fakes import FakeInstrument as Instrument


.. testcode::

    #
    # This file is part of the PyMeasure package.
    #
    # Copyright (c) 2013-2024 PyMeasure Developers
    #
    # Permission is hereby granted, free of charge, to any person obtaining a copy
    # of this software and associated documentation files (the "Software"), to deal
    # in the Software without restriction, including without limitation the rights
    # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    # copies of the Software, and to permit persons to whom the Software is
    # furnished to do so, subject to the following conditions:
    #
    # The above copyright notice and this permission notice shall be included in
    # all copies or substantial portions of the Software.
    #
    # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    # THE SOFTWARE.
    #

    # from pymeasure.instruments import Instrument

This is a minimal instrument definition:

.. testcode::
    
    class Extreme5000(Instrument):
        """Control the imaginary Extreme 5000 instrument."""

        def __init__(self, adapter, name="Extreme 5000", **kwargs):
            super().__init__(
                adapter,
                name,
                **kwargs
            )

Make sure to include the PyMeasure license to each file, and add yourself as an author to the :code:`AUTHORS.txt` file.

There is a certain order of elements in an instrument class that is useful to adhere to:

* First, the initializer (the :code:`__init__()` method), this makes it faster to find when browsing the source code.
* Then class attributes/variables, if you need them.
* Then properties (pymeasure-specific or generic Python variants). This will be the bulk of the implementation.
* Finally, any methods.

Your instrument's user interface
================================

Your instrument will have a certain set of properties and methods that are available to a user and discoverable via the documentation or their editor's autocomplete function.

In principle you are free to choose how you do this (with the exception of standard SCPI properties like :code:`id`).
However, there are a couple of practices that have turned out to be useful to follow:

* Naming things is important. Try to choose clear, expressive, unambiguous names for your instrument's elements.
* If there are already similar instruments in the same "family" (like a power supply) in pymeasure, try to follow their lead where applicable. It's better if, e.g., all power supplies have a :code:`current_limit` instead of an assortment of :code:`current_max`, :code:`Ilim`, :code:`max_curr`, etc.
* If there is already an instrument with a similar command set, check if you can inherit from that one and just tweak a couple of things. This massively reduces code duplication and maintenance effort. The section :ref:`instruments_with_similar_features` shows how to achieve that.
* The bulk of your instrument's interface will probably be made up of properties for quantities to set and/or read out. Our custom properties (see :ref:`properties` ff. below) offer some convenience features and are therefore preferable, but plain Python properties are also fine.
* "Actions", commands or verbs should typically be methods, not properties: :code:`recall()`, :code:`trigger_scan()`, :code:`prepare_resistance_measurement()`, etc.
* This separation between properties and methods also naturally helps with observing the `"command-query separation" principle <https://en.wikipedia.org/wiki/Command%E2%80%93query_separation>`__.
* If your instrument has multiple identical channels, see :ref:`channels`.

In principle, you are free to write any methods that are necessary for interacting with the instrument. When doing so, make sure to use the :code:`self.ask(command)`, :code:`self.write(command)`, and :code:`self.read()` methods to issue commands instead of calling the adapter directly. If the communication requires changes to the commands sent/received, you can override these methods in your instrument, for further information see :ref:`advanced_communication_protocols`.

In practice, we have developed a number of best practices for making instruments easy to write and maintain. The following sections detail these, which are highly encouraged to follow.

.. _common_instrument_types:

Common instrument types
***********************
There are a number of categories that many instruments fit into.
In the future, pymeasure should gain an abstraction layer based on that, see `this issue <https://github.com/pymeasure/pymeasure/issues/416>`__.
Until that is ready, here are a couple of guidelines towards a more uniform API.
Note that not all already available instruments follow these, but expect this to be harmonized in the future.

Generic types mixins
--------------------
The :doc:`generic_types <../../api/instruments/generic_types>` module contains mixin classes for common types.
For example, if an instrument complies to SCPI standards, you can add :class:`~pymeasure.instruments.generic_types.SCPIMixin` to your instrument:

.. testcode::

    from pymeasure.instruments.generic_types import SCPIMixin

    class SomeSCPIInstrument(SCPIMixin, Instrument):
        """This instrument has properties and methods defined for all SCPI instruments"""

This mixin adds default SCPI properties like :attr:`~pymeasure.instruments.generic_types.SCPIMixin.id`, :attr:`~pymeasure.instruments.generic_types.SCPIMixin.status` and default methods like :meth:`~pymeasure.instruments.generic_types.SCPIMixin.clear` and :meth:`~pymeasure.instruments.generic_types.SCPIMixin.reset` to :code:`SomeSCPIInstrument`.


Frequent properties
-------------------
If your instrument has an **output** that can be switched on and off, use a :ref:`boolean property <boolean-properties>` called :code:`output_enabled`.

Power supplies
--------------
PSUs typically can measure the *actual* current and voltage, as well as have settings for the voltage level and the current limit.
To keep naming clear and avoid confusion, implement the properties :code:`current`, :code:`voltage`, :code:`voltage_setpoint` and :code:`current_limit`, respectively.

Managing status codes or other indicator values
***********************************************
Often, an instrument features one or more collections of specific values that signal some status, an instrument mode or a number of possible configuration values.
Typically, these are collected in mappings of some sort, as you want to provide a clear and understandable value to the user, while abstracting away the raw data, think :code:`ACQUISITION_MODE` instead of :code:`0x04`.
The mappings normally are kept at module level (i.e. not defined within the instrument class), so that they are available when using the property factories.
This is a small drawback of using Python class attributes.

The easiest way to handle these mappings is a plain :code:`dict`.
However, there is often a better way, the Python :code:`enum.Enum`.
To cite the `Python documentation <https://docs.python.org/3.11/howto/enum.html>`__,

    An Enum is a set of symbolic names bound to unique values. They are similar to global variables, but they offer a more useful :code:`repr()`, grouping, type-safety, and a few other features.

As our signal values are often integers, the most appropriate enum types are :code:`IntEnum` and :code:`IntFlag`.

:code:`IntEnum` is the same as :code:`Enum`, but its members are also integers and can be used anywhere that an integer can be used (so their use for composing commands is transparent), but logic/code they appear in is much more legible.
Note that starting from Python version 3.11, the printed format of the :code:`IntEnum` and :code:`IntFlag` has been changed to return numeric value; however, the symbolic name can be obtained by printing its :code:`repr` or the :code:`.name` property, or returning the value in a REPL.

.. doctest::

    >>> from enum import IntEnum
    >>> class InstrMode(IntEnum):
    ...     WAITING = 0x00
    ...     HEATING = 0x01
    ...     COOLING = 0x05
    ...
    >>> received_from_device = 0x01
    >>> current_mode = InstrMode(received_from_device)
    >>> if current_mode == InstrMode.WAITING:
    ...     print('Idle')
    ... else:
    ...     current_mode
    ...     print(repr(current_mode))
    ...     print(f'Mode value: {current_mode}')
    ...
    <InstrMode.HEATING: 1>
    <InstrMode.HEATING: 1>
    Mode value: 1

:code:`IntFlag` has the added benefit that it supports bitwise operators and combinations, and as such is a good fit for status bitmasks or error codes that can represent multiple values:

.. doctest::

    >>> from enum import IntFlag
    >>> class ErrorCode(IntFlag):
    ...     TEMP_OUT_OF_RANGE = 8
    ...     TEMPSENSOR_FAILURE = 4
    ...     COOLER_FAILURE = 2
    ...     HEATER_FAILURE = 1
    ...     OK = 0
    ...
    >>> received_from_device = 7
    >>> ErrorCode(received_from_device)
    <ErrorCode.TEMPSENSOR_FAILURE|COOLER_FAILURE|HEATER_FAILURE: 7>

:code:`IntFlags` are used by many instruments for the purpose just demonstrated.

The status property could look like this:

.. testcode::

    status = Instrument.measurement(
        "STB?", 
        """Measure the status of the device as enum.""",
        get_process=lambda v: ErrorCode(v), 
    )

.. _default_connection_settings:

Defining default connection settings
====================================

When implementing instruments, it's sometimes necessary to define default connection settings.
This might be because an instrument connection requires *specific non-default settings*, or because your instrument actually supports *multiple interfaces*.

The :py:class:`~pymeasure.adapters.VISAAdapter` class offers a flexible way of dealing with connection settings fully within the initializer of your instrument.

Single interface
****************

The simplest version, suitable when the instrument connection needs default settings, just passes all keywords through to the ``Instrument`` initializer, which hands them over to :py:class:`~pymeasure.adapters.VISAAdapter` if ``adapter`` is a string or integer.

.. code-block:: python

    def __init__(self, adapter, name="Extreme 5000", **kwargs):
        super().__init__(
            adapter,
            name,
            **kwargs
        )

If you want to set defaults that should be prominently visible to the user and may be overridden, place them in the signature.
This is suitable when the instrument has one type of interface, or any defaults are valid for all interface types, see the documentation in :py:class:`~pymeasure.adapters.VISAAdapter` for details.

.. code-block:: python

    def __init__(self, adapter, name="Extreme 5000", baud_rate=2400, **kwargs):
        super().__init__(
            adapter,
            name,
            baud_rate=baud_rate,
            **kwargs
        )

If you want to set defaults, but they don't need to be prominently exposed for replacement, use this pattern, which sets the value only when there is no entry in ``kwargs``, yet.

.. code-block:: python

    def __init__(self, adapter, name="Extreme 5000", **kwargs):
        kwargs.setdefault('timeout', 1500)
        super().__init__(
            adapter,
            name,
            **kwargs
        )

Multiple interfaces
*******************

Now, if you have instruments with multiple interfaces (e.g. serial, TCPI/IP, USB), things get interesting.
You might have settings common to all interfaces (like ``timeout``), but also settings that are only valid for one interface type, but not others.

The trick is to add keyword arguments that name the interface type, like ``asrl`` or ``gpib``, below (see `here <https://pyvisa.readthedocs.io/en/latest/api/constants.html#pyvisa.constants.InterfaceType>`__ for the full list).
These then contain a *dictionary* with the settings specific to the respective interface:

.. code-block:: python

    def __init__(self, adapter, name="Extreme 5000", baud_rate=2400, **kwargs):
        kwargs.setdefault('timeout', 1500)
        super().__init__(
            adapter,
            name,
            gpib=dict(enable_repeat_addressing=False,
                      read_termination='\r'),
            asrl={'baud_rate': baud_rate,
                  'read_termination': '\r\n'},
            **kwargs
        )

When the instrument instance is created, the interface-specific settings for the actual interface being used get merged with ``**kwargs`` before passing them on to PyVISA, the rest is discarded. 
This way, we always pass on a valid set of arguments.
In addition, any entries in ``**kwargs**`` take precedence, so if they need to, it is *still* possible for users to override any defaults you set in the instrument definition.

For many instruments, the simple way presented first is enough, but in case you have a more complex arrangement to implement, see whether :ref:`advanced_communication_protocols` fits your bill. If, for some exotic reason, you need a special connection type, which you cannot model with PyVISA, you can write your own Adapter.