Repository: pymeasure/pymeasure

Issue:
Customizable error query as instrument constructor argument
This PR adds the option to specify a custom command for querying errors.
For example, the HP 54540A needs the command `SYST:ERR? STR` instead of just `SYST:ERR?` to return the error number and error description.
Since the `check_errors()` method contains quite a bit of logic, I would argue that it is cleaner to specify just the command instead of copying the whole method to a subclass with just the command changed.


Discussion:
I think this PR could address several cases similar to this. I recently had same problem with Anritsu MS2668C. I also think that same problem could happen for other commands (e.g. reset, etc), so I would evaluate to structure the parameter in a way similar to the one proposed by @bilderbuchi in https://github.com/wnluke/pymeasure/blob/f03315b3ae9149370ac4f6b00652e08ce8656add/pymeasure/adapters/visa.py#L53
Huh, I was under the impression that the command format is prescribed/defined in the SCPI standard. Anyway, good idea to expose just the error string, that indeed makes implementing deviating instruments cleaner. 👍 

However, this error string is always the same for a given instrument model. Therefore, I argue that is should not be in the constructor signature - after all, when would a user instantiating an instrument ever pass that argument?

Instead, what do you think about a private attribute `Instrument._error_query`, that is set to the default value in `Instrument` as a class attribute. (private because it will no be useful to a user of the class)
Then, any derived instrument needing a different value just needs to set the desired value (as a class attribute or in the ctor, does not matter).
Thank you both for your suggestions. I have now implemented it as a class attribute as suggested by @bilderbuchi.
@msmttchr did you have a problem with the error query or with another IEEE 488 root command? As of now, I haven't made the other root commands customizable because
1. the methods don't contain any logic and can be implemented in a subclass without much code duplication
2. I think it's very unlikely that these root commands are different for an instrument supporting SCPI
> * the methods don't contain any logic and can be implemented in a subclass without much code duplication

I think this make sense and also the comment of @bilderbuchi about standard compliance is very relevant. so I think this instrument is not following the rules.
I still see the change specific to `check_errors()` method still very specific, but I understand the logic.
Another option could be to implement an `error` method command (in a similar fashion of `reset` or `clear`, etc.), have the `check_errors` using `error` method. In such way, it is much more obvious to users how to customize the `error` command by reimplementing the `error` method.

This could have also the advantage that, if we decide to go down the road with an `SCPIInstrument `class using dynamic properties, changes will be easier.

Now `next_error()` is a pretty lean method which can easily be overridden. It's a good solution for my problem. Thanks @msmttchr for the suggestion.
As a use case, I can imagine a situation where the instrument return an
error after a specific command and may be you need to implement some
recovery action.
So in this case, you don't want to log this error condition, but just check
if a specific error condition happen, so assuming your error queue is
empty, you just want to read next error.

Really my point is from a consistency point of view, SCPI defines this
logic based on error queue, so next_error offer access to the related SCPI
command, and this would be consistent to other defined SCPI commands.

On top of this basic command, check_errors is a utility which allow to read
and log instrument errors presumably, but not necessarily, at the end of an
experiment and return them as a list.

Il Ven 11 Feb 2022, 14:34 Christoph Buchner ***@***.***> ha
scritto:

> ***@***.**** commented on this pull request.
> ------------------------------
>
> In pymeasure/instruments/instrument.py
> <https://github.com/pymeasure/pymeasure/pull/587#discussion_r804653046>:
>
> > +    def next_error(self):
> +        """ Read the next error from the instrument.
> +
> +        :return: error entry
> +        """
>
> In that case, what is the usecase for "just needing the next error"?
> Differently put: in which situation will it be useful to *only* fetch one
> error, without then immediately fetching the next error to make sure no
> other errors remain? In these situations, why would you *not* use
> check_errors?
>
> I guess I don't get why we need an additional similar, but separate,
> method, where we now have to make sure that users and/or instrument
> implementors choose the *correct* one to use and/or override,
> respectively. I have the feeling that a private class attribute would be
> clearer in purpose. But maybe this usecase you refer to can clear things up?
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/pymeasure/pymeasure/pull/587#discussion_r804653046>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AJAXVWX4TM7YV7XHFXSPSXLU2UF67ANCNFSM5NV3RUJQ>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>

