Repository: pymeasure/pymeasure

Issue:
Add instrument for AimTTi LD400P
We have a LD400P that we'd like to automate. (See https://www.aimtti.com/product-category/electronic-loads/aim-ld400series.)

I'll be making a PR next, I figured I'd create this issue as a placeholder.

Hints from issue comments:

Solution patch:
diff --git a/docs/api/instruments/aimtti/index.rst b/docs/api/instruments/aimtti/index.rst
index 53505e220a..b6f6d39f96 100644
--- a/docs/api/instruments/aimtti/index.rst
+++ b/docs/api/instruments/aimtti/index.rst
@@ -10,3 +10,4 @@ This section contains specific documentation on the Aim-TTI instruments that are
    :maxdepth: 2
 
    aimttiPL
+   ld400p
diff --git a/docs/api/instruments/aimtti/ld400p.rst b/docs/api/instruments/aimtti/ld400p.rst
new file mode 100644
index 0000000000..956694f0b9
--- /dev/null
+++ b/docs/api/instruments/aimtti/ld400p.rst
@@ -0,0 +1,7 @@
+########################
+Aim-TTi Electronic Loads
+########################
+
+.. autoclass:: pymeasure.instruments.aimtti.ld400p.LD400P
+    :members:
+    :show-inheritance:
diff --git a/pymeasure/instruments/aimtti/__init__.py b/pymeasure/instruments/aimtti/__init__.py
index f7831761e6..a920f5840b 100644
--- a/pymeasure/instruments/aimtti/__init__.py
+++ b/pymeasure/instruments/aimtti/__init__.py
@@ -23,3 +23,4 @@
 #
 
 from .aimttiPL import PL068P, PL155P, PL303P, PL601P, PL303QMDP, PL303QMTP
+from .ld400p import LD400P
diff --git a/pymeasure/instruments/aimtti/ld400p.py b/pymeasure/instruments/aimtti/ld400p.py
new file mode 100644
index 0000000000..9e191c6a3e
--- /dev/null
+++ b/pymeasure/instruments/aimtti/ld400p.py
@@ -0,0 +1,138 @@
+#
+# 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
+from pymeasure.instruments.generic_types import SCPIMixin
+from pymeasure.instruments.validators import strict_discrete_set
+
+
+class LD400P(SCPIMixin, Instrument):
+    """Interface for the LD400P electronic DC load from Aim-TTi.
+
+    Note: the LD400 edition doesn't have digital interfacing, only the LD400P does.
+
+    The interface is described by the LD400[P] `manual`_.
+
+    The class :class:`~SCPIMixin` is inherited, however the commands `options`,
+    next_error` and `check_errors` do not work for this device.
+
+    .. _manual: https://resources.aimtti.com/manuals/
+                LD400+LD400P_Instruction_Manual_EN_48511-1730_8.pdf
+    """
+
+    unit_suffixes = ["A", "V", "W", "OHM", "SIE", "HZ"]
+
+    def __init__(self, adapter, name="LD400", **kwargs):
+        super().__init__(adapter, name, **kwargs)
+
+    @classmethod
+    def remove_unit_suffix(cls, msg: str) -> str:
+        """Remove known unit suffixes from a msg.
+
+        The units of some replies depend on the mode of the device, hence all known
+        units are removed.
+        """
+        for suffix in cls.unit_suffixes:
+            msg = msg.replace(suffix, "")
+        return msg
+
+    mode = Instrument.control(
+        "MODE?",
+        "MODE %s",
+        """Control the mode of the digital load. Can be one of:
+        ``("C", "P", "R", "G", "V")``, corresponding to (respectively) constant current,
+        power, resistance, conductance, or voltage (string).
+        """,
+        validator=strict_discrete_set,
+        values=["C", "P", "R", "G", "V"],
+        get_process=lambda v: v.replace("MODE ", ""),
+    )
+
+    level_select = Instrument.control(
+        "LVLSEL?",
+        "LVLSEL %s",
+        """Control the selected level of the digital load. Can be one of:
+        ``("A", "B", "T", "V", "E")``, corresponding to (respectively) Level A, Level B,
+        Transient, Ext Voltage and Ext TTL (string).
+        """,
+        validator=strict_discrete_set,
+        values=["A", "B", "T", "V", "E"],
+        get_process=lambda v: v.replace("LVLSEL ", ""),
+    )
+
+    level_a = Instrument.control(
+        "A?",
+        "A %g",
+        """Control the A level of the current control mode (float).""",
+        preprocess_reply=lambda v: LD400P.remove_unit_suffix(v.replace("A", "")),
+    )
+
+    level_b = Instrument.control(
+        "B?",
+        "B %g",
+        """Control the B level of the current control mode (float).""",
+        preprocess_reply=lambda v: LD400P.remove_unit_suffix(v.replace("B", "")),
+    )
+
+    input_enabled = Instrument.control(
+        "INP?",
+        "INP %d",
+        """Control the input state of the digital load, can be ``True`` (on) or
+        ``False`` (off).
+        """,
+        validator=strict_discrete_set,
+        values={True: 1, False: 0},
+        map_values=True,
+        preprocess_reply=lambda v: v.replace("INP ", ""),
+    )
+
+    # Voltage / current replies are _not_ prefixed by the command, but do have a unit:
+
+    voltage = Instrument.measurement(
+        "V?",
+        """Get the measured source input voltage (float).""",
+        preprocess_reply=lambda v: v.replace("V", ""),
+    )
+
+    current = Instrument.measurement(
+        "I?",
+        """Get the measured load current (float).""",
+        preprocess_reply=lambda v: v.replace("A", ""),
+    )
+
+    # Remove SCP properties that don't work on this device:
+
+    @property
+    def options(self):
+        """Get options - Not available."""
+        raise AttributeError("No attribute 'options' exists")
+
+    @property
+    def next_error(self):
+        """Get next error - Not available."""
+        raise AttributeError("No attribute 'next_error' exists")
+
+    def check_errors(self):
+        """Not available."""
+        raise NotImplementedError("This method is not available on this device")


