diff --git a/src/pdftools/lakeshore336.py b/src/pdftools/lakeshore336.py new file mode 100644 index 0000000..1c1e0cb --- /dev/null +++ b/src/pdftools/lakeshore336.py @@ -0,0 +1,317 @@ +# ruff: noqa: D101, D102 +"""Lake Shore 336 temperature controller (the PDF cryostat, ``XF:28ID1-ES{LS336:1}``). + +Ported from pdf-profile-collection ``startup/101-Lakeshore336Cryostat.py`` +(Cheng-Hung Lin, 129831d), keeping its names, PID tables and limits. The PV +suffixes and enum states match the live IOC's records (``lakeshore336`` on +xf28id1-inst-ioc1, read 2026-10-26). +""" + +import asyncio +from bisect import bisect +from dataclasses import dataclass, field +from functools import cached_property +from math import isclose +from time import perf_counter +from typing import Annotated as A + +from ophyd_async.core import ( + MovableLogic, + Reference, + SignalR, + SignalRW, + StandardMovable, + StandardReadable, + StrictEnum, + TimeoutCalculator, + derived_signal_r, +) +from ophyd_async.core import StandardReadableFormat as Format +from ophyd_async.epics.core import EpicsDevice, PvSuffix + + +class Lakeshore336Switch(StrictEnum): + OFF = "OFF" + ON = "ON" + + +class Lakeshore336LoopMode(StrictEnum): + OFF = "OFF" + PID = "PID" + ZONE = "ZONE" + OLOOP = "OPEN LOOP" + MON = "MONITOR" + WARM = "WARMUP" + + +class Lakeshore336RangeSelect(StrictEnum): + OFF = "OFF" + RANGE1 = "RANGE 1" + RANGE2 = "RANGE 2" + RANGE3 = "RANGE 3" + RANGE4 = "RANGE 4" + RANGE5 = "RANGE 5" + + +class Lakeshore336Resistance(StrictEnum): + LO25 = "25 OHM" + HI50 = "50 OHM" + + +class Lakeshore336LoopInput(StrictEnum): + NONE = "NONE" + A = "A" + B = "B" + C = "C" + D = "D" + D2 = "D2" + D3 = "D3" + D4 = "D4" + D5 = "D5" + + +class LakeShore336Input(EpicsDevice, StandardReadable): + """One Lake Shore 336 temperature input.""" + + temp: A[SignalR[float], PvSuffix("T-I"), Format.HINTED_SIGNAL] + tempc: A[SignalR[float], PvSuffix("T:C-I")] + raw: A[SignalR[float], PvSuffix("Val:Sens-I")] + status: A[SignalR[str], PvSuffix("T-Sts")] + + def __init__( + self, prefix: str, channel: Lakeshore336LoopInput, *, name: str = "" + ) -> None: + # The input's own identity, whichever loop (if any) reads it. + self.channel = channel + super().__init__(prefix, name=name) + + +# PID gains (P, I, D) chosen by target temperature (K) and move direction, +# as tuned on the PDF cryostat. +HEATING_THRESHOLDS = [140, 270, 300] +HEATING_PIDS = [(50, 10, 1), (25, 6, 3), (26, 5, 3), (26, 5, 3)] +COOLING_THRESHOLDS = [140, 200] +COOLING_PIDS = [(25, 4, 3), (25, 6, 3), (35, 8, 3)] +MIN_TEMPERATURE = 5 +MAX_TEMPERATURE = 550 + + +@dataclass +class LakeShore336LoopLogic(MovableLogic[float]): + """What a move means for one Lake Shore control loop.""" + + tolerance: float + settle_time: float + move_timeout: float + p: SignalRW[float] + i: SignalRW[float] + d: SignalRW[float] + ramp: SignalRW[float] + delta: float = field(default=0.0, init=False) + + async def check_move(self, new_position: float) -> None: + if not MIN_TEMPERATURE <= new_position <= MAX_TEMPERATURE: + raise ValueError( + f"{new_position} K is outside {MIN_TEMPERATURE}-{MAX_TEMPERATURE} K" + ) + + async def delta_t(self, old_position: float, new_position: float) -> None: + self.delta = new_position - old_position + + async def set_pids(self, new_position: float) -> None: + if self.delta > 0.5: + temp_thresholds, pids = HEATING_THRESHOLDS, HEATING_PIDS + elif self.delta < -0.5: + temp_thresholds, pids = COOLING_THRESHOLDS, COOLING_PIDS + else: + return + _p, _i, _d = pids[bisect(temp_thresholds, new_position)] + await self.p.set(_p) + await self.i.set(_i) + await self.d.set(_d) + + async def calculate_timeout( + self, old_position: float, new_position: float + ) -> float: + delta = new_position - old_position + print(f"Delta = {delta:.2f}") + + ramp_rate = await self.ramp.get_value() # K/min + if ramp_rate == 0: + ramp_rate = 0.015 * 60 # 0.015 K/s + travel_time = abs(delta) / ramp_rate * 60 + timeout = travel_time + self.settle_time + 30 + print(f"{timeout:.2f} Seconds to complete the temp change") + return timeout + + async def move(self, new_position: float, timeout: TimeoutCalculator) -> None: + """Write the setpoint and wait for the readback to settle around it.""" + start_time = perf_counter() + original_temp = await self.readback.get_value() + print(f"Changing temp from {original_temp:.2f} to {new_position:.2f}") + await self.delta_t(original_temp, new_position) + + loop = asyncio.get_running_loop() + settled = asyncio.Event() + settle_timer: asyncio.TimerHandle | None = None + + def update_settled_state(reading) -> None: + nonlocal settle_timer + value = reading[self.readback.name]["value"] + in_tolerance = isclose( + value, new_position, rel_tol=0.0, abs_tol=self.tolerance + ) + # The timer starts on entering tolerance and is cancelled on leaving it. + if in_tolerance and settle_timer is None: + settle_timer = loop.call_later(self.settle_time, settled.set) + elif not in_tolerance and settle_timer is not None: + settle_timer.cancel() + settle_timer = None + settled.clear() + + # Subscribe before writing so that a fast readback update cannot be missed. + self.readback.subscribe_reading(update_settled_state) + try: + async with asyncio.timeout(timeout()): + await self.set_pids(new_position) + await self.setpoint.set(new_position) + await settled.wait() + minutes = (perf_counter() - start_time) / 60 + print(f"Temp change completed in {minutes:.2f} mins") + finally: + if settle_timer is not None: + settle_timer.cancel() + self.readback.clear_sub(update_settled_state) + + +class LakeShore336Loop(EpicsDevice, StandardReadable, StandardMovable[float]): + """One heater/control loop: setpoint, PID, heater range, ramp settings.""" + + enbl: A[SignalRW[str], PvSuffix("Enbl-Sel")] # the loop's general on/off + sp: A[SignalRW[float], PvSuffix("T-SP"), Format.CONFIG_SIGNAL] + sprdk: A[SignalR[float], PvSuffix("T-RB"), Format.HINTED_SIGNAL] + p: A[ + SignalRW[float], + PvSuffix(write_suffix="Gain:P-SP", read_suffix="Gain:P-RB"), + Format.CONFIG_SIGNAL, + ] + i: A[ + SignalRW[float], + PvSuffix(write_suffix="Gain:I-SP", read_suffix="Gain:I-RB"), + Format.CONFIG_SIGNAL, + ] + d: A[ + SignalRW[float], + PvSuffix(write_suffix="Gain:D-SP", read_suffix="Gain:D-RB"), + Format.CONFIG_SIGNAL, + ] + ramp: A[ + SignalRW[float], + PvSuffix(write_suffix="Val:Ramp-SP", read_suffix="Val:Ramp-RB"), + Format.CONFIG_SIGNAL, + ] + range: A[ + SignalRW[str], + PvSuffix(write_suffix="Val:Range-Sel", read_suffix="Val:Range-Sts"), + Format.CONFIG_SIGNAL, + ] + maxi: A[SignalRW[float], PvSuffix("Out:MaxI-SP")] + loop_mode: A[SignalRW[str], PvSuffix("Mode-Sel"), Format.CONFIG_SIGNAL] + resistance: A[SignalRW[str], PvSuffix("Out:R-SP"), Format.CONFIG_SIGNAL] + ramp_enbl: A[SignalRW[str], PvSuffix("Enbl:Ramp-Sel"), Format.CONFIG_SIGNAL] + autotune: A[ + SignalRW[str], + PvSuffix(write_suffix="Mode:ATune-Sel", read_suffix="Mode:ATune-Sts"), + ] + loop_in_sel: A[ + SignalRW[Lakeshore336LoopInput], PvSuffix("Out-Sel"), Format.CONFIG_SIGNAL + ] + loop_in_rb: A[ + SignalR[Lakeshore336LoopInput], PvSuffix("Out-Sts"), Format.CONFIG_SIGNAL + ] + + def __init__( + self, + prefix: str, + loop_input: LakeShore336Input, + *, + tolerance: float = 0.1, + settle_time: float = 5.0, + move_timeout: float = 600.0, + name: str = "", + ) -> None: + # A Reference, so the input (a child of LakeShore336) is not re-parented. + self._loop_input_ref = Reference(loop_input) + self._tolerance = tolerance + self._settle_time = settle_time + self._move_timeout = move_timeout + super().__init__(prefix, name=name) + self.add_readables([self.movable_logic.readback], Format.HINTED_SIGNAL) + + @property + def loop_input(self) -> LakeShore336Input: + return self._loop_input_ref() + + async def select_input(self) -> None: + """Point the loop's PID input at the channel ``loop_input`` was built with.""" + await self.loop_in_sel.set(self.loop_input.channel) + + async def get_loop_temp_k(self) -> float: + return await self.loop_input.temp.get_value() + + @cached_property + def movable_logic(self) -> MovableLogic[float]: + # A private mirror of the input's temperature: StandardMovable renames its + # readback after this loop, which would rename the shared input signal. + def temp(temp: float) -> float: + return temp + + return LakeShore336LoopLogic( + setpoint=self.sp, + readback=derived_signal_r(temp, temp=self.loop_input.temp), + tolerance=self._tolerance, + settle_time=self._settle_time, + move_timeout=self._move_timeout, + p=self.p, + i=self.i, + d=self.d, + ramp=self.ramp, + ) + + +class LakeShore336(StandardReadable): + """Lake Shore 336: eight inputs (A-D, D2-D5) and four control loops.""" + + def __init__(self, prefix: str, *, name: str = "") -> None: + with self.add_children_as_readables(): + self.input_a = LakeShore336Input( + f"{prefix}-Chan:A}}", Lakeshore336LoopInput.A + ) + self.input_b = LakeShore336Input( + f"{prefix}-Chan:B}}", Lakeshore336LoopInput.B + ) + self.input_c = LakeShore336Input( + f"{prefix}-Chan:C}}", Lakeshore336LoopInput.C + ) + self.input_d = LakeShore336Input( + f"{prefix}-Chan:D}}", Lakeshore336LoopInput.D + ) + self.input_d2 = LakeShore336Input( + f"{prefix}-Chan:D2}}", Lakeshore336LoopInput.D2 + ) + self.input_d3 = LakeShore336Input( + f"{prefix}-Chan:D3}}", Lakeshore336LoopInput.D3 + ) + self.input_d4 = LakeShore336Input( + f"{prefix}-Chan:D4}}", Lakeshore336LoopInput.D4 + ) + self.input_d5 = LakeShore336Input( + f"{prefix}-Chan:D5}}", Lakeshore336LoopInput.D5 + ) + self.out1 = LakeShore336Loop( + f"{prefix}-Out:1}}", self.input_c, tolerance=1, settle_time=120 + ) + self.out2 = LakeShore336Loop(f"{prefix}-Out:2}}", self.input_a) + self.out3 = LakeShore336Loop(f"{prefix}-Out:3}}", self.input_a) + self.out4 = LakeShore336Loop(f"{prefix}-Out:4}}", self.input_a) + super().__init__(name=name) diff --git a/src/pdftools/profiles/collection.py b/src/pdftools/profiles/collection.py index f2f3c03..a96b457 100644 --- a/src/pdftools/profiles/collection.py +++ b/src/pdftools/profiles/collection.py @@ -23,6 +23,7 @@ import pdftools from pdftools.detectors import XSPDetector from pdftools.eurotherm3k import Eurotherm3k +from pdftools.lakeshore336 import LakeShore336 from pdftools.motors import Mirror, OCMTable, OpticsTableADC, SideBounceMono, Slits from pdftools.shutter import FastShutter, FilterBank from pdftools.utils import ( @@ -172,6 +173,8 @@ # Eurotherm 3504, as in pdf-profile-collection 16-eurotherm_HAB; # the old eurotherm3504 is loop1 eurotherm = Eurotherm3k("XF:28ID1-ES{ET:05}", name="eurotherm") + # Lake Shore 336 cryostat, as in pdf-profile-collection 101-Lakeshore336Cryostat + lake = LakeShore336("XF:28ID1-ES{LS336:1", name="Lakeshore") # Optics, as in pdf-profile-collection 12-optics OCM_slits = Slits("XF:28ID1B-OP{Slt:2-Ax:", name="OCM_slits") @@ -183,6 +186,9 @@ optics_table_adc = OpticsTableADC("XF:28ID1B-ES{Tbl:1-Ax:", name="optics_table_adc") OCM_table = OCMTable("XF:28ID1B-ES{OCM-Ax:", name="OCM_table") +# Outside init_devices, which would rename lake.out1 to "cryostat" +cryostat = lake.out1 + # Add baseline=[, , ...] to SupplementalData as devices arrive. sd = bpp.SupplementalData() RE.preprocessors.append(sd) diff --git a/tests/test_lakeshore336.py b/tests/test_lakeshore336.py new file mode 100644 index 0000000..68e562b --- /dev/null +++ b/tests/test_lakeshore336.py @@ -0,0 +1,230 @@ +"""Lake Shore 336 (PDF cryostat) against mocked PVs, pinned to the live IOC.""" + +import time + +import pytest +from bluesky import plan_stubs as bps +from bluesky.run_engine import RunEngine +from ophyd_async.core import ( + Device, + SignalR, + StandardReadable, + callback_on_mock_put, + get_mock_put, + init_devices, + set_mock_put_proceeds, + set_mock_value, +) + +from pdftools.lakeshore336 import ( + LakeShore336, + LakeShore336Input, + LakeShore336Loop, + Lakeshore336LoopInput, + Lakeshore336LoopMode, + Lakeshore336RangeSelect, + Lakeshore336Resistance, + Lakeshore336Switch, +) + +# Suffixes served per input and per loop by the lakeshore336 IOC on +# xf28id1-inst-ioc1 (its records.dbl, 2026-10-26). +IOC_INPUT_SUFFIXES = { + "Alrm-Calc_", "Alrm:High-Sts", "Alrm:Low-Sts", "Cmd:Fltr-Calc_", "Cmd:Fltr-Sel", + "Cmd:Fltr-Sts", "FOut", "T:C-I", "T-I", "T:Lim-RB", "T:Lim-SP", "T:Name-RB", + "T:Name-SP", "T-Sts", "Val:FltrP-RB", "Val:FltrP-SP", "Val:FltrW-RB", + "Val:FltrW-SP", "Val:Sens-I", +} # fmt: skip +IOC_LOOP_SUFFIXES = { + "Alrm-Calc_", "Enbl:Ramp-Calc_", "Enbl:Ramp-Sel", "Enbl:Ramp-Sts", "Enbl-Sel", + "Enbl-Sts", "Err:Htr-Sts", "FOut", "Gain-Calc_", "Gain:D-RB", "Gain:D-SP", + "Gain:I-RB", "Gain:I-SP", "Gain:P-RB", "Gain:P-SP", "Mode:ATune-Sel", + "Mode:ATune-Sts", "Mode-Calc_", "Mode-Sel", "Mode-Sts", "Out:Disp-RB", + "Out:Disp-SP", "Out-I", "Out:Man-RB", "Out:Man-SP", "Out:MaxI-RB", "Out:MaxI-SP", + "Out:Max-RB", "Out:Max-SP", "Out:R-Calc_", "Out:R-RB", "Out:R-SP", "Out-Sel", + "Out-Sts", "Sts:Ramp-Sts", "T-RB", "T-SP", "Val:Ramp-RB", "Val:Ramp-SP", + "Val:Range-Sel", "Val:Range-Sts", +} # fmt: skip +# Enum states of the same IOC's lakeshore336_loop.db records +IOC_STATES = { + Lakeshore336Switch: ["OFF", "ON"], # Enbl-Sel, Enbl:Ramp-Sel + Lakeshore336LoopMode: ["OFF", "PID", "ZONE", "OPEN LOOP", "MONITOR", "WARMUP"], + Lakeshore336RangeSelect: [ + "OFF", "RANGE 1", "RANGE 2", "RANGE 3", "RANGE 4", "RANGE 5", + ], + Lakeshore336Resistance: ["25 OHM", "50 OHM"], + Lakeshore336LoopInput: ["NONE", "A", "B", "C", "D", "D2", "D3", "D4", "D5"], +} # fmt: skip + + +class _Cryostat(StandardReadable): + """One input and one loop, settling fast enough for a test.""" + + def __init__(self, prefix: str, name: str = "") -> None: + with self.add_children_as_readables(): + self.input_c = LakeShore336Input( + f"{prefix}-Chan:C}}", Lakeshore336LoopInput.C + ) + self.out1 = LakeShore336Loop( + f"{prefix}-Out:1}}", self.input_c, tolerance=1, settle_time=0.05 + ) + super().__init__(name=name) + + +@pytest.fixture +def lake(RE: RunEngine) -> LakeShore336: + with init_devices(mock=True): + lake = LakeShore336("XF:28ID1-ES{LS336:1", name="Lakeshore") + return lake + + +@pytest.fixture +def cryostat(RE: RunEngine) -> _Cryostat: + with init_devices(mock=True): + cryostat = _Cryostat("XF:28ID1-ES{LS336:1", name="cryostat") + set_mock_value(cryostat.input_c.temp, 80.0) + set_mock_value(cryostat.out1.ramp, 6.0) + # the loop drives the input it reads to the new setpoint + callback_on_mock_put( + cryostat.out1.sp, + lambda value, **_: set_mock_value(cryostat.input_c.temp, value), + ) + return cryostat + + +def _epics_signals(device: Device) -> list[SignalR]: + found = [] + for _, child in device.children(): + if isinstance(child, SignalR) and child.source.startswith("mock+ca://"): + found.append(child) + elif not isinstance(child, SignalR): + found.extend(_epics_signals(child)) + return found + + +def test_every_pv_is_served_by_the_pdf_ioc(lake: LakeShore336): + inputs = [lake.input_a, lake.input_b, lake.input_c, lake.input_d] + inputs += [lake.input_d2, lake.input_d3, lake.input_d4, lake.input_d5] + loops = [lake.out1, lake.out2, lake.out3, lake.out4] + for devices, suffixes, kind in ( + (inputs, IOC_INPUT_SUFFIXES, "Chan"), + (loops, IOC_LOOP_SUFFIXES, "Out"), + ): + for device in devices: + signals = _epics_signals(device) + assert len(signals) >= 4, device.name + for signal in signals: + pv = signal.source.removeprefix("mock+ca://") + prefix, suffix = pv.split("}", 1) + assert prefix.startswith(f"XF:28ID1-ES{{LS336:1-{kind}:"), pv + assert suffix in suffixes, pv + + +def test_loops_read_the_inputs_of_the_old_profile(lake: LakeShore336): + assert lake.out1.loop_input is lake.input_c + assert {lake.out2.loop_input, lake.out3.loop_input, lake.out4.loop_input} == { + lake.input_a + } + + +@pytest.mark.parametrize("enum", list(IOC_STATES)) +def test_enum_states_match_the_ioc(enum): + assert [member.value for member in enum] == IOC_STATES[enum] + + +async def test_select_input_points_the_loop_at_its_input(lake: LakeShore336): + await lake.out1.select_input() + assert await lake.out1.loop_in_sel.get_value() == Lakeshore336LoopInput.C + + +async def test_readback_mirrors_the_input_and_is_named_after_the_loop( + lake: LakeShore336, +): + set_mock_value(lake.input_c.temp, 77.0) + location = await lake.out1.locate() + assert location["readback"] == 77.0 + assert lake.out1.movable_logic.readback.name == "Lakeshore-out1" + assert lake.input_c.temp.name == "Lakeshore-input_c-temp" + + +@pytest.mark.parametrize( + ("start", "target", "pid"), + [ + (80.0, 100.0, (50, 10, 1)), # heating, below 140 K + (250.0, 280.0, (26, 5, 3)), # heating, 270-300 K + (250.0, 150.0, (25, 6, 3)), # cooling, 140-200 K + (250.0, 100.0, (25, 4, 3)), # cooling, below 140 K + ], +) +async def test_a_move_sets_the_pids_for_its_direction_and_settles( + RE: RunEngine, cryostat: _Cryostat, start, target, pid +): + set_mock_value(cryostat.input_c.temp, start) + RE(bps.mv(cryostat.out1, target)) + out1 = cryostat.out1 + assert await out1.sp.get_value() == target + assert ( + await out1.p.get_value(), + await out1.i.get_value(), + await out1.d.get_value(), + ) == pid + + +async def test_a_small_move_keeps_the_pids(RE: RunEngine, cryostat: _Cryostat): + set_mock_value(cryostat.out1.p, 1.0) + RE(bps.mv(cryostat.out1, 80.3)) + assert await cryostat.out1.p.get_value() == 1.0 + + +async def test_the_timeout_allows_for_the_ramp_and_the_settle(cryostat: _Cryostat): + logic = cryostat.out1.movable_logic + # 60 K at 6 K/min, plus the settle time, plus 30 s + assert await logic.calculate_timeout(80.0, 140.0) == pytest.approx(630.05) + + +@pytest.mark.parametrize("target", [4.0, 551.0]) +def test_moves_outside_5_to_550_k_are_refused( + RE: RunEngine, cryostat: _Cryostat, target +): + with pytest.raises(Exception, match="outside 5-550 K"): + RE(bps.mv(cryostat.out1, target)) + get_mock_put(cryostat.out1.sp).assert_not_called() + + +async def test_a_move_that_never_settles_times_out(cryostat: _Cryostat): + callback_on_mock_put(cryostat.out1.sp, lambda *_, **__: None) + with pytest.raises(TimeoutError): + await cryostat.out1.set(100.0, timeout=0.2) + + +async def test_with_the_ramp_off_the_timeout_assumes_0_015_k_per_second( + cryostat: _Cryostat, +): + set_mock_value(cryostat.out1.ramp, 0.0) + logic = cryostat.out1.movable_logic + # 60 K at 0.015 K/s, plus the settle time, plus 30 s + assert await logic.calculate_timeout(80.0, 140.0) == pytest.approx(4030.05) + + +async def test_a_move_with_its_own_timeout_still_sets_the_pids(cryostat: _Cryostat): + await cryostat.out1.set(100.0, timeout=5.0) + out1 = cryostat.out1 + gains = (await out1.p.get_value(), await out1.i.get_value()) + assert (*gains, await out1.d.get_value()) == (50, 10, 1) + + +async def test_the_timeout_also_bounds_the_setpoint_write(cryostat: _Cryostat): + set_mock_put_proceeds(cryostat.out1.sp, False) + start = time.monotonic() + with pytest.raises(TimeoutError): + await cryostat.out1.set(100.0, timeout=0.2) + assert time.monotonic() - start < 2.0 + set_mock_put_proceeds(cryostat.out1.sp, True) + + +async def test_the_loop_reads_its_measured_temperature(cryostat: _Cryostat): + set_mock_value(cryostat.input_c.temp, 77.0) + reading = await cryostat.out1.read() + assert reading["cryostat-out1"]["value"] == 77.0 + assert "cryostat-out1" in await cryostat.out1.describe() + assert "cryostat-out1" in cryostat.out1.hints.get("fields", [])