feat(lamni): Keithley 2400 current source and queue actions
CI for csaxs_bec / test (push) Successful in 4m24s
CI for csaxs_bec / test (push) Successful in 4m24s
Add a Keithley 2400 device, a positioner in mA over the TCP-serial bridge, with voltage readback and output_on/output_off/status user commands. The output and level are left alone on connect, stop and restart. Add the LamNI tomo queue actions set_keithley_current and sleep, available in the command job dialog, and a config with only the Keithley plus one combining it with the simulated LamNI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8zoDzTXoPAqgdFjHUFFF8
This commit is contained in:
1 parent
602eac82ba
commit
a566540a83
9 files changed
+861
No files matched your search
@@ -207,6 +207,7 @@ class LamNI(
|
||||
# overwrite). loptz is deliberately NOT in _TOMO_QUEUE_MOVE_DEVICES, so
|
||||
# this is the only way to queue a loptz move and the new shift can't be
|
||||
# forgotten. See TomoQueueMixin._queue_action_move() for "move".
|
||||
# "set_keithley_current" is not a "move" because it carries its own sleep.
|
||||
_TOMO_QUEUE_ACTIONS = {
|
||||
"move": {
|
||||
"func": "_queue_action_move",
|
||||
@@ -255,8 +256,76 @@ class LamNI(
|
||||
},
|
||||
},
|
||||
},
|
||||
"set_keithley_current": {
|
||||
"func": "_queue_action_set_keithley_current",
|
||||
"idempotent": True, # absolute level, re-runs harmlessly
|
||||
"help": (
|
||||
"Set the Keithley source current, switching its output on if it is "
|
||||
"off, then sleep."
|
||||
),
|
||||
"params": {
|
||||
"current_mA": {
|
||||
"type": "float",
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 3.0,
|
||||
"unit": "mA",
|
||||
"label": "Current",
|
||||
"device_limits_from": "keithley",
|
||||
},
|
||||
"sleep_s": {
|
||||
"type": "float",
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 86400.0,
|
||||
"unit": "s",
|
||||
"label": "Sleep afterwards",
|
||||
},
|
||||
},
|
||||
},
|
||||
"sleep": {
|
||||
"func": "_queue_action_sleep",
|
||||
"idempotent": True, # re-running only waits again
|
||||
"help": "Wait for a number of seconds.",
|
||||
"params": {
|
||||
"seconds": {
|
||||
"type": "float",
|
||||
"default": 60.0,
|
||||
"min": 0.0,
|
||||
"max": 86400.0,
|
||||
"unit": "s",
|
||||
"label": "Sleep",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _queue_action_sleep(self, seconds: float) -> None:
|
||||
"""Wait, the queue stays on this job."""
|
||||
if seconds < 0:
|
||||
raise ValueError(f"Queue sleep: seconds={seconds} is negative.")
|
||||
print(f"Queue: sleeping {seconds} s.")
|
||||
time.sleep(seconds)
|
||||
|
||||
def _queue_action_set_keithley_current(self, current_mA: float, sleep_s: float = 0.0) -> None:
|
||||
"""Set the Keithley current and sleep.
|
||||
|
||||
The move fails if the current isn't reached (e.g. voltage compliance),
|
||||
which pauses the queue before the next job. Re-checks the live limits
|
||||
at execution time.
|
||||
"""
|
||||
if "keithley" not in dev:
|
||||
raise ValueError("Queue set_keithley_current: device 'keithley' is not loaded.")
|
||||
lo, hi = dev.keithley.limits
|
||||
if not (lo <= current_mA <= hi):
|
||||
raise ValueError(
|
||||
f"Queue set_keithley_current: current_mA={current_mA} is outside "
|
||||
f"keithley's current limits ({lo}, {hi})."
|
||||
)
|
||||
umv(dev.keithley, current_mA)
|
||||
if sleep_s > 0:
|
||||
self._queue_action_sleep(sleep_s)
|
||||
|
||||
def _queue_action_move_loptz_with_shift(
|
||||
self, loptz_target: float, loptz_shift_x: float, loptz_shift_y: float
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Keithley 2400 as a current source, RS-232 through the TCP-serial bridge on mpc2844
|
||||
# (service flomni-serial-keithley, temporary).
|
||||
# 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.
|
||||
keithley:
|
||||
description: Keithley 2400 current source
|
||||
deviceClass: csaxs_bec.devices.keithley.keithley_2400.Keithley2400
|
||||
deviceConfig:
|
||||
host: mpc2844.psi.ch
|
||||
port: 3336
|
||||
limits:
|
||||
- 0
|
||||
- 3
|
||||
tolerance: 0.01
|
||||
compliance_voltage: 5
|
||||
source_range_ma: 10
|
||||
remote_sense: false
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
softwareTrigger: false
|
||||
connectionTimeout: 20
|
||||
deviceTags:
|
||||
- keithley
|
||||
@@ -0,0 +1,6 @@
|
||||
# Simulated LamNI endstation with the real Keithley 2400.
|
||||
lamni:
|
||||
- !include ./simulated_lamni.yaml
|
||||
|
||||
keithley:
|
||||
- !include ../keithley_2400.yaml
|
||||
Whitespace-only changes.
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
Keithley 2400 SourceMeter used as a current source, RS-232 reached through a TCP-serial bridge.
|
||||
|
||||
The device is a positioner in mA, so ``umv(dev.keithley, 1.5)`` sources 1.5 mA. Voltage and
|
||||
current are measured concurrently while the output is on.
|
||||
|
||||
Output handling, deliberate because a load hangs on this instrument:
|
||||
- ``:MEAS?`` switches the output on by itself and is therefore never sent. Readbacks use ``:READ?``
|
||||
and only while the output is on (it fails with the output off), reporting 0 otherwise.
|
||||
- ``:SOUR:CLE:AUTO`` is kept off, otherwise ``:READ?`` would toggle the output.
|
||||
- Connecting, disconnecting, stopping or a BEC restart leave the output and the level untouched. If
|
||||
the output is on at connect, the source settings are not changed at all.
|
||||
- A move programs the level first and only then switches the output on, so a stale level is
|
||||
never sourced. The output is only switched off by ``output_off()``.
|
||||
|
||||
Commands end in CR. Replies end in CR LF, set by the RS-232 terminator on the front panel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.logger import bec_logger
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import Device, PositionerBase, Signal, SignalRO
|
||||
from ophyd.status import MoveStatus
|
||||
from ophyd.utils import LimitError, ReadOnlyError
|
||||
from ophyd.utils.errors import InvalidState
|
||||
from ophyd_devices.utils.controller import Controller, ControllerCommunicationError, threadlocked
|
||||
from ophyd_devices.utils.socket import SocketIO, SocketSignal, raise_if_disconnected
|
||||
from prettytable import PrettyTable
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class Keithley2400Error(Exception):
|
||||
"""Instrument in an unexpected state, or the requested current could not be reached."""
|
||||
|
||||
|
||||
class Keithley2400Controller(Controller):
|
||||
"""SCPI communication with the Keithley 2400."""
|
||||
|
||||
_axes_per_controller = 1
|
||||
READBACK_MAX_AGE = 0.1 # s, the voltage and current signals share one :READ?
|
||||
|
||||
def _initialize(self):
|
||||
super()._initialize()
|
||||
self._reading = (0.0, 0.0)
|
||||
self._reading_time = 0.0
|
||||
|
||||
@threadlocked
|
||||
def socket_put(self, val: str) -> None:
|
||||
self.command_history.append(f"[PUT]: {val}")
|
||||
self.sock.put(f"{val}\r".encode())
|
||||
|
||||
@threadlocked
|
||||
def socket_get(self) -> str:
|
||||
"""Read one reply. At 9600 baud a reply can arrive in several TCP segments."""
|
||||
buffer = b""
|
||||
while not buffer.endswith(b"\n"):
|
||||
chunk = self.sock.receive()
|
||||
if not chunk:
|
||||
raise ConnectionError("Connection closed while waiting for a reply.")
|
||||
buffer += chunk
|
||||
response = buffer.decode().strip()
|
||||
self.command_history.append(f"[GET]: {response}")
|
||||
return response
|
||||
|
||||
@threadlocked
|
||||
def socket_put_and_receive(self, val: str, remove_trailing_chars: bool = True) -> str:
|
||||
"""Query, not retried: a late reply to a repeated query would shift all following replies."""
|
||||
try:
|
||||
self.socket_put(val)
|
||||
return self.socket_get()
|
||||
except Exception as exc:
|
||||
logger.error(f"Keithley query {val} failed: {exc}. {list(self.command_history)}")
|
||||
raise ControllerCommunicationError(f"Keithley query {val} failed: {exc}") from exc
|
||||
|
||||
@threadlocked
|
||||
def configure(
|
||||
self, compliance_voltage: float, source_range_ma: float, remote_sense: bool
|
||||
) -> None:
|
||||
"""Set up the current source. With the output on only harmless settings are sent."""
|
||||
idn = self.socket_put_and_receive("*IDN?")
|
||||
if "MODEL 2400" not in idn:
|
||||
raise Keithley2400Error(f"Unexpected instrument identification: {idn}")
|
||||
self.socket_put(":SOUR:CLE:AUTO OFF")
|
||||
self.socket_put(":FORM:ELEM VOLT,CURR")
|
||||
|
||||
if self.output_enabled():
|
||||
source_function = self.socket_put_and_receive(":SOUR:FUNC?")
|
||||
if source_function != "CURR":
|
||||
raise Keithley2400Error(
|
||||
f"Output is on with source function {source_function}, not CURR. "
|
||||
"Check the instrument and switch the output off."
|
||||
)
|
||||
actual = self.get_compliance_voltage()
|
||||
if not np.isclose(actual, compliance_voltage):
|
||||
logger.warning(
|
||||
f"Keithley output is on, compliance stays at {actual} V "
|
||||
f"(configured: {compliance_voltage} V)."
|
||||
)
|
||||
else:
|
||||
for command in (
|
||||
":SOUR:FUNC CURR",
|
||||
":SOUR:CURR:MODE FIX",
|
||||
":SOUR:CURR:RANG:AUTO OFF",
|
||||
f":SOUR:CURR:RANG {source_range_ma * 1e-3:.6E}",
|
||||
":SENS:FUNC:CONC ON",
|
||||
':SENS:FUNC:ON "VOLT","CURR"',
|
||||
f":SYST:RSEN {'ON' if remote_sense else 'OFF'}",
|
||||
":ARM:COUN 1",
|
||||
":TRIG:COUN 1",
|
||||
):
|
||||
self.socket_put(command)
|
||||
self.set_compliance_voltage(compliance_voltage)
|
||||
actual = self.get_compliance_voltage()
|
||||
if not np.isclose(actual, compliance_voltage):
|
||||
raise Keithley2400Error(
|
||||
f"Compliance is {actual} V, expected {compliance_voltage} V."
|
||||
)
|
||||
|
||||
error = self.socket_put_and_receive(":SYST:ERR?")
|
||||
if not error.startswith("0,"):
|
||||
raise Keithley2400Error(f"Instrument error after setup: {error}")
|
||||
|
||||
def output_enabled(self) -> bool:
|
||||
return self.socket_put_and_receive(":OUTP?") == "1"
|
||||
|
||||
def set_output(self, enabled: bool) -> None:
|
||||
self.socket_put(f":OUTP {'ON' if enabled else 'OFF'}")
|
||||
self._reading_time = 0.0
|
||||
|
||||
def get_level(self) -> float:
|
||||
"""Programmed source level in A."""
|
||||
return float(self.socket_put_and_receive(":SOUR:CURR?"))
|
||||
|
||||
def set_level(self, amps: float) -> None:
|
||||
self.socket_put(f":SOUR:CURR {amps:.6E}")
|
||||
self._reading_time = 0.0
|
||||
|
||||
def get_compliance_voltage(self) -> float:
|
||||
return float(self.socket_put_and_receive(":SENS:VOLT:PROT?"))
|
||||
|
||||
def set_compliance_voltage(self, volts: float) -> None:
|
||||
self.socket_put(f":SENS:VOLT:PROT {volts:.6E}")
|
||||
|
||||
def in_compliance(self) -> bool:
|
||||
return self.socket_put_and_receive(":SENS:VOLT:PROT:TRIP?") == "1"
|
||||
|
||||
@threadlocked
|
||||
def read_output(self, max_age: float | None = None) -> tuple[float, float]:
|
||||
"""Measured (volts, amps), (0, 0) with the output off."""
|
||||
max_age = self.READBACK_MAX_AGE if max_age is None else max_age
|
||||
if time.monotonic() - self._reading_time > max_age:
|
||||
if self.output_enabled():
|
||||
volts, amps = self.socket_put_and_receive(":READ?").split(",")[:2]
|
||||
self._reading = (float(volts), float(amps))
|
||||
else:
|
||||
self._reading = (0.0, 0.0)
|
||||
self._reading_time = time.monotonic()
|
||||
return self._reading
|
||||
|
||||
|
||||
class Keithley2400SignalBase(SocketSignal):
|
||||
def __init__(self, signal_name, **kwargs):
|
||||
self.signal_name = signal_name
|
||||
super().__init__(**kwargs)
|
||||
self.controller: Keithley2400Controller = self.parent.controller
|
||||
|
||||
|
||||
class Keithley2400SignalRO(Keithley2400SignalBase):
|
||||
def __init__(self, signal_name, **kwargs):
|
||||
super().__init__(signal_name, **kwargs)
|
||||
self._metadata["write_access"] = False
|
||||
|
||||
def _socket_set(self, val):
|
||||
raise ReadOnlyError("Read-only signals cannot be set")
|
||||
|
||||
|
||||
class Keithley2400ReadbackSignal(Keithley2400SignalRO):
|
||||
"""Measured current in mA."""
|
||||
|
||||
def _socket_get(self):
|
||||
return self.controller.read_output()[1] * 1e3
|
||||
|
||||
|
||||
class Keithley2400VoltageSignal(Keithley2400SignalRO):
|
||||
"""Measured voltage in V."""
|
||||
|
||||
def _socket_get(self):
|
||||
return self.controller.read_output()[0]
|
||||
|
||||
|
||||
class Keithley2400OutputSignal(Keithley2400SignalRO):
|
||||
def _socket_get(self):
|
||||
return int(self.controller.output_enabled())
|
||||
|
||||
|
||||
class Keithley2400ComplianceTrippedSignal(Keithley2400SignalRO):
|
||||
def _socket_get(self):
|
||||
return int(self.controller.in_compliance())
|
||||
|
||||
|
||||
class Keithley2400SetpointSignal(Keithley2400SignalBase):
|
||||
"""Programmed source level in mA."""
|
||||
|
||||
def _socket_get(self):
|
||||
return self.controller.get_level() * 1e3
|
||||
|
||||
def _socket_set(self, val):
|
||||
self.parent.check_value(val)
|
||||
self.controller.set_level(val * 1e-3)
|
||||
|
||||
|
||||
class Keithley2400ComplianceVoltageSignal(Keithley2400SignalBase):
|
||||
"""Voltage limit in V."""
|
||||
|
||||
def _socket_get(self):
|
||||
return self.controller.get_compliance_voltage()
|
||||
|
||||
def _socket_set(self, val):
|
||||
self.controller.set_compliance_voltage(val)
|
||||
|
||||
|
||||
class Keithley2400IsMoving(SignalRO):
|
||||
def set_is_moving(self, value: int) -> None:
|
||||
self._readback = value
|
||||
|
||||
|
||||
class Keithley2400(Device, PositionerBase):
|
||||
"""
|
||||
Keithley 2400 as a current source, positioner in mA.
|
||||
|
||||
Args:
|
||||
limits: Allowed source current in mA.
|
||||
tolerance: Allowed difference between measured and requested current in mA.
|
||||
compliance_voltage: Voltage limit in V.
|
||||
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.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["output_on", "output_off", "status"]
|
||||
|
||||
readback = Cpt(Keithley2400ReadbackSignal, signal_name="readback", kind="hinted")
|
||||
user_setpoint = Cpt(Keithley2400SetpointSignal, signal_name="setpoint")
|
||||
voltage = Cpt(Keithley2400VoltageSignal, signal_name="voltage", kind="normal")
|
||||
output_enabled = Cpt(Keithley2400OutputSignal, signal_name="output_enabled", kind="normal")
|
||||
compliance_tripped = Cpt(
|
||||
Keithley2400ComplianceTrippedSignal, signal_name="compliance_tripped", kind="normal"
|
||||
)
|
||||
compliance_voltage = Cpt(
|
||||
Keithley2400ComplianceVoltageSignal, signal_name="compliance_voltage", kind="config"
|
||||
)
|
||||
motor_is_moving = Cpt(Keithley2400IsMoving, value=0, kind="normal")
|
||||
high_limit_travel = Cpt(Signal, value=0, kind="omitted")
|
||||
low_limit_travel = Cpt(Signal, value=0, kind="omitted")
|
||||
|
||||
SUB_READBACK = "readback"
|
||||
SUB_CONNECTION_CHANGE = "connection_change"
|
||||
_default_sub = SUB_READBACK
|
||||
|
||||
SETTLE_TIMEOUT = 2.0 # s
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prefix="",
|
||||
*,
|
||||
name,
|
||||
kind=None,
|
||||
read_attrs=None,
|
||||
configuration_attrs=None,
|
||||
parent=None,
|
||||
host="mpc2844.psi.ch",
|
||||
port=3336,
|
||||
limits=(0.0, 3.0),
|
||||
tolerance: float = 0.01,
|
||||
compliance_voltage: float = 5.0,
|
||||
source_range_ma: float = 10.0,
|
||||
remote_sense: bool = False,
|
||||
socket_cls=SocketIO,
|
||||
device_manager=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.controller = Keithley2400Controller(
|
||||
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
|
||||
)
|
||||
self.controller.set_axis(axis=self, axis_nr=0)
|
||||
self.tolerance = tolerance
|
||||
self._compliance_voltage = compliance_voltage
|
||||
self._source_range_ma = source_range_ma
|
||||
self._remote_sense = remote_sense
|
||||
|
||||
super().__init__(
|
||||
prefix,
|
||||
name=name,
|
||||
kind=kind,
|
||||
read_attrs=read_attrs,
|
||||
configuration_attrs=configuration_attrs,
|
||||
parent=parent,
|
||||
**kwargs,
|
||||
)
|
||||
self.readback.name = self.name
|
||||
self.controller.subscribe(
|
||||
self._update_connection_state, event_type=self.SUB_CONNECTION_CHANGE
|
||||
)
|
||||
self._update_connection_state()
|
||||
self.low_limit_travel.put(limits[0])
|
||||
self.high_limit_travel.put(limits[1])
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def destroy(self):
|
||||
"""Close the socket. The output stays as it is."""
|
||||
self.controller.off(update_config=False)
|
||||
return super().destroy()
|
||||
|
||||
@property
|
||||
def limits(self):
|
||||
return (self.low_limit_travel.get(), self.high_limit_travel.get())
|
||||
|
||||
@property
|
||||
def low_limit(self):
|
||||
return self.limits[0]
|
||||
|
||||
@property
|
||||
def high_limit(self):
|
||||
return self.limits[1]
|
||||
|
||||
def check_value(self, pos):
|
||||
low_limit, high_limit = self.limits
|
||||
if not low_limit <= pos <= high_limit:
|
||||
raise LimitError(f"current={pos} mA not within limits {self.limits}")
|
||||
|
||||
def _update_connection_state(self, **kwargs):
|
||||
for walk in self.walk_signals():
|
||||
walk.item._metadata["connected"] = self.controller.connected
|
||||
|
||||
@raise_if_disconnected
|
||||
def move(self, position, wait=True, **kwargs) -> MoveStatus:
|
||||
"""Source ``position`` mA, switching the output on if it is off."""
|
||||
timeout = kwargs.pop("timeout", 10)
|
||||
status = super().move(position, timeout=timeout, **kwargs)
|
||||
self.user_setpoint.put(position, wait=False) # level first, then the output
|
||||
|
||||
def apply_and_finish():
|
||||
self.motor_is_moving.set_is_moving(1)
|
||||
error = None
|
||||
try:
|
||||
self.controller.set_output(True)
|
||||
self._wait_for_current(position)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
error = exc
|
||||
self.motor_is_moving.set_is_moving(0)
|
||||
val = self.readback.read()
|
||||
self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time())
|
||||
if error is None:
|
||||
self._done_moving(success=True)
|
||||
return
|
||||
# BEC forwards this exception to the client, _done_moving would lose the reason.
|
||||
logger.error(f"{self.name}: {error}")
|
||||
try:
|
||||
status.set_exception(error)
|
||||
except InvalidState:
|
||||
pass # stopped or timed out in the meantime
|
||||
self._reset_sub(self._SUB_REQ_DONE)
|
||||
|
||||
threading.Thread(target=apply_and_finish, daemon=True).start()
|
||||
if wait:
|
||||
status.wait()
|
||||
return status
|
||||
|
||||
def _wait_for_current(self, position: float) -> None:
|
||||
deadline = time.monotonic() + self.SETTLE_TIMEOUT
|
||||
while True:
|
||||
volts, amps = self.controller.read_output(max_age=0)
|
||||
if np.isclose(amps * 1e3, position, atol=self.tolerance, rtol=0):
|
||||
return
|
||||
if time.monotonic() > deadline:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
hint = (
|
||||
f" Voltage compliance reached at {volts} V: check the wiring, or the load needs "
|
||||
"more than the compliance voltage."
|
||||
if self.controller.in_compliance()
|
||||
else ""
|
||||
)
|
||||
raise Keithley2400Error(f"Requested {position} mA, measured {amps * 1e3} mA.{hint}")
|
||||
|
||||
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)
|
||||
|
||||
def output_off(self) -> None:
|
||||
"""Switch the output off. The programmed level is kept."""
|
||||
self.controller.set_output(False)
|
||||
|
||||
def status(self) -> None:
|
||||
"""Print the state of the instrument."""
|
||||
volts, amps = self.controller.read_output(max_age=0)
|
||||
table = PrettyTable()
|
||||
table.header = False
|
||||
table.add_rows(
|
||||
[
|
||||
["Output", "on" if self.controller.output_enabled() else "off"],
|
||||
["Programmed current", f"{self.controller.get_level() * 1e3:.4f} mA"],
|
||||
["Measured current", f"{amps * 1e3:.4f} mA"],
|
||||
["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"],
|
||||
]
|
||||
)
|
||||
print(table)
|
||||
|
||||
@property
|
||||
def egu(self):
|
||||
return "mA"
|
||||
@@ -333,6 +333,8 @@ Only actions from a fixed, reviewed registry can be queued — not arbitrary cod
|
||||
| --- | --- | --- |
|
||||
| `move` | Move device(s) to absolute position(s). Only devices on the allow-list below can be targeted. | `positions`: `{device: target}`, e.g. `{"mokev": 6.2}` |
|
||||
| `optimize_idgap` | Scan the undulator gap over a range and move to the peak. **Not yet implemented (no-op stub).** | `search_range`: mm, default 0.5, range 0–2 |
|
||||
| `set_keithley_current` | Set the Keithley 2400 current, switching its output on if it is off, then sleep. The queue pauses if the current is not reached, e.g. in voltage compliance. | `current_mA`: mA, range from the device limits; `sleep_s`: s, default 0 |
|
||||
| `sleep` | Wait. | `seconds`: s, default 60 |
|
||||
|
||||
Devices allowed for `move`: `mokev` (energy, keV), `idgap` (undulator gap, mm) — the
|
||||
same beamline-wide devices as flOMNI's queue. A `move` naming any other device is
|
||||
@@ -347,6 +349,15 @@ lamni.tomo_queue_add_command(
|
||||
)
|
||||
```
|
||||
|
||||
Example — change the sample current and wait for it to act, then wait further:
|
||||
```python
|
||||
lamni.tomo_queue_add_command(
|
||||
{"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 120}},
|
||||
label="1.5 mA",
|
||||
)
|
||||
lamni.tomo_queue_add_command({"action": "sleep", "kwargs": {"seconds": 600}}, label="wait 10 min")
|
||||
```
|
||||
|
||||
`tomo_queue_show()` lists command jobs alongside tomogram jobs, e.g.:
|
||||
```text
|
||||
[2] pending reconfigure to 6.2 keV CMD move{'positions': {'mokev': 6.2}} > optimize_idgap{'search_range': 0.5} [idem]
|
||||
|
||||
@@ -9,6 +9,8 @@ tomo-queue backend actually needs: self.client + _init_tomo_queue().
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
|
||||
import csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin as tomo_queue_mixin
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, LamNIError, _ProgressProxy
|
||||
@@ -438,3 +440,75 @@ def test_format_command_step_shows_units():
|
||||
move_step = {"action": "move", "kwargs": {"positions": {"ccm_energy": 6.2}}}
|
||||
assert tomo_queue_mixin.format_command_step(move_step, registry) == "move(ccm_energy=6.2 keV)"
|
||||
assert tomo_queue_mixin.format_command_step({"action": "noop", "kwargs": {}}) == "noop"
|
||||
|
||||
|
||||
def _setup_keithley_action(monkeypatch):
|
||||
fake_dev = _FakeDev()
|
||||
fake_dev.keithley = types.SimpleNamespace(limits=(0.0, 3.0))
|
||||
lamni = make_lamni()
|
||||
lamni.device_manager = types.SimpleNamespace(devices={"keithley": fake_dev.keithley})
|
||||
calls = []
|
||||
monkeypatch.setattr(lamni_module, "dev", fake_dev, raising=False)
|
||||
monkeypatch.setattr(lamni_module, "umv", lambda *a: calls.append(("umv", a)), raising=False)
|
||||
monkeypatch.setattr(lamni_module.time, "sleep", lambda s: calls.append(("sleep", s)))
|
||||
return lamni, fake_dev, calls
|
||||
|
||||
|
||||
def test_tomo_queue_set_keithley_current_sets_then_sleeps(monkeypatch):
|
||||
lamni, fake_dev, calls = _setup_keithley_action(monkeypatch)
|
||||
lamni.tomo_queue_add_command(
|
||||
{"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 30}}
|
||||
)
|
||||
lamni.tomo_queue_execute()
|
||||
assert calls == [("umv", (fake_dev.keithley, 1.5)), ("sleep", 30)]
|
||||
assert lamni._tomo_queue_proxy[0]["status"] == "done"
|
||||
assert lamni._tomo_queue_proxy[0]["idempotent"] is True
|
||||
|
||||
|
||||
def test_tomo_queue_set_keithley_current_without_sleep(monkeypatch):
|
||||
lamni, fake_dev, calls = _setup_keithley_action(monkeypatch)
|
||||
lamni._queue_action_set_keithley_current(0.0)
|
||||
assert calls == [("umv", (fake_dev.keithley, 0.0))]
|
||||
|
||||
|
||||
def test_tomo_queue_set_keithley_current_rejects_out_of_limit(monkeypatch):
|
||||
lamni, _fake_dev, calls = _setup_keithley_action(monkeypatch)
|
||||
with pytest.raises(ValueError, match="above the maximum"):
|
||||
lamni.tomo_queue_add_command(
|
||||
{"action": "set_keithley_current", "kwargs": {"current_mA": 3.5}}
|
||||
)
|
||||
with pytest.raises(ValueError, match="current limits"):
|
||||
lamni._queue_action_set_keithley_current(3.5)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_tomo_queue_set_keithley_current_without_device_pauses_the_queue(monkeypatch):
|
||||
lamni, fake_dev, calls = _setup_keithley_action(monkeypatch)
|
||||
del fake_dev.keithley
|
||||
lamni.device_manager.devices.clear()
|
||||
lamni.tomo_queue_add_command(
|
||||
{"action": "set_keithley_current", "kwargs": {"current_mA": 1.0}}
|
||||
)
|
||||
with pytest.raises(ValueError, match="not loaded"):
|
||||
lamni.tomo_queue_execute()
|
||||
assert lamni._tomo_queue_proxy[0]["status"] == "incomplete"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_tomo_queue_sleep_action(monkeypatch):
|
||||
lamni, _fake_dev, calls = _setup_keithley_action(monkeypatch)
|
||||
lamni.tomo_queue_add_command({"action": "sleep", "kwargs": {"seconds": 5}})
|
||||
lamni.tomo_queue_execute()
|
||||
assert calls == [("sleep", 5)]
|
||||
with pytest.raises(ValueError, match="below the minimum"):
|
||||
lamni.tomo_queue_add_command({"action": "sleep", "kwargs": {"seconds": -1}})
|
||||
|
||||
|
||||
def test_format_command_step_keithley_units():
|
||||
lamni = make_lamni()
|
||||
registry = lamni._describe_tomo_queue_actions()
|
||||
step = {"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 30}}
|
||||
assert (
|
||||
tomo_queue_mixin.format_command_step(step, registry)
|
||||
== "set_keithley_current(current_mA=1.5 mA, sleep_s=30 s)"
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The LamNI keithley and sleep queue actions must be buildable in the command job dialog, which
|
||||
is generated from the published action registry."""
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QDoubleSpinBox # noqa: E402
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI # noqa: E402
|
||||
from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( # noqa: E402
|
||||
CommandJobBuilderDialog,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialog(qapp):
|
||||
registry = {
|
||||
"actions": {
|
||||
name: {key: spec[key] for key in ("idempotent", "help", "params")}
|
||||
for name, spec in LamNI._TOMO_QUEUE_ACTIONS.items()
|
||||
},
|
||||
"move_devices": {},
|
||||
}
|
||||
client = mock.MagicMock()
|
||||
client.get_global_var.return_value = registry
|
||||
client.device_manager.devices = {"keithley": mock.MagicMock(limits=(0.0, 3.0))}
|
||||
return CommandJobBuilderDialog(client)
|
||||
|
||||
|
||||
def _add_step(dialog, action, **values):
|
||||
dialog._action_combo.setCurrentIndex(dialog._action_combo.findData(action))
|
||||
for name, value in values.items():
|
||||
widget, _field = dialog._step_field_widgets[name]
|
||||
assert isinstance(widget, QDoubleSpinBox)
|
||||
widget.setValue(value)
|
||||
dialog._on_add_step()
|
||||
|
||||
|
||||
def test_keithley_and_sleep_steps_build_a_queue_job(dialog):
|
||||
_add_step(dialog, "set_keithley_current", current_mA=1.5, sleep_s=30)
|
||||
_add_step(dialog, "sleep", seconds=60)
|
||||
dialog._on_accept()
|
||||
assert dialog.job["steps"] == [
|
||||
{"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 30}},
|
||||
{"action": "sleep", "kwargs": {"seconds": 60}},
|
||||
]
|
||||
assert dialog.job["idempotent"] is True
|
||||
|
||||
|
||||
def test_current_field_is_bounded_by_the_device_limits(dialog):
|
||||
dialog._action_combo.setCurrentIndex(dialog._action_combo.findData("set_keithley_current"))
|
||||
widget, _field = dialog._step_field_widgets["current_mA"]
|
||||
assert (widget.minimum(), widget.maximum()) == (0.0, 3.0)
|
||||
@@ -0,0 +1,191 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from ophyd.utils import LimitError
|
||||
from ophyd_devices.utils.controller import ControllerCommunicationError
|
||||
|
||||
from csaxs_bec.devices.keithley.keithley_2400 import (
|
||||
Keithley2400,
|
||||
Keithley2400Controller,
|
||||
Keithley2400Error,
|
||||
)
|
||||
|
||||
|
||||
class FakeKeithleySocket:
|
||||
"""Minimal 2400: replies CR LF terminated and split in two segments, as seen through the bridge."""
|
||||
|
||||
instances = []
|
||||
output = False
|
||||
level = 0.0
|
||||
source_function = "VOLT"
|
||||
compliance = 21.0
|
||||
max_volts = 100.0 # load limit, above it the instrument sits in compliance
|
||||
resistance = 1000.0
|
||||
|
||||
def __init__(self, host, port, socket_timeout=2):
|
||||
self.is_open = False
|
||||
self.commands = []
|
||||
self.replies = []
|
||||
type(self).instances.append(self)
|
||||
|
||||
def open(self, timeout=10):
|
||||
self.is_open = True
|
||||
|
||||
def close(self):
|
||||
self.is_open = False
|
||||
|
||||
def receive(self, buffer_length=1024):
|
||||
return self.replies.pop(0) if self.replies else b""
|
||||
|
||||
def _measured(self):
|
||||
volts = min(self.level * self.resistance, self.compliance, self.max_volts)
|
||||
return volts, volts / self.resistance
|
||||
|
||||
def put(self, msg):
|
||||
command = msg.decode().strip()
|
||||
self.commands.append(command)
|
||||
reply = {
|
||||
"*IDN?": "KEITHLEY INSTRUMENTS INC.,MODEL 2400,4009094,C32",
|
||||
":OUTP?": "1" if self.output else "0",
|
||||
":SOUR:FUNC?": self.source_function,
|
||||
":SOUR:CURR?": f"{self.level:E}",
|
||||
":SENS:VOLT:PROT?": f"{self.compliance:E}",
|
||||
":SYST:ERR?": '0,"No error"',
|
||||
}.get(command)
|
||||
if command == ":READ?":
|
||||
assert self.output, "the 2400 refuses :READ? with the output off"
|
||||
reply = "{:E},{:E}".format(*self._measured())
|
||||
elif command == ":SENS:VOLT:PROT:TRIP?":
|
||||
reply = "1" if self._measured()[0] < self.level * self.resistance else "0"
|
||||
elif command in (":OUTP ON", ":OUTP OFF"):
|
||||
type(self).output = command == ":OUTP ON"
|
||||
elif command == ":SOUR:FUNC CURR":
|
||||
type(self).source_function = "CURR"
|
||||
elif command.startswith(":SOUR:CURR ") and "RANG" not in command:
|
||||
type(self).level = float(command.split()[1])
|
||||
elif command.startswith(":SENS:VOLT:PROT "):
|
||||
type(self).compliance = float(command.split()[1])
|
||||
if reply is not None:
|
||||
data = f"{reply}\r\n".encode()
|
||||
self.replies += [part for part in (data[:3], data[3:]) if part]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def socket_cls():
|
||||
"""A fresh socket class per test, the instrument state lives on the class."""
|
||||
Keithley2400Controller._reset_controller()
|
||||
return type("Socket", (FakeKeithleySocket,), {"instances": []})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keithley(socket_cls):
|
||||
dev = Keithley2400(
|
||||
name="keithley", socket_cls=socket_cls, host="fake", port=1, device_manager=mock.MagicMock()
|
||||
)
|
||||
yield dev
|
||||
dev.controller.off(update_config=False)
|
||||
|
||||
|
||||
def commands(socket_cls):
|
||||
return socket_cls.instances[-1].commands
|
||||
|
||||
|
||||
def test_connect_with_output_off_configures_and_keeps_it_off(keithley, socket_cls):
|
||||
keithley.wait_for_connection()
|
||||
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))
|
||||
|
||||
|
||||
def test_connect_with_output_on_leaves_the_source_alone(keithley, socket_cls):
|
||||
socket_cls.output, socket_cls.level, socket_cls.source_function = True, 2e-3, "CURR"
|
||||
keithley.wait_for_connection()
|
||||
assert (socket_cls.output, socket_cls.level, socket_cls.compliance) == (True, 2e-3, 21.0)
|
||||
sent = commands(socket_cls)
|
||||
assert not any(cmd.startswith((":SOUR:FUNC ", ":SOUR:CURR", ":OUTP ", "*RST")) for cmd in sent)
|
||||
assert keithley.readback.get() == pytest.approx(2.0)
|
||||
assert keithley.user_setpoint.get() == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_connect_refuses_voltage_source_with_output_on(keithley, socket_cls):
|
||||
socket_cls.output = True
|
||||
with pytest.raises(Keithley2400Error, match="source function VOLT"):
|
||||
keithley.wait_for_connection()
|
||||
assert socket_cls.output is True
|
||||
|
||||
|
||||
def test_connect_refuses_other_instrument(keithley, socket_cls, monkeypatch):
|
||||
monkeypatch.setattr(keithley.controller, "socket_put_and_receive", lambda val: "SOMETHING ELSE")
|
||||
keithley.controller.on()
|
||||
with pytest.raises(Keithley2400Error, match="identification"):
|
||||
keithley.controller.configure(5.0, 10.0, False)
|
||||
|
||||
|
||||
def test_readbacks_with_output_off_do_not_trigger(keithley, socket_cls):
|
||||
keithley.wait_for_connection()
|
||||
assert keithley.readback.get() == 0.0
|
||||
assert keithley.voltage.get() == 0.0
|
||||
assert ":READ?" not in commands(socket_cls)
|
||||
|
||||
|
||||
def test_move_programs_the_level_before_the_output(keithley, socket_cls):
|
||||
keithley.wait_for_connection()
|
||||
keithley.move(1.5).wait(timeout=5)
|
||||
sent = commands(socket_cls)
|
||||
assert sent.index(":SOUR:CURR 1.500000E-03") < sent.index(":OUTP ON")
|
||||
assert keithley.readback.get() == pytest.approx(1.5)
|
||||
assert keithley.voltage.get() == pytest.approx(1.5)
|
||||
assert keithley.output_enabled.get() == 1
|
||||
|
||||
|
||||
def test_move_outside_limits_changes_nothing(keithley, socket_cls):
|
||||
keithley.wait_for_connection()
|
||||
with pytest.raises(LimitError):
|
||||
keithley.move(3.1)
|
||||
with pytest.raises(LimitError):
|
||||
keithley.user_setpoint.put(3.1)
|
||||
assert socket_cls.level == 0.0
|
||||
assert socket_cls.output is False
|
||||
|
||||
|
||||
def test_move_in_compliance_fails_with_the_reason(keithley, socket_cls, monkeypatch):
|
||||
monkeypatch.setattr(keithley, "SETTLE_TIMEOUT", 0.1)
|
||||
keithley.wait_for_connection()
|
||||
with pytest.raises(Keithley2400Error, match="compliance"):
|
||||
socket_cls.max_volts = 1.0
|
||||
keithley.move(2.0).wait(timeout=5)
|
||||
|
||||
|
||||
def test_output_off_keeps_the_level(keithley, socket_cls):
|
||||
keithley.wait_for_connection()
|
||||
keithley.move(1.0).wait(timeout=5)
|
||||
keithley.output_off()
|
||||
assert socket_cls.output is False
|
||||
assert socket_cls.level == pytest.approx(1e-3)
|
||||
assert keithley.readback.get() == 0.0
|
||||
|
||||
|
||||
def test_status_prints_the_state(keithley, socket_cls, capsys):
|
||||
keithley.wait_for_connection()
|
||||
keithley.status()
|
||||
assert "Compliance voltage" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_stop_and_destroy_leave_the_output_on(socket_cls):
|
||||
dev = Keithley2400(
|
||||
name="keithley", socket_cls=socket_cls, host="fake", port=1, device_manager=mock.MagicMock()
|
||||
)
|
||||
dev.wait_for_connection()
|
||||
dev.move(1.0).wait(timeout=5)
|
||||
dev.stop()
|
||||
dev.destroy()
|
||||
assert socket_cls.output is True
|
||||
assert socket_cls.level == pytest.approx(1e-3)
|
||||
|
||||
|
||||
def test_closed_connection_raises(keithley, socket_cls):
|
||||
keithley.controller.on()
|
||||
socket_cls.instances[-1].put = lambda msg: None
|
||||
with pytest.raises(ControllerCommunicationError):
|
||||
keithley.controller.socket_put_and_receive(":OUTP?")
|
||||
Reference in new issue
Block a user