Test patch:
diff --git a/tests/instruments/aimtti/test_ld400p.py b/tests/instruments/aimtti/test_ld400p.py
new file mode 100644
index 0000000000..6cfcacb467
--- /dev/null
+++ b/tests/instruments/aimtti/test_ld400p.py
@@ -0,0 +1,124 @@
+#
+# 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.instruments.aimtti.ld400p import LD400P
+
+
+def test_mode():
+    with expected_protocol(
+        LD400P,
+        [
+            ("MODE P", None),
+            ("MODE?", "MODE P"),
+        ],
+    ) as inst:
+        inst.mode = "P"
+        assert inst.mode == "P"
+
+
+def test_mode_write_invalid():
+    with expected_protocol(LD400P, []) as inst:
+        with pytest.raises(ValueError):
+            inst.mode = "X"
+
+
+def test_level_select():
+    with expected_protocol(
+        LD400P,
+        [
+            ("LVLSEL V", None),
+            ("LVLSEL?", "LVLSEL V"),
+        ],
+    ) as inst:
+        inst.level_select = "V"
+        assert inst.level_select == "V"
+
+
+def test_level_a():
+    with expected_protocol(
+        LD400P,
+        [
+            ("A 10.5", None),
+            ("A?", "A 10.5W"),
+        ],
+    ) as inst:
+        inst.level_a = 10.5
+        assert inst.level_a == 10.5
+
+
+def test_level_b():
+    with expected_protocol(
+        LD400P,
+        [
+            ("B 10.5", None),
+            ("B?", "B 10.5W"),
+        ],
+    ) as inst:
+        inst.level_b = 10.5
+        assert inst.level_b == 10.5
+
+
+def test_input_enabled():
+    with expected_protocol(
+        LD400P,
+        [
+            ("INP 1", None),
+            ("INP?", "INP 1"),
+        ],
+    ) as inst:
+        inst.input_enabled = True
+        enabled = inst.input_enabled
+        assert enabled and isinstance(enabled, bool)
+
+
+def test_voltage():
+    with expected_protocol(
+        LD400P,
+        [
+            ("V?", "3.14V"),
+        ],
+    ) as inst:
+        assert inst.voltage == 3.14
+
+
+def test_current():
+    with expected_protocol(
+        LD400P,
+        [
+            ("I?", "3.14A"),
+        ],
+    ) as inst:
+        assert inst.current == 3.14
+
+
+def test_options():
+    with expected_protocol(
+        LD400P,
+        [],
+    ) as inst:
+        with pytest.raises(AttributeError):
+            _ = inst.options
diff --git a/tests/instruments/aimtti/test_ld400p_with_device.py b/tests/instruments/aimtti/test_ld400p_with_device.py
new file mode 100644
index 0000000000..e850c0b2e0
--- /dev/null
+++ b/tests/instruments/aimtti/test_ld400p_with_device.py
@@ -0,0 +1,61 @@
+#
+# 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
+import time
+
+from pymeasure.instruments.aimtti import LD400P
+
+
+@pytest.fixture(scope="module")
+def instrument(connected_device_address):
+    instr = LD400P(connected_device_address)
+    instr.reset()
+    return instr
+
+
+def test_load(instrument):
+    """
+    Connect the load to a power supply that delivers at least 1V and can supply 200mA
+    Then run tests.
+    """
+    assert 0.9 <= instrument.voltage
+
+    instrument.mode = "C"
+    instrument.level_a = 0.2
+    instrument.level_b = 0.1
+    instrument.level_select = "A"
+
+    time.sleep(0.1)
+
+    assert instrument.mode == "C"
+    assert instrument.level_select == "A"
+
+    instrument.input = "On"
+    time.sleep(2)
+    assert pytest.approx(instrument.current, 0.01) == 0.2
+
+    instrument.level_select = "B"
+    time.sleep(2)
+    assert pytest.approx(instrument.current, 0.01) == 0.1