Fix EM power supply correctness bugs and add safety validation#18
Fix EM power supply correctness bugs and add safety validation#18jfichtner wants to merge 7 commits into
Conversation
Fix set_operational_error_enable_mask to place the operational mask in the second position of the ERSTE command (ERSTE <hardware>,<operational>), matching the protocol format. Previously it was incorrectly placed in the first (hardware) position. Also fix the docstring parameter type from EMPowerSupplyHardwareErrorsRegister to EMPowerSupplyOperationalErrorsRegister. Add comments to both set_hardware_error_enable_mask and set_operational_error_enable_mask noting the non-atomic read-modify-write TOCTOU limitation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add input validation to set_current, set_ramp_rate, set_limits, and set_ramp_segment to prevent dangerous out-of-range values from being sent to the electromagnet power supply (up to 135A output). Validates type checking and range bounds per the instrument specifications. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add _validate_number helper that rejects bool, non-numeric types, NaN,
and Inf values. Previously float('nan') and float('inf') passed all
validation checks due to NaN comparison semantics and would send invalid
commands to the instrument.
Also add missing current magnitude bounds check to set_ramp_segment, and
fix pre-existing docstring bug in get_operational_error_enable_mask (return
type was incorrectly documented as EMPowerSupplyHardwareErrorsRegister).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add input validation to set_internal_water, set_magnet_water, set_display_brightness, set_front_panel_lock, and set_programming_mode. These methods control physical systems (especially cooling water for a 135A electromagnet) where invalid values could cause equipment damage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add 43 new test cases covering all validation logic: - Type rejection (string, None, bool, NaN, Inf) for set_current, set_ramp_rate, set_limits, and set_ramp_segment - Range boundary tests (both acceptance and rejection) - Discrete mode validation for set_internal_water, set_magnet_water, set_display_brightness, set_front_panel_lock, set_programming_mode Strengthen test_set_hardware_error_enable_mask to use distinguishable non-zero values (hw=4, op=7) so a swapped-argument regression would be caught by the assertion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract duplicated bounds (135.1 A, 0.0001-50.0 A/s) into class constants _MAX_CURRENT, _MIN_RAMP_RATE, _MAX_RAMP_RATE for maintainability. Add missing validation to set_ieee_488 (terminator, eoi_enable, address), set_ieee_interface_mode (mode 0-2), set_ramp_segments_enable (bool/0/1), and the code parameter in set_front_panel_lock. Fix _validate_number: remove over-indented docstring sections and unused return value. Fix set_ramp_segment Raises docstring to accurately describe the mixed validation (discrete for segment, numeric for current/ramp_rate). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add missing validation tests: bool/None/inf rejection for ramp_rate, bool rejection for limits, negative segment and ramp_rate below-min for ramp_segment. Add tests for newly validated methods: ieee_488, ieee_interface_mode, ramp_segments_enable, and front_panel_lock code parameter. Remove unused math import. Total: 108 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes correctness issues in the Model 643/648 electromagnet power supply driver (notably ERSTE mask ordering) and adds safety-focused input validation across multiple setter APIs, along with expanded unit test coverage to exercise validation edge cases.
Changes:
- Fix ERSTE operational vs hardware mask argument ordering and update related tests.
- Add shared finite-number validation and apply bounds/type checks to current/ramp/limit and other safety-relevant setters.
- Add many new validation tests for boundary values and invalid types.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 12 comments.
| File | Description |
|---|---|
lakeshore/em_power_supply.py |
Fixes ERSTE operational mask write ordering and introduces numeric and range validation for multiple safety-critical commands. |
tests/test_em_power_supply.py |
Updates ERSTE tests and adds a large suite of new validation tests for setter input bounds and invalid types. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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()) |
There was a problem hiding this comment.
These tests run under TestWithFakeEMPowerSupply, which identifies as MODEL643 (see tests/utils.py). They currently assert that 135.1 A is accepted, but the Model 643 documented limit is 70.1 A. Either switch these validation tests to use a MODEL648 fixture or make the asserted max current depend on the detected model so the tests don’t lock in unsafe behavior for MODEL643.
| 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()) | |
| self.dut.set_current(70.2) | |
| def test_set_current_rejects_below_negative_max(self): | |
| with self.assertRaises(ValueError): | |
| self.dut.set_current(-70.2) | |
| def test_set_current_accepts_boundary_positive(self): | |
| self.fake_connection.setup_response("0") | |
| self.dut.set_current(70.1) | |
| self.assertIn("SETI 70.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(-70.1) | |
| self.assertIn("SETI -70.1", self.fake_connection.get_outgoing_message()) |
| 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) | ||
|
|
There was a problem hiding this comment.
The new safety/mode validation tests don’t cover the bool edge case for integer-coded parameters (e.g., set_internal_water(True), set_magnet_water(True), etc.). Since bools currently compare equal to 0/1, add explicit tests to ensure bool inputs are rejected (or correctly coerced) for these methods.
| """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 |
There was a problem hiding this comment.
_MAX_CURRENT is hard-coded to the Model 648 limit (135.1 A) even though this class is used for MODEL643 as well (tests instantiate Model643). This contradicts the docstrings (MODEL643 max 70.1 A) and can allow unsafe currents/limits on a 643. Consider deriving the max current from the connected instrument model (e.g., based on self.model_number) or making it configurable per instance, and update validation accordingly.
| _MAX_CURRENT = 135.1 # Model 648 maximum; Model 643 is 70.1 A | |
| # Use the more restrictive of the two model limits (643: 70.1 A, 648: 135.1 A) for safety. | |
| _MAX_CURRENT = 70.1 # A |
| Raises: | ||
| ValueError: If state is not a bool or 0/1. | ||
| """ | ||
| if state not in (True, False, 0, 1): |
There was a problem hiding this comment.
state not in (True, False, 0, 1) will also accept 0.0/1.0 (and other numeric types equal to 0/1) due to equality semantics, even though the docstring says bool or 0/1. If you want strict input validation, check type explicitly (bool or int) and reject floats/other numerics.
| if state not in (True, False, 0, 1): | |
| # Enforce strict type checking: accept only bool or int 0/1, reject floats and other numerics. | |
| if isinstance(state, bool): | |
| valid = True | |
| elif isinstance(state, int) and state in (0, 1): | |
| valid = True | |
| else: | |
| valid = False | |
| if not valid: |
| 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}") |
There was a problem hiding this comment.
mode not in (0, 1, 2, 3) will allow bools (True == 1 / False == 0). Passing True currently results in INTWTR True being sent, which is not a valid instrument command. Explicitly reject bool (and/or enforce isinstance(mode, int) and format with int(mode)).
| if segment not in range(1, 6): | ||
| raise ValueError(f"Segment must be 1-5, got {segment}") |
There was a problem hiding this comment.
The segment validation allows bool values because True/False compare equal to 1/0 and will pass segment in range(1, 6). That would format into the command as RSEGS True,..., which the instrument will not accept. Add an explicit type check to require an int (and reject bool) before the range check.
| 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}") |
There was a problem hiding this comment.
mode not in (0, 1, 2, 3) will allow bools (True == 1 / False == 0). Passing True will send MAGWTR True, which is not valid for the instrument. Add an explicit bool/type check and/or cast to int when formatting the command.
| 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}") |
There was a problem hiding this comment.
brightness_level not in (0, 1, 2, 3) allows bools (True == 1 / False == 0), and f-string formatting would send DISP True/False. Add an explicit check to reject bool and require an int, and/or format the command with int(brightness_level).
| 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}") | |
| if isinstance(brightness_level, bool): | |
| raise ValueError("Brightness level must be an int between 0 and 3, got bool") | |
| if not isinstance(brightness_level, int): | |
| raise ValueError( | |
| f"Brightness level must be an int between 0 and 3, got {type(brightness_level).__name__}" | |
| ) | |
| 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 {int(brightness_level)}") |
| 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}") |
There was a problem hiding this comment.
lock_state not in (0, 1, 2) allows bools (True == 1 / False == 0), which would result in LOCK True,<code> being sent. Add an explicit isinstance(lock_state, bool) rejection (or require int and cast with int(lock_state) when formatting).
| 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}") | |
| if isinstance(lock_state, bool) or not isinstance(lock_state, int) or 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 {int(lock_state)},{code}") |
| 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}") |
There was a problem hiding this comment.
mode not in (0, 1, 2) allows bools, and f-string formatting would send XPGM True/False. Add an explicit bool/type check and/or cast to int when formatting the command.
| 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}") | |
| if not isinstance(mode, int) or isinstance(mode, bool): | |
| raise ValueError(f"Programming mode must be an integer, got {type(mode).__name__}") | |
| if mode not in (0, 1, 2): | |
| raise ValueError(f"Programming mode must be 0, 1, or 2, got {mode}") | |
| self.command(f"XPGM {int(mode)}") |
Summary
set_operational_error_enable_mask— operational mask was incorrectly placed in the hardware mask position, causing cross-register corruptionset_current,set_ramp_rate,set_limits,set_ramp_segment,set_ieee_488, and others) with bounds checking for the Model 643/648 (up to 135A)bool,NaN,Inf, and non-numeric types via a shared_validate_numberhelperCommits
set_current,set_ramp_rate,set_limits,set_ramp_segment_validate_numberhelper, adds current bounds toset_ramp_segmentset_internal_water,set_magnet_water,set_display_brightness,set_front_panel_lock,set_programming_modeset_ieee_488,set_ieee_interface_mode,set_ramp_segments_enable; extract_MAX_CURRENT,_MIN_RAMP_RATE,_MAX_RAMP_RATEclass constantsTest plan
python -m pytest tests/test_em_power_supply.py -v🤖 Generated with Claude Code