This, in my opinion a strong and valid argument, which I share.

In the specific case, the next error is a standard command, so I would not
consider it an exhotic addition, but just a way to keep consistency with
what it is already implemented.

Il Ven 11 Feb 2022, 14:39 Christoph Buchner ***@***.***> ha
scritto:

> ***@***.**** commented on this pull request.
> ------------------------------
>
> In pymeasure/instruments/instrument.py
> <https://github.com/pymeasure/pymeasure/pull/587#discussion_r804657524>:
>
> > +    def next_error(self):
> +        """ Read the next error from the instrument.
> +
> +        :return: error entry
> +        """
>
> To provide context: I want to make sure we don't add public API to *all*
> our instruments without a clear use case for it. I have seen in other code
> bases how messy an unchecked proliferation of public API in an inheritance
> tree can become, and I *very much* want to avoid that here.
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/pymeasure/pymeasure/pull/587#discussion_r804657524>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AJAXVWX46G3DQIXSMQBCXWDU2UGQPANCNFSM5NV3RUJQ>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>

OK, then, you're more familiar with SCPI standard-related things, so I'll defer to your judgement -- i ou still think it's a good idea we can go ahead with `next_error()`. 
However, the point about converting currently customized methods to the "new way" still stands before we merge this.

Resolving patch:
diff --git a/pymeasure/instruments/__init__.py b/pymeasure/instruments/__init__.py
--- a/pymeasure/instruments/__init__.py
+++ b/pymeasure/instruments/__init__.py
@@ -25,3 +25,4 @@
 from .channel import Channel
 from .instrument import Instrument
 from .resources import find_serial_port, list_resources
+from .generic_types import SCPIMixin, SCPIUnknownMixin
diff --git a/pymeasure/instruments/generic_types.py b/pymeasure/instruments/generic_types.py
new file mode 100644
--- /dev/null
+++ b/pymeasure/instruments/generic_types.py
@@ -0,0 +1,111 @@
+#
+# 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.
+#
+
+import logging
+from warnings import warn
+
+from .instrument import Instrument
+
+log = logging.getLogger(__name__)
+log.addHandler(logging.NullHandler())
+
+
+class SCPIMixin:
+    """Mixin class for SCPI instruments with the default implementation of base SCPI commands."""
+
+    def __init__(self, *args, **kwargs):
+        kwargs.setdefault("includeSCPI", False)  # in order not to trigger the deprecation warning
+        super().__init__(*args, **kwargs)
+
+    # SCPI default properties
+    complete = Instrument.measurement(
+        "*OPC?",
+        """Get the synchronization bit.
+
+        This property allows synchronization between a controller and a device. The Operation
+        Complete query places an ASCII character 1 into the device's Output Queue when all pending
+        selected device operations have been finished.
+        """,
+        cast=str,
+    )
+
+    status = Instrument.measurement(
+        "*STB?",
+        """Get the status byte and Master Summary Status bit.""",
+        cast=str,
+    )
+
+    options = Instrument.measurement(
+        "*OPT?",
+        """Get the device options installed.""",
+        cast=str,
+    )
+
+    id = Instrument.measurement(
+        "*IDN?",
+        """Get the identification of the instrument.""",
+        cast=str,
+    )
+
+    next_error = Instrument.measurement(
+        "SYST:ERR?",
+        """Get the next error in the queue.
+        If you want to read and log all errors, use :meth:`check_errors` instead.
+        """,
+    )
+
+    # SCPI default methods
+    def clear(self):
+        """Clear the instrument status byte."""
+        self.write("*CLS")
+
+    def reset(self):
+        """Reset the instrument."""
+        self.write("*RST")
+
+    def check_errors(self):
+        """ Read all errors from the instrument.
+
+        :return: List of error entries.
+        """
+        errors = []
+        while True:
+            err = self.next_error
+            if int(err[0]) != 0:
+                log.error(f"{self.name}: {err[0]}, {err[1]}")
+                errors.append(err)
+            else:
+                break
+        return errors
+
+
+class SCPIUnknownMixin(SCPIMixin):
+    """Mixin which adds SCPI commands to an instrument from which it is not known, whether it
+    supports SCPI commands or not.
+    """
+
+    def __init__(self, *args, **kwargs):
+        warn("It is not known, whether this device support SCPI commands or not. Please inform "
+             "the pymeasure maintainers, if you know the answer.", FutureWarning)
+        super().__init__(*args, **kwargs)
diff --git a/pymeasure/instruments/instrument.py b/pymeasure/instruments/instrument.py
--- a/pymeasure/instruments/instrument.py
+++ b/pymeasure/instruments/instrument.py
@@ -60,6 +60,11 @@ class Instrument(CommonBase):
     :param adapter: A string, integer, or :py:class:`~pymeasure.adapters.Adapter` subclass object
     :param string name: The name of the instrument. Often the model designation by default.
     :param includeSCPI: An obligatory boolean, which toggles the inclusion of standard SCPI commands
