Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/pdftools/profiles/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
100 changes: 100 additions & 0 deletions src/pdftools/temperature.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +42 to +43
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)
37 changes: 26 additions & 11 deletions src/pdftools/xrd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}


Expand Down Expand Up @@ -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
89 changes: 89 additions & 0 deletions tests/test_temperature.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading