Repository: pymeasure/pymeasure

Issue:
Thorlabs PM100 doesn't work with sensor head S130C
When using the Thorlabs PM100 with the measurement head S121C everything works as expected.

However, if a measurement head S130C is used instead, I get the following error message upon inialization of the ThorlabsPM100USB class:

```
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-15-b33dbcfde9d7> in <module>
----> 1 pm = ThorlabsPM100USB('USB0::0x1313::0x8078::P0032734::INSTR')

~\Miniconda3\envs\lab\lib\site-packages\pymeasure\instruments\thorlabs\thorlabspm100usb.py in __init__(self, adapter, **kwargs)
     49             adapter, "ThorlabsPM100USB powermeter", **kwargs)
     50         self.timout = 3000
---> 51         self.sensor()
     52
     53     def measure_power(self, wavelength):

~\Miniconda3\envs\lab\lib\site-packages\pymeasure\instruments\thorlabs\thorlabspm100usb.py in sensor(self)
     82         # setting the flags; _dn are empty
     83         self.is_power, self.is_energy, _d4, _d8, \
---> 84         self.resp_settable, self.wavelength_settable, self.tau_settable, _d128, self.temperature_sens = self._flags
     85
     86     @property

ValueError: not enough values to unpack (expected 9, got 6)
```

So the problem is in this line: https://github.com/pymeasure/pymeasure/blob/658d8fb9a02bdb62f64cc3838875c0de12f49ca1/pymeasure/instruments/thorlabs/thorlabspm100usb.py#L84

I have to admit that I don't really understand what exactly is happing in this specific code block. The two different measurement heads identify as follows  ("SYST:SENSOR:IDN?"):

```
'S130C,14102240,22-Oct-2014,1,3,33\n'
```

and 

```
'S121C,18030824,08-Mrz-2018,1,18,289\n'
```

respectively.


Discussion:
Thanks, great that you thought of providing these response strings. 

What seems to be going on is that the last portion of this ID string encodes "flags" for the capabilities of the sensor head.
`self._flags_str = response[-1][:-1]` reverses the last element, which (incidentally?) gets rid of the `\n` in there. 
the `bin(in(bla))` turns the reversed string into a binary sequence:
```
bin(int('289'))[2:]
Out[22]: '100100001'
bin(int('33'))[2:]
Out[23]: '100001'
```
Here you notice stuff begins to fall down, because as implemented the code that fails later expects 9 fields to be present, but only 6 are present in your sensor head.
(The tuple-map-lambda combination turns the bit sequence to booleans)

The way you can troubleshoot this is to consult the manual, and locate the procedure for converting this last part of the sensor ID into capability flags, ideally for all or at least the two sensor head models you have available.
Some of-the-cuff questions surrounding this:
What happens if older sensors don't even have some flags, or newer ones have a different format?
Can we just pad this bit sequence with `0`(in front or back?) for the flags not present? 
What is the "complete" set of flags?
Is there another way to query for the sensor head capabilities?
Why the need for reversing the string? 

Hopefully we can find a better and more resilient way of determining the flags, so all kinds of sensor heads work! The original author @dvspirito even hinted that there's probably a better way, but judging from his Github activity level I don't think they will be able to chime in here.

A quick patch, to have always 9 bits, could be to replace `bin` with `format`, something like:
`format(33, "09b")`
Sure, this also looks much more understandable than an int-bin-lambda-dance. We need to make sure that this fits the expected format, though.

Resolving patch:
diff --git a/pymeasure/instruments/thorlabs/thorlabspm100usb.py b/pymeasure/instruments/thorlabs/thorlabspm100usb.py
--- a/pymeasure/instruments/thorlabs/thorlabspm100usb.py
+++ b/pymeasure/instruments/thorlabs/thorlabspm100usb.py
@@ -23,74 +23,109 @@
 #
 
 import logging
+
+from pymeasure.instruments import Instrument
+from pymeasure.instruments.validators import truncated_range
+
 log = logging.getLogger(__name__)
 log.addHandler(logging.NullHandler())
 
-from pymeasure.instruments import Instrument, RangeException
-
 
 class ThorlabsPM100USB(Instrument):
-    """Represents Thorlabs PM100USB powermeter"""
-
-    # TODO: refactor to check if the sensor wavelength is adjustable
-    wavelength = Instrument.control("SENSE:CORR:WAV?", "SENSE:CORR:WAV %g",
-                                    "Wavelength in nm; not set outside of range")
-    
-    # TODO: refactor to check if the sensor is a power sensor
-    power = Instrument.measurement("MEAS:POW?", "Power, in Watts")
-    
-    wavelength_min = Instrument.measurement("SENS:CORR:WAV? MIN", "Get minimum wavelength, in nm")
-    
-    wavelength_max = Instrument.measurement("SENS:CORR:WAV? MAX", "Get maximum wavelength, in nm")
-
+    """Represents Thorlabs PM100USB powermeter."""
 
     def __init__(self, adapter, **kwargs):
         super(ThorlabsPM100USB, self).__init__(
-            adapter, "ThorlabsPM100USB powermeter", **kwargs)
+            adapter, "ThorlabsPM100USB powermeter", **kwargs
+        )
         self.timout = 3000
