Skip to content

Fix EM power supply correctness bugs and add safety validation#18

Open
jfichtner wants to merge 7 commits into
mainfrom
fix/em-power-supply
Open

Fix EM power supply correctness bugs and add safety validation#18
jfichtner wants to merge 7 commits into
mainfrom
fix/em-power-supply

Conversation

@jfichtner

Copy link
Copy Markdown

Summary

  • Fix ERSTE argument order bug in set_operational_error_enable_mask — operational mask was incorrectly placed in the hardware mask position, causing cross-register corruption
  • Add input validation to all safety-critical methods (set_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)
  • Harden validation against edge cases — rejects bool, NaN, Inf, and non-numeric types via a shared _validate_number helper
  • Add 58 new validation tests (50 → 108 total) covering boundary values, type rejection, and safety bounds

Commits

  1. Fix ERSTE argument order and TOCTOU race — Corrects the register mask position and documents the read-modify-write race condition
  2. Add bounds validation to safety-critical methodsset_current, set_ramp_rate, set_limits, set_ramp_segment
  3. Harden validation against NaN/Inf — Adds _validate_number helper, adds current bounds to set_ramp_segment
  4. Add validation to remaining methodsset_internal_water, set_magnet_water, set_display_brightness, set_front_panel_lock, set_programming_mode
  5. Add comprehensive validation tests — 43 new tests, strengthen ERSTE mask test with distinguishable values
  6. Fix remaining gaps and extract constants — Validate set_ieee_488, set_ieee_interface_mode, set_ramp_segments_enable; extract _MAX_CURRENT, _MIN_RAMP_RATE, _MAX_RAMP_RATE class constants
  7. Complete test coverage — 15 additional tests for newly validated methods and edge cases

Test plan

  • All 108 tests pass: python -m pytest tests/test_em_power_supply.py -v
  • Verify on physical Model 643/648 hardware that validated commands still work within bounds
  • Confirm ERSTE register behavior matches corrected argument order

🤖 Generated with Claude Code

jfichtner and others added 7 commits February 17, 2026 19:16
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +331 to +345
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())

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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())

Copilot uses AI. Check for mistakes.
Comment on lines +498 to +505
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)

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
"""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

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Suggested change
_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

Copilot uses AI. Check for mistakes.
Raises:
ValueError: If state is not a bool or 0/1.
"""
if state not in (True, False, 0, 1):

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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:

Copilot uses AI. Check for mistakes.
Comment on lines +452 to 454
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}")

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)).

Copilot uses AI. Check for mistakes.
Comment on lines +344 to +345
if segment not in range(1, 6):
raise ValueError(f"Segment must be 1-5, got {segment}")

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +474 to 476
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}")

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +495 to 497
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}")

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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)}")

Copilot uses AI. Check for mistakes.
Comment on lines +517 to 521
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}")

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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}")

Copilot uses AI. Check for mistakes.
Comment on lines +548 to 550
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}")

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)}")

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants