From 2b50b688786a792ad683ac964cba9da3bc814afe Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 3 Nov 2026 14:37:27 +0100 Subject: [PATCH 1/4] feat(keithley): output voltage protection, remote sense on, publish after output on/off - ovp_voltage config parameter (:SOUR:VOLT:PROT), 20/40/60/80/100/120/160 V or None, applied and verified at connect with the output off, shown in status() - ptycho_lamni: remote_sense true, ovp_voltage 20 V - output_on/output_off push a fresh reading to BEC, output_on waits for the current Co-Authored-By: Claude Opus 5.5 --- csaxs_bec/device_configs/ptycho_lamni.yaml | 13 +++- ...simulated_bl_simulated_lamni_keithley.yaml | 1 + csaxs_bec/devices/keithley/keithley_2400.py | 56 +++++++++++++- tests/tests_devices/test_keithley_2400.py | 75 ++++++++++++++++++- 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/csaxs_bec/device_configs/ptycho_lamni.yaml b/csaxs_bec/device_configs/ptycho_lamni.yaml index 0a43c205..14f04f92 100644 --- a/csaxs_bec/device_configs/ptycho_lamni.yaml +++ b/csaxs_bec/device_configs/ptycho_lamni.yaml @@ -354,8 +354,14 @@ rt_positions: ############################################################ # RS-232 through the TCP-serial bridge on mpc-lamni (service lamni-serial-keithley, TCP 3336). # The Keithley is not always connected; onFailure: buffer keeps the session running without it. -# compliance_voltage: the output never drives more than this, also with the load disconnected. -# remote_sense: true needs the sense leads connected at the load. +# compliance_voltage: voltage limit in V. With remote_sense it is measured at the sense leads, so a +# broken sense lead lets the output rise above it, up to ovp_voltage. +# remote_sense: true for 4-wire sensing, needs the sense leads connected at the load. The measured +# voltage (dev.keithley.voltage, front panel) is then the voltage at the load. +# ovp_voltage: output voltage protection, hard limit at the output terminals in any sense mode. +# Possible values: 20, 40, 60, 80, 100, 120, 160 (V), or null for no limit. 20 V is the lowest +# possible. Must not be below compliance_voltage. +# All settings are only applied when the output is off at connect. keithley: description: Keithley 2400 current source deviceClass: csaxs_bec.devices.keithley.keithley_2400.Keithley2400 @@ -368,7 +374,8 @@ keithley: tolerance: 0.01 compliance_voltage: 5 source_range_ma: 10 - remote_sense: false + remote_sense: true + ovp_voltage: 20 enabled: true onFailure: buffer readOnly: false diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni_keithley.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni_keithley.yaml index b89b3db0..37ff4618 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni_keithley.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni_keithley.yaml @@ -17,6 +17,7 @@ keithley: compliance_voltage: 5 source_range_ma: 10 remote_sense: false + ovp_voltage: 20 enabled: true onFailure: buffer readOnly: false diff --git a/csaxs_bec/devices/keithley/keithley_2400.py b/csaxs_bec/devices/keithley/keithley_2400.py index 386762a2..6cac6163 100644 --- a/csaxs_bec/devices/keithley/keithley_2400.py +++ b/csaxs_bec/devices/keithley/keithley_2400.py @@ -43,6 +43,8 @@ class Keithley2400Controller(Controller): """SCPI communication with the Keithley 2400.""" _axes_per_controller = 1 + # :SOUR:VOLT:PROT takes only these levels in V, None stands for NONE (no limit). + OVP_LEVELS = (20, 40, 60, 80, 100, 120, 160) READBACK_MAX_AGE = 0.1 # s, the voltage and current signals share one :READ? def _initialize(self): @@ -80,7 +82,11 @@ class Keithley2400Controller(Controller): @threadlocked def configure( - self, compliance_voltage: float, source_range_ma: float, remote_sense: bool + self, + compliance_voltage: float, + source_range_ma: float, + remote_sense: bool, + ovp_voltage: float | None = None, ) -> None: """Set up the current source. With the output on only harmless settings are sent.""" idn = self.socket_put_and_receive("*IDN?") @@ -102,6 +108,11 @@ class Keithley2400Controller(Controller): f"Keithley output is on, compliance stays at {actual} V " f"(configured: {compliance_voltage} V)." ) + if ovp_voltage is not None and not np.isclose(self.get_ovp_voltage(), ovp_voltage): + logger.warning( + f"Keithley output is on, output voltage protection stays at " + f"{self.get_ovp_voltage()} V (configured: {ovp_voltage} V)." + ) else: for command in ( ":SOUR:FUNC CURR", @@ -113,8 +124,15 @@ class Keithley2400Controller(Controller): f":SYST:RSEN {'ON' if remote_sense else 'OFF'}", ":ARM:COUN 1", ":TRIG:COUN 1", + f":SOUR:VOLT:PROT {'NONE' if ovp_voltage is None else int(ovp_voltage)}", ): self.socket_put(command) + if ovp_voltage is not None: + actual = self.get_ovp_voltage() + if not np.isclose(actual, ovp_voltage): + raise Keithley2400Error( + f"Output voltage protection is {actual} V, expected {ovp_voltage} V." + ) self.set_compliance_voltage(compliance_voltage) actual = self.get_compliance_voltage() if not np.isclose(actual, compliance_voltage): @@ -147,6 +165,10 @@ class Keithley2400Controller(Controller): def set_compliance_voltage(self, volts: float) -> None: self.socket_put(f":SENS:VOLT:PROT {volts:.6E}") + def get_ovp_voltage(self) -> float: + """Output voltage protection in V, effective at the output terminals also with remote sense.""" + return float(self.socket_put_and_receive(":SOUR:VOLT:PROT?")) + def in_compliance(self) -> bool: return self.socket_put_and_receive(":SENS:VOLT:PROT:TRIP?") == "1" @@ -237,9 +259,13 @@ class Keithley2400(Device, PositionerBase): Args: limits: Allowed source current in mA. tolerance: Allowed difference between measured and requested current in mA. - compliance_voltage: Voltage limit in V. + compliance_voltage: Voltage limit in V. With remote sense it is measured at the sense + leads, so an open sense lead lets the output rise above it up to ovp_voltage. source_range_ma: Fixed source range in mA, fixed so level changes cause no range glitch. remote_sense: True for 4-wire sensing, needs the sense leads connected at the load. + ovp_voltage: Output voltage protection in V, a hard limit at the output terminals + independent of the sense mode. Only 20, 40, 60, 80, 100, 120 or 160 V, or None for + no limit. Must not be below compliance_voltage. """ USER_ACCESS = ["output_on", "output_off", "status"] @@ -280,6 +306,7 @@ class Keithley2400(Device, PositionerBase): compliance_voltage: float = 5.0, source_range_ma: float = 10.0, remote_sense: bool = False, + ovp_voltage: float | None = None, socket_cls=SocketIO, device_manager=None, **kwargs, @@ -292,6 +319,17 @@ class Keithley2400(Device, PositionerBase): self._compliance_voltage = compliance_voltage self._source_range_ma = source_range_ma self._remote_sense = remote_sense + if ovp_voltage is not None: + if ovp_voltage not in Keithley2400Controller.OVP_LEVELS: + raise ValueError( + f"ovp_voltage {ovp_voltage} V not one of " + f"{Keithley2400Controller.OVP_LEVELS} V or None." + ) + if compliance_voltage > ovp_voltage: + raise ValueError( + f"compliance_voltage {compliance_voltage} V above ovp_voltage {ovp_voltage} V." + ) + self._ovp_voltage = ovp_voltage super().__init__( prefix, @@ -313,7 +351,7 @@ class Keithley2400(Device, PositionerBase): def wait_for_connection(self, timeout: float = 30.0) -> bool: self.controller.on(timeout=timeout) self.controller.configure( - self._compliance_voltage, self._source_range_ma, self._remote_sense + self._compliance_voltage, self._source_range_ma, self._remote_sense, self._ovp_voltage ) def destroy(self): @@ -393,13 +431,24 @@ class Keithley2400(Device, PositionerBase): ) raise Keithley2400Error(f"Requested {position} mA, measured {amps * 1e3} mA.{hint}") + def _publish_reading(self) -> None: + """Push a fresh reading to BEC, an RPC call alone leaves the cached values untouched.""" + self._run_subs( + sub_type=self.SUB_READBACK, value=self.readback.read(), timestamp=time.time() + ) + def output_on(self) -> None: """Switch the output on at the programmed level. Use ``umv`` to set a new level.""" self.controller.set_output(True) + try: + self._wait_for_current(self.controller.get_level() * 1e3) + finally: + self._publish_reading() def output_off(self) -> None: """Switch the output off. The programmed level is kept.""" self.controller.set_output(False) + self._publish_reading() def status(self) -> None: """Print the state of the instrument.""" @@ -414,6 +463,7 @@ class Keithley2400(Device, PositionerBase): ["Measured voltage", f"{volts:.4f} V"], ["Compliance voltage", f"{self.controller.get_compliance_voltage()} V"], ["In compliance", "YES" if self.controller.in_compliance() else "no"], + ["Output voltage protection", f"{self.controller.get_ovp_voltage()} V"], ] ) print(table) diff --git a/tests/tests_devices/test_keithley_2400.py b/tests/tests_devices/test_keithley_2400.py index 51bff74b..8d11bdd7 100644 --- a/tests/tests_devices/test_keithley_2400.py +++ b/tests/tests_devices/test_keithley_2400.py @@ -19,6 +19,7 @@ class FakeKeithleySocket: level = 0.0 source_function = "VOLT" compliance = 21.0 + ovp = 210.0 # NONE max_volts = 100.0 # load limit, above it the instrument sits in compliance resistance = 1000.0 @@ -50,6 +51,7 @@ class FakeKeithleySocket: ":SOUR:FUNC?": self.source_function, ":SOUR:CURR?": f"{self.level:E}", ":SENS:VOLT:PROT?": f"{self.compliance:E}", + ":SOUR:VOLT:PROT?": f"{self.ovp:E}", ":SYST:ERR?": '0,"No error"', }.get(command) if command == ":READ?": @@ -65,6 +67,9 @@ class FakeKeithleySocket: type(self).level = float(command.split()[1]) elif command.startswith(":SENS:VOLT:PROT "): type(self).compliance = float(command.split()[1]) + elif command.startswith(":SOUR:VOLT:PROT "): + value = command.split()[1] + type(self).ovp = 210.0 if value == "NONE" else float(value) if reply is not None: data = f"{reply}\r\n".encode() self.replies += [part for part in (data[:3], data[3:]) if part] @@ -95,7 +100,60 @@ def test_connect_with_output_off_configures_and_keeps_it_off(keithley, socket_cl assert socket_cls.source_function == "CURR" assert socket_cls.compliance == 5.0 assert socket_cls.output is False - assert not any("OUTP ON" in cmd or "MEAS" in cmd or cmd == "*RST" for cmd in commands(socket_cls)) + assert not any( + "OUTP ON" in cmd or "MEAS" in cmd or cmd == "*RST" for cmd in commands(socket_cls) + ) + + +def test_connect_sets_ovp_and_remote_sense(socket_cls): + dev = Keithley2400( + name="keithley", + socket_cls=socket_cls, + host="fake", + port=1, + device_manager=mock.MagicMock(), + remote_sense=True, + ovp_voltage=20, + ) + try: + dev.wait_for_connection() + assert socket_cls.ovp == 20.0 + assert ":SYST:RSEN ON" in commands(socket_cls) + finally: + dev.controller.off(update_config=False) + + +def test_connect_with_output_on_keeps_ovp(socket_cls): + socket_cls.output, socket_cls.level, socket_cls.source_function = True, 2e-3, "CURR" + dev = Keithley2400( + name="keithley", + socket_cls=socket_cls, + host="fake", + port=1, + device_manager=mock.MagicMock(), + ovp_voltage=20, + ) + try: + dev.wait_for_connection() + assert socket_cls.ovp == 210.0 + assert not any(cmd.startswith(":SOUR:VOLT:PROT ") for cmd in commands(socket_cls)) + finally: + dev.controller.off(update_config=False) + + +@pytest.mark.parametrize( + "kwargs", [{"ovp_voltage": 25}, {"ovp_voltage": 20, "compliance_voltage": 30}] +) +def test_invalid_ovp_rejected(socket_cls, kwargs): + with pytest.raises(ValueError): + Keithley2400( + name="keithley", + socket_cls=socket_cls, + host="fake", + port=1, + device_manager=mock.MagicMock(), + **kwargs, + ) def test_connect_with_output_on_leaves_the_source_alone(keithley, socket_cls): @@ -189,3 +247,18 @@ def test_closed_connection_raises(keithley, socket_cls): socket_cls.instances[-1].put = lambda msg: None with pytest.raises(ControllerCommunicationError): keithley.controller.socket_put_and_receive(":OUTP?") + + +def test_output_on_and_off_publish_a_fresh_reading(keithley, socket_cls): + keithley.wait_for_connection() + keithley.move(1.5).wait(timeout=5) + published = [] + keithley.subscribe( + lambda *_, obj, **__: published.append(obj.root.read()), event_type="readback", run=False + ) + keithley.output_off() + assert published[-1]["keithley"]["value"] == 0.0 + assert published[-1]["keithley_output_enabled"]["value"] == 0 + keithley.output_on() + assert published[-1]["keithley"]["value"] == pytest.approx(1.5) + assert published[-1]["keithley_output_enabled"]["value"] == 1 -- 2.54.0 From 83e271430e31d9f3d76ecb41b12a9707c655944e Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 3 Nov 2026 14:43:54 +0100 Subject: [PATCH 2/4] config(main): load lamni instead of sastt_aerotech Co-Authored-By: Claude Opus 5.5 --- csaxs_bec/device_configs/main.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/csaxs_bec/device_configs/main.yaml b/csaxs_bec/device_configs/main.yaml index 944a0c1f..d6aeddae 100644 --- a/csaxs_bec/device_configs/main.yaml +++ b/csaxs_bec/device_configs/main.yaml @@ -25,8 +25,8 @@ detectors: #sastt: # - !include ./sastt.yaml -sastt: - - !include ./sastt_aerotech.yaml +# sastt: +# - !include ./sastt_aerotech.yaml # flomni: # - !include ./ptycho_flomni.yaml @@ -34,8 +34,8 @@ sastt: #omny: # - !include ./ptycho_omny.yaml -# lamni: -# - !include ./ptycho_lamni.yaml +lamni: + - !include ./ptycho_lamni.yaml # user setup: # - !include ./user_setup.yaml -- 2.54.0 From 8498271a5cf261ffc18130c566b7b8daf0732a3e Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 4 Nov 2026 15:56:53 +0100 Subject: [PATCH 3/4] config file changes --- csaxs_bec/device_configs/bl_optics_hutch.yaml | 5 ++++- csaxs_bec/device_configs/ptycho_lamni.yaml | 15 ++++++++++++--- csaxs_bec/device_configs/ptycho_omny.yaml | 0 3 files changed, 16 insertions(+), 4 deletions(-) mode change 100755 => 100644 csaxs_bec/device_configs/ptycho_omny.yaml diff --git a/csaxs_bec/device_configs/bl_optics_hutch.yaml b/csaxs_bec/device_configs/bl_optics_hutch.yaml index 3808bd3a..b30db14a 100644 --- a/csaxs_bec/device_configs/bl_optics_hutch.yaml +++ b/csaxs_bec/device_configs/bl_optics_hutch.yaml @@ -288,6 +288,8 @@ poly: userParameter: # bl_smar_stage to use csaxs reference method. assign number according to axis channel init_position: -23 + diamond_500mu: 0.8 + diamond_300mu: -9.75 bl_smar_stage: 3 deviceTags: - bl_optics_hutch @@ -312,7 +314,8 @@ scinx: # bl_smar_stage to use csaxs reference method. assign number according to axis channel init_position: -23 bl_smar_stage: 4 - in_position: -12.5 + scin_in_position: -12.5 + diode_in_position: 10 deviceTags: - bl_optics_hutch diff --git a/csaxs_bec/device_configs/ptycho_lamni.yaml b/csaxs_bec/device_configs/ptycho_lamni.yaml index 14f04f92..8c7355e9 100644 --- a/csaxs_bec/device_configs/ptycho_lamni.yaml +++ b/csaxs_bec/device_configs/ptycho_lamni.yaml @@ -207,7 +207,10 @@ losax: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -1.6437 + #250 micron, 30 nm, 7.9 kev, loptz at 80.8429 + #in: -1.6437 + #250 micron, 30 nm, 7.24 kev, loptz at + in: -1.603676 deviceTags: - ptycho_lamni losay: @@ -227,7 +230,10 @@ losay: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -0.2109 + #250 micron, 30 nm, 7.9 kev + #in: -0.2109 + #250 micron, 30 nm, 7.24 kev + in: -0.150825 out: 3.8 deviceTags: - ptycho_lamni @@ -248,7 +254,10 @@ losaz: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: 6.8288 + #250 micron, 30 nm, 7.9 kev + #in: 6.8288 + #250 micron, 30 nm, 7.24 kev + in: 1.828631 out: -3 deviceTags: - ptycho_lamni diff --git a/csaxs_bec/device_configs/ptycho_omny.yaml b/csaxs_bec/device_configs/ptycho_omny.yaml old mode 100755 new mode 100644 -- 2.54.0 From 97f7f76f0dd3800ac323de6b8511f98498776054 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 4 Nov 2026 16:39:24 +0100 Subject: [PATCH 4/4] config(optics): add polrotz (X12SA-OP-POL2:ROTZ) with zero_degrees user parameter Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M8zoDy45sotSDj1i8J9vpY --- csaxs_bec/device_configs/bl_optics_hutch.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/csaxs_bec/device_configs/bl_optics_hutch.yaml b/csaxs_bec/device_configs/bl_optics_hutch.yaml index b30db14a..392586dc 100644 --- a/csaxs_bec/device_configs/bl_optics_hutch.yaml +++ b/csaxs_bec/device_configs/bl_optics_hutch.yaml @@ -294,6 +294,21 @@ poly: deviceTags: - bl_optics_hutch +polrotz: + description: "polarizer 2 crystal rotation" + deviceClass: ophyd_devices.EpicsMotorEC + deviceConfig: + prefix: "X12SA-OP-POL2:ROTZ" + onFailure: raise + enabled: true + readoutPriority: baseline + readOnly: false + softwareTrigger: false + userParameter: + zero_degrees: 7.7 + deviceTags: + - bl_optics_hutch + scinx: description: scintillator in OPbox deviceClass: csaxs_bec.devices.mcs2.mcs2_ophyd.Mcs2Motor -- 2.54.0