diff --git a/src/pdftools/profiles/collection.py b/src/pdftools/profiles/collection.py index a96b457..f5e1280 100644 --- a/src/pdftools/profiles/collection.py +++ b/src/pdftools/profiles/collection.py @@ -26,6 +26,11 @@ from pdftools.lakeshore336 import LakeShore336 from pdftools.motors import Mirror, OCMTable, OpticsTableADC, SideBounceMono, Slits from pdftools.shutter import FastShutter, FilterBank +from pdftools.temperature import ( # noqa: F401 for the session + default_loop, + move_and_continue, + t_list, +) from pdftools.utils import ( CI_ENV_VAR, ProposalIDPrompt, diff --git a/src/pdftools/temperature.py b/src/pdftools/temperature.py new file mode 100644 index 0000000..b6ae23c --- /dev/null +++ b/src/pdftools/temperature.py @@ -0,0 +1,100 @@ +"""Temperature-controller plans for the PDF cryostat and furnaces. + +``default_loop``, ``move_and_continue`` and ``t_list`` are ported from +pdf-profile-collection ``startup/101-Lakeshore336Cryostat.py`` (Cheng-Hung Lin, +129831d), keeping their names. +""" + +import warnings +from collections.abc import Sequence + +from bluesky import plan_stubs as bps +from bluesky.utils import FailedStatus + +from pdftools.eurotherm3k import Eurotherm3kLoop +from pdftools.lakeshore336 import ( + LakeShore336Loop, + Lakeshore336LoopMode, + Lakeshore336RangeSelect, + Lakeshore336Resistance, + Lakeshore336Switch, +) +from pdftools.utils import ensure_available + +TemperatureController = Eurotherm3kLoop | LakeShore336Loop + + +def move_and_continue(device: TemperatureController, target: float): + """Move ``device`` to ``target``; if it does not settle in time, warn and go on. + + The RunEngine re-raises a failed move into the plan as ``FailedStatus``, with + the device's own exception as ``__cause__``. Only a ``TimeoutError`` is let + through, so an out-of-range setpoint or a lost connection still stops the plan. + + Returns + ------- + FailedStatus or None + the failure if the move timed out, else ``None`` + """ + try: + yield from bps.mv(device, target) + except FailedStatus as e: + if not isinstance(e.__cause__, TimeoutError): + raise + warnings.warn( + f"{device.name} did not settle at {target} ({e.__cause__!r}); continuing", + RuntimeWarning, + stacklevel=2, + ) + return e + return None + + +def t_list(tlist: Sequence[float], controller: LakeShore336Loop | None = None): + """Visit each temperature in turn, going on past any that does not settle. + + Parameters + ---------- + tlist : list of float + the setpoints, in K, in the order to visit them + controller : LakeShore336Loop, optional + the loop to move; the session's ``cryostat`` if not given + + Returns + ------- + list of (float, FailedStatus) + the setpoints that did not settle, with why + """ + controller = ensure_available(LakeShore336Loop, cryostat=controller) + failures = [] + for temp in tlist: + exc = yield from move_and_continue(controller, temp) + if exc is not None: + failures.append((temp, exc)) + if failures: + print(f"{len(failures)}/{len(tlist)} setpoints did not settle:") + for temp, exc in failures: + print(f" {temp}: {exc.__cause__!r}") + return failures + + +def default_loop(loop: LakeShore336Loop): + """Put a Lake Shore loop in its standard PID state and turn its heater on. + + The setpoint is first set to the current temperature, so nothing moves + when the heater comes on. Then the loop reads its own input, ramps at + 6 K/min in PID mode with a 25 ohm heater and maximum-current setting 2, + and finally the heater range is set to 3. + """ + current = yield from bps.rd(loop.loop_input.temp) + yield from bps.mv(loop.sp, current) + yield from bps.mv( + loop.loop_in_sel, loop.loop_input.channel, + loop.ramp_enbl, Lakeshore336Switch.ON, + loop.ramp, 6, + loop.enbl, Lakeshore336Switch.ON, + loop.loop_mode, Lakeshore336LoopMode.PID, + loop.maxi, 2, + loop.resistance, Lakeshore336Resistance.LO25, + ) # fmt: skip + yield from bps.mv(loop.range, Lakeshore336RangeSelect.RANGE3) diff --git a/src/pdftools/xrd.py b/src/pdftools/xrd.py index 406236c..b91c3d4 100644 --- a/src/pdftools/xrd.py +++ b/src/pdftools/xrd.py @@ -29,13 +29,12 @@ from ophyd_async.plan_stubs import ensure_connected from ophyd_async.sim import SimMotor -from pdftools.eurotherm3k import Eurotherm3kLoop +from pdftools.lakeshore336 import LakeShore336Loop from pdftools.shutter import FastShutter +from pdftools.temperature import TemperatureController, move_and_continue from pdftools.utils import ensure_available Steppable = Motor | SimMotor -# Moving one waits until its reading has settled at the setpoint. -TemperatureController = Eurotherm3kLoop def ensure_cont_acq_exposure(detector: StandardDetector, exposure_time: float): @@ -459,6 +458,7 @@ def xrd_temperature_series( num_iterations: int = 1, time_gap: float = 0.0, ramp_rate: float | None = None, + continue_if_unsettled: bool = False, sample_name: str | None = None, description: str | None = None, use_shutter: bool = True, @@ -470,15 +470,18 @@ def xrd_temperature_series( For each temperature the controller is moved, which waits until its reading has settled there (set ``tolerance`` and ``settle_time`` on the controller, e.g. ``eurotherm.loop1.tolerance = 0.5``), then the images are taken as in - ``xrd_count``. The shutter is closed while the temperature changes. Replaces - pdf-profile-collection ``pbar_set_temp`` followed by a count. + ``xrd_count``. The shutter is closed while the temperature changes. Each + start document records the setpoint, the temperature read just before the + run and whether the move settled. Replaces pdf-profile-collection + ``pbar_set_temp`` followed by a count, and the cryostat's + ``move_and_continue`` followed by a count. Parameters ---------- detectors : list of detectors the detectors to read, e.g. ``[pe1c]`` controller : temperature controller - the loop to set, e.g. ``eurotherm.loop1`` + the loop to set, e.g. ``eurotherm.loop1`` or ``cryostat`` temperatures : list of float the setpoints, in the order to visit them exposure_time : float @@ -493,7 +496,10 @@ def xrd_temperature_series( idle time between runs at one temperature, in seconds ramp_rate : float, optional ramp rate to set once before the first temperature, in the controller's - units; left as it is if not given + units (K/min for the Lake Shore); left as it is if not given + continue_if_unsettled : bool + if a move times out before settling, warn and take the images anyway + instead of stopping; any other move failure still stops the plan sample_name : str, optional name of the sample description : str, optional @@ -512,22 +518,31 @@ def _body(): for det in detectors: yield from ensure_cont_acq_exposure(det, exposure_time) if ramp_rate is not None: - yield from bps.mv(controller.ramp_rate, ramp_rate) + if isinstance(controller, LakeShore336Loop): + yield from bps.mv(controller.ramp, ramp_rate) + else: + yield from bps.mv(controller.ramp_rate, ramp_rate) for setpoint in temperatures: if use_shutter: yield from bps.mv(shutter, "Close") - yield from bps.mv(controller, setpoint) + if continue_if_unsettled: + failure = yield from move_and_continue(controller, setpoint) + settled = failure is None + else: + yield from bps.mv(controller, setpoint) + settled = True if use_shutter: yield from bps.mv(shutter, "Open") for i in range(num_iterations): if i > 0: yield from bps.sleep(time_gap) - readback = yield from bps.rd(controller.readback) + location = yield from bps.locate(controller) run_md = _md("xrd_temperature_series", sample_name, description, md) run_md["temperature"] = { "controller": controller.name, "setpoint": setpoint, - "readback": readback, + "readback": location["readback"], + "settled": settled, } yield from _count_runs( detectors, diff --git a/tests/conftest.py b/tests/conftest.py index 5cca9de..3cd8221 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,8 @@ from tiled.client.container import Container from tiled.server import SimpleTiledServer +from pdftools.lakeshore336 import LakeShore336, LakeShore336LoopLogic + _ALLOWED_PYTEST_TASKS = {"async_finalizer", "async_setup", "async_teardown"} @@ -226,3 +228,19 @@ async def _buffer_frames(value): # each run opens a new file, which starts the count again callback_on_mock_put(tiff.capture, lambda _: set_mock_value(tiff.num_captured, 0)) return pe + + +@pytest.fixture +def fast_lake(RE: RunEngine) -> LakeShore336: + """The PDF Lake Shore, mocked; out1 settles in 0.05 s and follows its setpoint.""" + with init_devices(mock=True): + lake = LakeShore336("XF:28ID1-ES{LS336:1", name="Lakeshore") + logic = lake.out1.movable_logic + assert isinstance(logic, LakeShore336LoopLogic) + logic.settle_time = 0.05 + set_mock_value(lake.input_c.temp, 80.0) + set_mock_value(lake.out1.ramp, 6.0) + callback_on_mock_put( + lake.out1.sp, lambda value, **_: set_mock_value(lake.input_c.temp, value) + ) + return lake diff --git a/tests/test_temperature.py b/tests/test_temperature.py new file mode 100644 index 0000000..01743ba --- /dev/null +++ b/tests/test_temperature.py @@ -0,0 +1,89 @@ +"""Cheng-Hung's Lake Shore helper plans against a mocked cryostat.""" + +import pytest +from bluesky.run_engine import RunEngine +from ophyd_async.core import ( + callback_on_mock_put, + get_mock_put, + set_mock_value, +) + +from pdftools.lakeshore336 import ( + LakeShore336, + Lakeshore336LoopInput, + LakeShore336LoopLogic, + Lakeshore336LoopMode, + Lakeshore336RangeSelect, + Lakeshore336Resistance, + Lakeshore336Switch, +) +from pdftools.temperature import default_loop, move_and_continue, t_list + + +def _stick_at(lake: LakeShore336, stuck_at: float) -> None: + def _reach(value, **_): + if value != stuck_at: + set_mock_value(lake.input_c.temp, value) + + callback_on_mock_put(lake.out1.sp, _reach) + + +def test_default_loop_holds_the_temperature_before_turning_the_heater_on( + RE: RunEngine, fast_lake: LakeShore336 +): + loop = fast_lake.out1 + sets: list[tuple[str, object]] = [] + + def _hook(msg): + if msg.command == "set": + sets.append((msg.obj.name, msg.args[0])) + + RE.msg_hook = _hook # pyright: ignore[reportAttributeAccessIssue] + try: + RE(default_loop(loop)) + finally: + RE.msg_hook = None + assert sets[0] == (loop.sp.name, 80.0) + assert sets[-1] == (loop.range.name, Lakeshore336RangeSelect.RANGE3) + assert set(sets[1:-1]) == { + (loop.loop_in_sel.name, Lakeshore336LoopInput.C), + (loop.ramp_enbl.name, Lakeshore336Switch.ON), + (loop.ramp.name, 6), + (loop.enbl.name, Lakeshore336Switch.ON), + (loop.loop_mode.name, Lakeshore336LoopMode.PID), + (loop.maxi.name, 2), + (loop.resistance.name, Lakeshore336Resistance.LO25), + } + + +def test_t_list_visits_each_temperature(RE: RunEngine, fast_lake: LakeShore336): + RE(t_list([90.0, 100.0], fast_lake.out1)) + assert [c.args[0] for c in get_mock_put(fast_lake.out1.sp).call_args_list] == [ + 90.0, + 100.0, + ] + + +def test_t_list_goes_on_past_a_temperature_that_did_not_settle( + RE: RunEngine, fast_lake: LakeShore336, monkeypatch: pytest.MonkeyPatch, capsys +): + async def _short(self, old_position, new_position): + return 0.3 + + monkeypatch.setattr(LakeShore336LoopLogic, "calculate_timeout", _short) + _stick_at(fast_lake, 150.0) + with pytest.warns(RuntimeWarning, match="did not settle at 150.0"): + RE(t_list([150.0, 100.0], fast_lake.out1)) + assert [c.args[0] for c in get_mock_put(fast_lake.out1.sp).call_args_list] == [ + 150.0, + 100.0, + ] + assert "1/2 setpoints did not settle" in capsys.readouterr().out + + +def test_move_and_continue_stops_on_other_failures( + RE: RunEngine, fast_lake: LakeShore336 +): + with pytest.raises(Exception, match="outside"): + RE(move_and_continue(fast_lake.out1, 600.0)) + assert get_mock_put(fast_lake.out1.sp).call_count == 0 diff --git a/tests/test_xrd_temperature.py b/tests/test_xrd_temperature.py index fec1ede..28f6b9a 100644 --- a/tests/test_xrd_temperature.py +++ b/tests/test_xrd_temperature.py @@ -13,6 +13,7 @@ from ophyd_async.epics.adcore import ContAcqDetector from pdftools.eurotherm3k import Eurotherm3k +from pdftools.lakeshore336 import LakeShore336 from pdftools.shutter import FastShutter, OnOff from pdftools.xrd import xrd_temperature_series @@ -74,7 +75,12 @@ async def test_xrd_temperature_series_counts_at_each_settled_temperature( assert {s["plan_name"] for s in starts} == {"xrd_temperature_series"} assert {s["sample_name"] for s in starts} == {"Ni"} assert [s["temperature"] for s in starts[::2]] == [ - {"controller": "eurotherm-loop1", "setpoint": t, "readback": t + 0.2} + { + "controller": "eurotherm-loop1", + "setpoint": t, + "readback": t + 0.2, + "settled": True, + } for t in (100.0, 200.0, 300.0) ] # closed while the temperature moves, open only to count @@ -165,3 +171,78 @@ def _drift(name, doc): 100.4, 100.7, ] + + +async def test_xrd_temperature_series_counts_with_the_lakeshore_cryostat( + RE: RunEngine, pe: ContAcqDetector, fs: FastShutter, fast_lake: LakeShore336 +): + cryostat = fast_lake.out1 + callback_on_mock_put( + cryostat.sp, + lambda value, **_: set_mock_value(fast_lake.input_c.temp, value + 0.3), + ) + docs, _ = _run( + RE, + xrd_temperature_series( + [pe], cryostat, [90.0, 100.0], 0.1, ramp_rate=3.0, shutter=fs + ), + ) + assert [c.args[0] for c in get_mock_put(cryostat.sp).call_args_list] == [ + 90.0, + 100.0, + ] + assert [c.args[0] for c in get_mock_put(cryostat.ramp).call_args_list] == [3.0] + assert [s["temperature"] for s in docs["start"]] == [ + { + "controller": "Lakeshore-out1", + "setpoint": t, + "readback": t + 0.3, + "settled": True, + } + for t in (90.0, 100.0) + ] + + +async def test_xrd_temperature_series_can_count_where_it_did_not_settle( + RE: RunEngine, pe: ContAcqDetector, fs: FastShutter, eurotherm: Eurotherm3k +): + loop = eurotherm.loop1 + callback_on_mock_put(loop.setpoint, lambda *_, **__: None) # stays at 25 + loop.move_timeout = 0.2 + with pytest.warns(RuntimeWarning, match="eurotherm-loop1 did not settle"): + docs, _ = _run( + RE, + xrd_temperature_series( + [pe], loop, [300.0, 310.0], 0.1, continue_if_unsettled=True, shutter=fs + ), + ) + assert [s["temperature"] for s in docs["start"]] == [ + { + "controller": "eurotherm-loop1", + "setpoint": t, + "readback": 25.0, + "settled": False, + } + for t in (300.0, 310.0) + ] + assert await fs.status.get_value() == OnOff.ON + + +async def test_xrd_temperature_series_stops_on_other_move_failures( + RE: RunEngine, pe: ContAcqDetector, fs: FastShutter, fast_lake: LakeShore336 +): + docs: dict[str, list[Any]] = {} + with pytest.raises(Exception, match="outside"): + RE( + xrd_temperature_series( + [pe], + fast_lake.out1, + [600.0], + 0.1, + continue_if_unsettled=True, + shutter=fs, + ), + lambda name, doc: docs.setdefault(name, []).append(doc), + ) + assert "start" not in docs + assert await fs.status.get_value() == OnOff.ON