+
+        .. deprecated:: 0.14
+            If True, inherit the :class:`~pymeasure.instruments.generic_types.SCPIMixin` class
+            instead.
+
     :param preprocess_reply: An optional callable used to preprocess
         strings received from the instrument. The callable returns the
         processed string.
@@ -83,9 +88,12 @@ def __init__(self, adapter, name, includeSCPI=None,
                 raise Exception("Invalid Adapter provided for Instrument since"
                                 " PyVISA is not present")
         self.adapter = adapter
-        if includeSCPI is None:
-            warn("It is deprecated to specify `includeSCPI` implicitly, declare it explicitly.",
-                 FutureWarning)
+        if includeSCPI is True:
+            warn("Defining SCPI base functionality with `includeSCPI=True` is deprecated, inherit "
+                 "the `SCPIMixin` class instead.", FutureWarning)
+        elif includeSCPI is None:
+            warn("It is deprecated to specify `includeSCPI` implicitly, use "
+                 "`includeSCPI=False` or inherit the `SCPIMixin` class instead.", FutureWarning)
             includeSCPI = True
         self.SCPI = includeSCPI
         self.isShutdown = False


Test patch:
diff --git a/tests/instruments/test_all_instruments.py b/tests/instruments/test_all_instruments.py
--- a/tests/instruments/test_all_instruments.py
+++ b/tests/instruments/test_all_instruments.py
@@ -29,7 +29,7 @@
 import pytest
 
 from pymeasure import instruments
-from pymeasure.instruments import Channel, Instrument
+from pymeasure.instruments import Instrument, Channel, generic_types
 
 
 # Collect all instruments
@@ -82,6 +82,16 @@ def find_devices_in_module(module):
         prop = getattr(device, property_name)
         if isinstance(prop, property):
             properties.append((device, property_name, prop))
+for mixin in dir(generic_types):
+    if mixin in ("Instrument", "Channel", "CommonBase"):  # exclucion list.
+        continue
+    elif mixin[0].isupper():
+        # filter only classes
+        device = getattr(generic_types, mixin)
+        for property_name in dir(device):
+            prop = getattr(device, property_name)
+            if isinstance(prop, property):
+                properties.append((device, property_name, prop))
 
 # Instruments unable to accept an Adapter instance.
 proper_adapters = []
diff --git a/tests/instruments/test_generic_types.py b/tests/instruments/test_generic_types.py
new file mode 100644
--- /dev/null
+++ b/tests/instruments/test_generic_types.py
@@ -0,0 +1,89 @@
+#
+# 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.
+#
+
+import pytest
+
+from pymeasure.test import expected_protocol
+from pymeasure.adapters import ProtocolAdapter
+from pymeasure.instruments.generic_types import SCPIMixin, SCPIUnknownMixin
+from pymeasure.instruments import Instrument
+
+
+class Test_SCPIMixin:
+    class SCPIInstrument(SCPIMixin, Instrument):
+        pass
+
+    def test_init(self):
+        inst = self.SCPIInstrument(ProtocolAdapter(), "test")
+        assert inst.SCPI is False  # should not be set by the new init
+
+    @pytest.mark.parametrize("method, write, reply", (
+        ("id", "*IDN?", "xyz"),
+        ("complete", "*OPC?", "1"),
+        ("status", "*STB?", "189"),
+        ("options", "*OPT?", "a9"),
+    ))
+    def test_SCPI_properties(self, method, write, reply):
+        with expected_protocol(
+                self.SCPIInstrument,
+                [(write, reply)],
+                name="test") as inst:
+            assert getattr(inst, method) == reply
+
+    def test_next_error(self):
+        with expected_protocol(
+                self.SCPIInstrument,
+                [("SYST:ERR?", '-100,"Command error"')],
+                name="test") as inst:
+            assert inst.next_error == [-100, '"Command error"']
+
+    @pytest.mark.parametrize("method, write", (("clear", "*CLS"),
+                                               ("reset", "*RST"),
+                                               ))
+    def test_SCPI_write_commands(self, method, write):
+        with expected_protocol(
+                self.SCPIInstrument,
+                [(write, None)],
+                name="test") as inst:
+            getattr(inst, method)()
+
+    def test_check_errors(self):
+        with expected_protocol(
+                self.SCPIInstrument,
+                [("SYST:ERR?", '-100,"Command error"'),
+                 ("SYST:ERR?", '-222,"Data out of range"'),
+                 ("SYST:ERR?", '0,"No error"'),
+                 ],
+                name="test") as inst:
+            assert inst.check_errors() == [[-100, '"Command error"'],
+                                           [-222, '"Data out of range"']]
+
+
+def test_SCPIunknownMixin():
+    class SCPIunknownInstrument(SCPIUnknownMixin, Instrument):
+        pass
+
+    with pytest.warns(FutureWarning):
+        inst = SCPIunknownInstrument(ProtocolAdapter(), "test")
+    assert inst.SCPI is False
diff --git a/tests/instruments/test_instrument.py b/tests/instruments/test_instrument.py
--- a/tests/instruments/test_instrument.py
+++ b/tests/instruments/test_instrument.py
@@ -109,7 +109,8 @@ def test_not_defined_includeSCPI_raises_warning(self):
         with pytest.warns(FutureWarning) as record:
             Instrument(name="test", adapter=ProtocolAdapter())
         msg = str(record[0].message)
-        assert msg == "It is deprecated to specify `includeSCPI` implicitly, declare it explicitly."
+        assert msg == ("It is deprecated to specify `includeSCPI` implicitly, use "
+                       "`includeSCPI=False` or inherit the `SCPIMixin` class instead.")
 
     def test_not_defined_includeSCPI_is_interpreted_as_true(self):
         inst = Instrument(name="test", adapter=ProtocolAdapter())
@@ -128,6 +129,16 @@ def test_init_visa_fail():
         Instrument("abc", "def", visa_library="@xyz")
 
 
+def test_init_includeSCPI_implicit_warning():
+    with pytest.warns(FutureWarning, match="includeSCPI"):
+        Instrument("COM1", "def", visa_library="@sim")
+
+
+def test_init_includeSCPI_explicit_warning():
+    with pytest.warns(FutureWarning, match="includeSCPI"):
+        Instrument("COM1", "def", visa_library="@sim", includeSCPI=True)
+
+
 def test_global_preprocess_reply():
     with pytest.warns(FutureWarning, match="deprecated"):
         inst = Instrument(FakeAdapter(), "name", preprocess_reply=lambda v: v.strip("x"))