diff --git a/lakeshore/em_power_supply.py b/lakeshore/em_power_supply.py index 56e167f..1fe2d78 100644 --- a/lakeshore/em_power_supply.py +++ b/lakeshore/em_power_supply.py @@ -1,13 +1,37 @@ """Implements functionality unique to the Model 643 and 648 electromagnet power supplies.""" +import math + import serial from .generic_instrument import GenericInstrument, RegisterBase, _parse_response, InstrumentException +def _validate_number(value, name): + """Validate that a value is a finite number (rejects bool, NaN, and inf). + + Args: + value: The value to validate. + name (str): Parameter name for error messages. + + Raises: + ValueError: If the value is not a finite number. + """ + if isinstance(value, bool): + raise ValueError(f"{name} must be a number, got bool") + if not isinstance(value, (int, float)): + raise ValueError(f"{name} must be a number, got {type(value).__name__}") + if not math.isfinite(value): + raise ValueError(f"{name} must be finite, got {value}") + + class ElectromagnetPowerSupply(GenericInstrument): """Class object representing a Lake Shore Model 643 or 648 electromagnet power supply.""" vid_pid = [(0x1FB9, 0x0601), (0x1FB9, 0x0602)] # 643, 648 + _MAX_CURRENT = 135.1 # Model 648 maximum; Model 643 is 70.1 A + _MIN_RAMP_RATE = 0.0001 # A/s + _MAX_RAMP_RATE = 50.0 # A/s + def __init__(self, serial_number=None, com_port=None, @@ -251,7 +275,19 @@ def set_limits(self, max_current, max_ramp_rate): A. The Model 648 bounds are 0.0000 - 135.1000 A. max_ramp_rate (float): The maximum output current ramp rate setting allowed (0.0001 - 50.000 A/s). - """ + + Raises: + ValueError: If max_current or max_ramp_rate is not a finite number or out of range. + """ + _validate_number(max_current, "Max current") + _validate_number(max_ramp_rate, "Max ramp rate") + if max_current < 0 or max_current > self._MAX_CURRENT: + raise ValueError( + f"Max current {max_current} A is outside valid range (0 - {self._MAX_CURRENT} A)") + if max_ramp_rate < self._MIN_RAMP_RATE or max_ramp_rate > self._MAX_RAMP_RATE: + raise ValueError( + f"Max ramp rate {max_ramp_rate} A/s is outside valid range " + f"({self._MIN_RAMP_RATE} - {self._MAX_RAMP_RATE} A/s)") self.command(f"LIMIT {max_current}, {max_ramp_rate}") def get_limits(self): @@ -273,7 +309,15 @@ def set_ramp_rate(self, ramp_rate): Args: ramp_rate (float): The rate at which the current will ramp when a new output current setting is entered (0.0001 - 50.000 A/s). + + Raises: + ValueError: If ramp_rate is not a finite number or out of range. """ + _validate_number(ramp_rate, "Ramp rate") + if ramp_rate < self._MIN_RAMP_RATE or ramp_rate > self._MAX_RAMP_RATE: + raise ValueError( + f"Ramp rate {ramp_rate} A/s is outside valid range " + f"({self._MIN_RAMP_RATE} - {self._MAX_RAMP_RATE} A/s)") self.command(f"RATE {ramp_rate}") def get_ramp_rate(self): @@ -294,7 +338,20 @@ def set_ramp_segment(self, segment, current, ramp_rate): current (float): Specifies the upper output current setting that will use this segment. ramp_rate (float): Specifies the rate at which the current will ramp. (0.0001 - 50.000 A/s). - """ + Raises: + ValueError: If segment is not 1-5, or current/ramp_rate is not a finite number or out of range. + """ + if segment not in range(1, 6): + raise ValueError(f"Segment must be 1-5, got {segment}") + _validate_number(current, "Current") + _validate_number(ramp_rate, "Ramp rate") + if abs(current) > self._MAX_CURRENT: + raise ValueError( + f"Current magnitude {abs(current)} A exceeds maximum safe value of {self._MAX_CURRENT} A") + if ramp_rate < self._MIN_RAMP_RATE or ramp_rate > self._MAX_RAMP_RATE: + raise ValueError( + f"Ramp rate {ramp_rate} A/s is outside valid range " + f"({self._MIN_RAMP_RATE} - {self._MAX_RAMP_RATE} A/s)") self.command(f"RSEGS {segment}, {current}, {ramp_rate}") def get_ramp_segment(self, segment): @@ -315,7 +372,12 @@ def set_ramp_segments_enable(self, state): Args: state (bool): The state of the ramp segments enable. 0=Disabled and 1=Enabled. + + Raises: + ValueError: If state is not a bool or 0/1. """ + if state not in (True, False, 0, 1): + raise ValueError(f"Ramp segments enable must be True/False or 0/1, got {state}") self.command(f"RSEG {int(state)}") def get_ramp_segments_enable(self): @@ -336,7 +398,14 @@ def set_current(self, current): Args: current (float): The output current value that the output will ramp to at the present ramp rate. The Model 643 bounds are 0.0000 - +/-70.1000 A. The Model 648 bounds are 0.0000 - +/-135.1000 A. + + Raises: + ValueError: If current is not a finite number or exceeds safe bounds. """ + _validate_number(current, "Current") + if abs(current) > self._MAX_CURRENT: + raise ValueError( + f"Current magnitude {abs(current)} A exceeds maximum safe value of {self._MAX_CURRENT} A") self.command(f"SETI {current}") def get_current(self): @@ -376,7 +445,12 @@ def set_internal_water(self, mode): Args: mode (int): Internal water mode (0, 1, 2, or 3). 0 = Manual-Off, 1 = Manual-On, 2 = Auto, 3 = Disabled. + + Raises: + ValueError: If mode is not 0, 1, 2, or 3. """ + if mode not in (0, 1, 2, 3): + raise ValueError(f"Internal water mode must be 0, 1, 2, or 3, got {mode}") self.command(f"INTWTR {mode}") def get_internal_water(self) : @@ -393,7 +467,12 @@ def set_magnet_water(self, mode): Args: mode (int): Magnet water mode. (0, 1, 2, or 3). 0 = Manual-Off, 1 = Manual-On, 2 = Auto, 3 = Disabled. + + Raises: + ValueError: If mode is not 0, 1, 2, or 3. """ + if mode not in (0, 1, 2, 3): + raise ValueError(f"Magnet water mode must be 0, 1, 2, or 3, got {mode}") self.command(f"MAGWTR {mode}") def get_magnet_water(self) : @@ -409,7 +488,12 @@ def set_display_brightness(self, brightness_level): Args: brightness_level (int): The display brightness. 0=25%, 1=50%, 2=75%, 3=100%. + + Raises: + ValueError: If brightness_level is not 0, 1, 2, or 3. """ + if brightness_level not in (0, 1, 2, 3): + raise ValueError(f"Brightness level must be 0, 1, 2, or 3, got {brightness_level}") self.command(f"DISP {brightness_level}") def get_display_brightness(self) : @@ -426,7 +510,14 @@ def set_front_panel_lock(self, lock_state, code): Args: lock_state (int): The lock state to be set (0, 1, or 2). 0=unlock, 1=lock, and 2=lock limits. code (int): Keypad lock code required to make changes to the lock state of the front panel. + + Raises: + ValueError: If lock_state is not 0, 1, or 2, or code is not an integer. """ + if lock_state not in (0, 1, 2): + raise ValueError(f"Lock state must be 0, 1, or 2, got {lock_state}") + if not isinstance(code, int) or isinstance(code, bool): + raise ValueError(f"Lock code must be an integer, got {type(code).__name__}") self.command(f"LOCK {lock_state},{code}") def get_front_panel_status(self): @@ -450,7 +541,12 @@ def set_programming_mode(self, mode): Args: mode (int): Programming mode (0, 1, or 2). 0=Internal, 1=External, 2=Sum. + + Raises: + ValueError: If mode is not 0, 1, or 2. """ + if mode not in (0, 1, 2): + raise ValueError(f"Programming mode must be 0, 1, or 2, got {mode}") self.command(f"XPGM {mode}") def get_programming_mode(self): @@ -468,7 +564,16 @@ def set_ieee_488(self, terminator, eoi_enable, address): terminator(int): the terminator. 0=, 1=, 2=, 3=no terminator (must have EOI enabled). eoi_enable(int): Sets EOI (End of Interrupt) mode. 0=Enabled, 1=Disabled. address (int): Specifies IEEE address. 1 - 30(0 and 31 are reserved). + + Raises: + ValueError: If any parameter is out of its valid range. """ + if terminator not in (0, 1, 2, 3): + raise ValueError(f"Terminator must be 0, 1, 2, or 3, got {terminator}") + if eoi_enable not in (0, 1): + raise ValueError(f"EOI enable must be 0 or 1, got {eoi_enable}") + if not isinstance(address, int) or isinstance(address, bool) or address < 1 or address > 30: + raise ValueError(f"IEEE address must be an integer from 1-30, got {address}") self.command(f"IEEE {terminator},{eoi_enable},{address}") def get_iee_488(self): @@ -488,7 +593,12 @@ def set_ieee_interface_mode(self, mode): Args: mode (int): Interface mode. 0, 1 or 2. 0=local, 1=remote, and 2=remote with local lockout. + + Raises: + ValueError: If mode is not 0, 1, or 2. """ + if mode not in (0, 1, 2): + raise ValueError(f"Interface mode must be 0, 1, or 2, got {mode}") self.command(f"MODE {mode}") def get_ieee_interface_mode(self): @@ -662,6 +772,9 @@ def set_hardware_error_enable_mask(self, register_mask): register_mask (ElectromagnetPowerSupply.EMPowerSupplyHardwareErrorsRegister): Register mask configuration object. """ + # Note: read-modify-write is not atomic. The lock is held per-call, so another thread could + # modify the register between the query and command. This is acceptable for typical use since + # concurrent register modification is unusual. operational_mask = self.query("ERSTE?").split(',')[1] self.command(f"ERSTE {register_mask.to_integer()},{operational_mask}") @@ -710,11 +823,14 @@ def set_operational_error_enable_mask(self, register_mask): Status Byte Register. Args: - register_mask (ElectromagnetPowerSupply.EMPowerSupplyHardwareErrorsRegister): Register mask configuration + register_mask (ElectromagnetPowerSupply.EMPowerSupplyOperationalErrorsRegister): Register mask configuration object. """ + # Note: read-modify-write is not atomic. The lock is held per-call, so another thread could + # modify the register between the query and command. This is acceptable for typical use since + # concurrent register modification is unusual. hardware_mask = self.query("ERSTE?").split(',')[0] - self.command(f"ERSTE {register_mask.to_integer()},{hardware_mask}") + self.command(f"ERSTE {hardware_mask},{register_mask.to_integer()}") def get_operational_error_enable_mask(self): """Returns which operational error bits will set the summary bit in the Status Byte Register. @@ -724,7 +840,7 @@ def get_operational_error_enable_mask(self): Status Byte Register. Returns: - ElectromagnetPowerSupply.EMPowerSupplyHardwareErrorsRegister: Register mask configuration object. + ElectromagnetPowerSupply.EMPowerSupplyOperationalErrorsRegister: Register mask configuration object. """ operational_mask = int(self.query("ERSTE?").split(',')[1]) return self.EMPowerSupplyOperationalErrorsRegister.from_integer(operational_mask) diff --git a/tests/test_em_power_supply.py b/tests/test_em_power_supply.py index 5509e3b..08d8cd8 100644 --- a/tests/test_em_power_supply.py +++ b/tests/test_em_power_supply.py @@ -252,12 +252,12 @@ def test_get_operation_event(self): self.assertIn("OPST?", self.fake_connection.get_outgoing_message()) def test_set_hardware_error_enable_mask(self): - self.fake_connection.setup_response("0,0; 0") + self.fake_connection.setup_response("0,7; 0") self.fake_connection.setup_response("0") hardware_error_enable_mask = self.dut.EMPowerSupplyHardwareErrorsRegister.from_integer(4) self.dut.set_hardware_error_enable_mask(hardware_error_enable_mask) self.fake_connection.get_outgoing_message() - self.assertIn("ERSTE 4", self.fake_connection.get_outgoing_message()) + self.assertIn("ERSTE 4,7", self.fake_connection.get_outgoing_message()) def test_get_hardware_error_enable_mask(self): self.fake_connection.setup_response("4,0; 0") @@ -283,7 +283,7 @@ def test_set_operational_error_enable_mask(self): operational_error_enable_mask = self.dut.EMPowerSupplyOperationalErrorsRegister.from_integer(4) self.dut.set_operational_error_enable_mask(operational_error_enable_mask) self.fake_connection.get_outgoing_message() - self.assertIn("ERSTE 4", self.fake_connection.get_outgoing_message()) + self.assertIn("ERSTE 1,4", self.fake_connection.get_outgoing_message()) def test_get_operational_error_enable_mask(self): self.fake_connection.setup_response("0,4; 0") @@ -302,3 +302,259 @@ def test_get_operational_error_event(self): response = self.dut.get_operational_error_event() self.assertEqual(response.to_integer(), 1) self.assertIn("ERSTR?", self.fake_connection.get_outgoing_message()) + + +class TestCurrentValidation(TestWithFakeEMPowerSupply): + + def test_set_current_rejects_string(self): + with self.assertRaises(ValueError): + self.dut.set_current("fifty") + + def test_set_current_rejects_none(self): + with self.assertRaises(ValueError): + self.dut.set_current(None) + + def test_set_current_rejects_bool(self): + with self.assertRaises(ValueError): + self.dut.set_current(True) + + def test_set_current_rejects_nan(self): + with self.assertRaises(ValueError): + self.dut.set_current(float('nan')) + + def test_set_current_rejects_inf(self): + with self.assertRaises(ValueError): + self.dut.set_current(float('inf')) + + def test_set_current_rejects_above_max(self): + with self.assertRaises(ValueError): + self.dut.set_current(135.2) + + def test_set_current_rejects_below_negative_max(self): + with self.assertRaises(ValueError): + self.dut.set_current(-135.2) + + def test_set_current_accepts_boundary_positive(self): + self.fake_connection.setup_response("0") + self.dut.set_current(135.1) + self.assertIn("SETI 135.1", self.fake_connection.get_outgoing_message()) + + def test_set_current_accepts_boundary_negative(self): + self.fake_connection.setup_response("0") + self.dut.set_current(-135.1) + self.assertIn("SETI -135.1", self.fake_connection.get_outgoing_message()) + + def test_set_current_accepts_zero(self): + self.fake_connection.setup_response("0") + self.dut.set_current(0) + self.assertIn("SETI 0", self.fake_connection.get_outgoing_message()) + + +class TestRampRateValidation(TestWithFakeEMPowerSupply): + + def test_set_ramp_rate_rejects_string(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_rate("fast") + + def test_set_ramp_rate_rejects_none(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_rate(None) + + def test_set_ramp_rate_rejects_bool(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_rate(True) + + def test_set_ramp_rate_rejects_nan(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_rate(float('nan')) + + def test_set_ramp_rate_rejects_inf(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_rate(float('inf')) + + def test_set_ramp_rate_rejects_zero(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_rate(0) + + def test_set_ramp_rate_rejects_negative(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_rate(-1.0) + + def test_set_ramp_rate_rejects_above_max(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_rate(50.1) + + def test_set_ramp_rate_accepts_lower_boundary(self): + self.fake_connection.setup_response("0") + self.dut.set_ramp_rate(0.0001) + self.assertIn("RATE 0.0001", self.fake_connection.get_outgoing_message()) + + def test_set_ramp_rate_accepts_upper_boundary(self): + self.fake_connection.setup_response("0") + self.dut.set_ramp_rate(50.0) + self.assertIn("RATE 50.0", self.fake_connection.get_outgoing_message()) + + +class TestLimitsValidation(TestWithFakeEMPowerSupply): + + def test_set_limits_rejects_string_current(self): + with self.assertRaises(ValueError): + self.dut.set_limits("high", 3.0) + + def test_set_limits_rejects_string_ramp_rate(self): + with self.assertRaises(ValueError): + self.dut.set_limits(50.0, "fast") + + def test_set_limits_rejects_negative_current(self): + with self.assertRaises(ValueError): + self.dut.set_limits(-1.0, 3.0) + + def test_set_limits_rejects_current_above_max(self): + with self.assertRaises(ValueError): + self.dut.set_limits(135.2, 3.0) + + def test_set_limits_rejects_ramp_rate_below_min(self): + with self.assertRaises(ValueError): + self.dut.set_limits(50.0, 0.0) + + def test_set_limits_rejects_ramp_rate_above_max(self): + with self.assertRaises(ValueError): + self.dut.set_limits(50.0, 50.1) + + def test_set_limits_rejects_bool_current(self): + with self.assertRaises(ValueError): + self.dut.set_limits(True, 3.0) + + def test_set_limits_rejects_nan_current(self): + with self.assertRaises(ValueError): + self.dut.set_limits(float('nan'), 3.0) + + def test_set_limits_rejects_inf_ramp_rate(self): + with self.assertRaises(ValueError): + self.dut.set_limits(50.0, float('inf')) + + def test_set_limits_accepts_boundary_values(self): + self.fake_connection.setup_response("0") + self.dut.set_limits(135.1, 50.0) + self.assertIn("LIMIT 135.1, 50.0", self.fake_connection.get_outgoing_message()) + + def test_set_limits_accepts_lower_boundary_values(self): + self.fake_connection.setup_response("0") + self.dut.set_limits(0, 0.0001) + self.assertIn("LIMIT 0, 0.0001", self.fake_connection.get_outgoing_message()) + + +class TestRampSegmentValidation(TestWithFakeEMPowerSupply): + + def test_set_ramp_segment_rejects_segment_zero(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segment(0, 50, 2.5) + + def test_set_ramp_segment_rejects_segment_negative(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segment(-1, 50, 2.5) + + def test_set_ramp_segment_rejects_segment_six(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segment(6, 50, 2.5) + + def test_set_ramp_segment_rejects_string_current(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segment(1, "fifty", 2.5) + + def test_set_ramp_segment_rejects_string_ramp_rate(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segment(1, 50, "fast") + + def test_set_ramp_segment_rejects_ramp_rate_above_max(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segment(1, 50, 50.1) + + def test_set_ramp_segment_rejects_ramp_rate_below_min(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segment(1, 50, 0) + + def test_set_ramp_segment_rejects_current_above_max(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segment(1, 200, 2.5) + + def test_set_ramp_segment_rejects_nan_current(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segment(1, float('nan'), 2.5) + + def test_set_ramp_segment_accepts_segment_one(self): + self.fake_connection.setup_response("0") + self.dut.set_ramp_segment(1, 50, 2.5) + self.assertIn("RSEGS 1, 50, 2.5", self.fake_connection.get_outgoing_message()) + + def test_set_ramp_segment_accepts_segment_five(self): + self.fake_connection.setup_response("0") + self.dut.set_ramp_segment(5, 50, 2.5) + self.assertIn("RSEGS 5, 50, 2.5", self.fake_connection.get_outgoing_message()) + + +class TestSafetyMethodValidation(TestWithFakeEMPowerSupply): + + def test_set_internal_water_rejects_invalid_mode(self): + with self.assertRaises(ValueError): + self.dut.set_internal_water(4) + + def test_set_internal_water_rejects_negative(self): + with self.assertRaises(ValueError): + self.dut.set_internal_water(-1) + + def test_set_magnet_water_rejects_invalid_mode(self): + with self.assertRaises(ValueError): + self.dut.set_magnet_water(5) + + def test_set_magnet_water_rejects_string(self): + with self.assertRaises(ValueError): + self.dut.set_magnet_water("auto") + + def test_set_display_brightness_rejects_invalid(self): + with self.assertRaises(ValueError): + self.dut.set_display_brightness(4) + + def test_set_front_panel_lock_rejects_invalid_state(self): + with self.assertRaises(ValueError): + self.dut.set_front_panel_lock(3, 123) + + def test_set_programming_mode_rejects_invalid(self): + with self.assertRaises(ValueError): + self.dut.set_programming_mode(3) + + def test_set_front_panel_lock_rejects_invalid_code_type(self): + with self.assertRaises(ValueError): + self.dut.set_front_panel_lock(1, "abc") + + def test_set_front_panel_lock_rejects_bool_code(self): + with self.assertRaises(ValueError): + self.dut.set_front_panel_lock(1, True) + + def test_set_ramp_segments_enable_rejects_invalid(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segments_enable(2) + + def test_set_ramp_segments_enable_rejects_string(self): + with self.assertRaises(ValueError): + self.dut.set_ramp_segments_enable("on") + + def test_set_ieee_488_rejects_invalid_terminator(self): + with self.assertRaises(ValueError): + self.dut.set_ieee_488(4, 0, 8) + + def test_set_ieee_488_rejects_invalid_eoi(self): + with self.assertRaises(ValueError): + self.dut.set_ieee_488(0, 2, 8) + + def test_set_ieee_488_rejects_address_zero(self): + with self.assertRaises(ValueError): + self.dut.set_ieee_488(0, 0, 0) + + def test_set_ieee_488_rejects_address_31(self): + with self.assertRaises(ValueError): + self.dut.set_ieee_488(0, 0, 31) + + def test_set_ieee_interface_mode_rejects_invalid(self): + with self.assertRaises(ValueError): + self.dut.set_ieee_interface_mode(3)