-        self.sensor()
-
-    def measure_power(self, wavelength):
-        """Set wavelength in nm and get power in W
-        If wavelength is out of range it will be set to range limit"""
-        if wavelength < self.wavelength_min:
-            raise RangeException("Wavelength %.2f nm out of range: using minimum wavelength: %.2f nm" % (
-                wavelength, self.wavelength_min))
-            # explicit setting wavelenghth, althought it would be automatically set
-            wavelength = self.wavelength_min
-        if wavelength > self.wavelength_max:
-            raise RangeException("Wavelength %.2f nm out of range: using maximum wavelength: %.2f nm" % (
-                wavelength, self.wavelength_max))
-            wavelength = self.wavelength_max
-        self.wavelength = wavelength
-        return self.power
-
-    def sensor(self):
-        "Get sensor info"
-        response = self.ask("SYST:SENSOR:IDN?").split(',')
+        self._set_flags()
+
+    wavelength_min = Instrument.measurement(
+        "SENS:CORR:WAV? MIN", "Minimum wavelength, in nm"
+    )
+
+    wavelength_max = Instrument.measurement(
+        "SENS:CORR:WAV? MAX", "Maximum wavelength, in nm"
+    )
+
+    @property
+    def wavelength(self):
+        """Wavelength in nm."""
+        value = self.values("SENSE:CORR:WAV?")[0]
+        return value
+
+    @wavelength.setter
+    def wavelength(self, value):
+        """Wavelength in nm."""
+        if self.wavelength_settable:
+            # Store min and max wavelength to only request them once.
+            if not hasattr(self, "_wavelength_min"):
+                self._wavelength_min = self.wavelength_min
+            if not hasattr(self, "_wavelength_max"):
+                self._wavelength_max = self.wavelength_max
+
+            value = truncated_range(
+                value, [self._wavelength_min, self._wavelength_max]
+            )
+            self.write("SENSE:CORR:WAV {}".format(value))
+        else:
+            raise AttributeError(
+                f"{self.sensor_name} does not allow setting the wavelength."
+            )
+
+    @property
+    def power(self):
+        """Power in W."""
+        if self.is_power_sensor:
+            return self.values("MEAS:POW?")[0]
+        else:
+            raise AttributeError(f"{self.sensor_name} is not a power sensor.")
+
+    @property
+    def energy(self):
+        """Energy in J."""
+        if self.is_energy_sensor:
+            return self.values("MEAS:ENER?")[0]
+        else:
+            raise AttributeError(
+                f"{self.sensor_name} is not an energy sensor."
+            )
+
+    def _set_flags(self):
+        """Get sensor info and write flags."""
+        response = self.values("SYST:SENSOR:IDN?")
+        if response[0] == "no sensor":
+            raise IOError("No sensor connected.")
         self.sensor_name = response[0]
         self.sensor_sn = response[1]
         self.sensor_cal_msg = response[2]
         self.sensor_type = response[3]
         self.sensor_subtype = response[4]
-        self._flags_str = response[-1][:-1]
-        # interpretation of the flags
-        # rough trick using bin repr, maybe something more elegant exixts
-        # (bitshift, bitarray?)
-        self._flags = tuple(
-            map(lambda x: x == '1', bin(int(self._flags_str))[2:]))
-        # setting the flags; _dn are empty
-        self.is_power, self.is_energy, _d4, _d8, \
-        self.resp_settable, self.wavelength_settable, self.tau_settable, _d128, self.temperature_sens = self._flags
+        _flags_str = response[5]
 
-    @property
-    def energy(self):
-        if self.is_energy:
-            return self.values("MEAS:ENER?")
-        else:
-            raise Exception("%s is not an energy sensor" % self.sensor_name)
-            return 0
+        # interpretation of the flags, see p. 49 of the manual:
+        # https://www.thorlabs.de/_sd.cfm?fileName=17654-D02.pdf&partNumber=PM100D
+
+        # Convert to binary representation and pad zeros to 9 bit for sensors
+        # where not all flags are present.
+        _flags_str = format(int(_flags_str), "09b")
+        # Reverse the order so it matches the flag order from the manual, i.e.
+        # from decimal values from 1 to 256.
+        _flags_str = _flags_str[::-1]
+
+        # Convert to boolean.
+        self.flags = [x == "1" for x in _flags_str]
 
-    @energy.setter
-    def energy(self, val):
-        raise Exception("Energy not settable!")
+        # setting the flags; _dn are unused; decimal values as comments
+        (
+            self.is_power_sensor,  # 1
+            self.is_energy_sensor,  # 2
+            _d4,  # 4
+            _d8,  # 8
+            self.response_settable,  # 16
+            self.wavelength_settable,  # 32
+            self.tau_settable,  # 64
+            _d128,  # 128
+            self.has_temperature_sensor,  # 256
+        ) = self.flags


Test patch: