Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f209651
Create Superconducting magnet device for i06-1
oliwenmandiamond Jun 30, 2026
b3f9037
correct pvs
oliwenmandiamond Jun 30, 2026
e004d75
Update to user own coordinate device
oliwenmandiamond Jun 30, 2026
a4812df
Complete implementation
oliwenmandiamond Jul 1, 2026
56a754d
Add to do comments
oliwenmandiamond Jul 1, 2026
d4f3dc7
Add SuperconductingMagnetController
oliwenmandiamond Jul 6, 2026
310be3b
Merge branch 'main' into add_i06_magnets
oliwenmandiamond Jul 6, 2026
71b1cc0
Add RampMagnetController
oliwenmandiamond Jul 7, 2026
ac72244
Move ramp controllers to own file
oliwenmandiamond Jul 7, 2026
770d400
Add read methods for spherical coordinates
oliwenmandiamond Jul 8, 2026
706c39c
Rename to ramp controllers to MagnetAxisRampRateController
oliwenmandiamond Jul 8, 2026
3487313
Add ramp_controller tests
oliwenmandiamond Jul 8, 2026
1deca95
Add tests for superconducting_magnet
oliwenmandiamond Jul 9, 2026
bc877e9
Merge branch 'main' into add_i06_magnets
oliwenmandiamond Jul 9, 2026
0f3829c
Merge branch 'main' into add_i06_magnets
oliwenmandiamond Jul 9, 2026
712ca91
Add individual spherical axis tests
oliwenmandiamond Jul 9, 2026
c2dc441
Merge branch 'add_i06_magnets' of ssh://github.com/DiamondLightSource…
oliwenmandiamond Jul 9, 2026
7da935c
Increased timeout to 600 seconds, no longer a signal
oliwenmandiamond Jul 9, 2026
27389fb
Add order of set test
oliwenmandiamond Jul 9, 2026
c45047f
Add missing code coverage
oliwenmandiamond Jul 9, 2026
de9bc5c
Andd Flyable and Prepare methods and tests
oliwenmandiamond Jul 10, 2026
1c359d5
Add doc strings to classes
oliwenmandiamond Jul 10, 2026
e526270
Refactor magnets to have moment strategies, add better mock behaviour
oliwenmandiamond Jul 14, 2026
7a55ad1
Remove commented out code
oliwenmandiamond Jul 14, 2026
917718d
Fix test
oliwenmandiamond Jul 14, 2026
7bb955b
Update doc strings and add logging
oliwenmandiamond Jul 14, 2026
d0282c6
Remove unneeded comment
oliwenmandiamond Jul 14, 2026
55446eb
Add better mocking behaviour
oliwenmandiamond Jul 15, 2026
424d5a3
Update SuperConductingMagnet to SuperConductingMagnetController
oliwenmandiamond Jul 15, 2026
c12f482
Add test for no movement strategy
oliwenmandiamond Jul 15, 2026
d11a7ee
Improve typing
oliwenmandiamond Jul 15, 2026
665095f
Add check_value logic for the ramp controllers
oliwenmandiamond Jul 15, 2026
f8b281e
Rename lakeshore devices to use cooling/heating
oliwenmandiamond Jul 15, 2026
928f0ad
Add missing code coverage
oliwenmandiamond Jul 15, 2026
7317776
Merge branch 'main' into add_i06_magnets
oliwenmandiamond Jul 15, 2026
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
22 changes: 20 additions & 2 deletions src/dodal/beamlines/i06_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline
from dodal.device_manager import DeviceManager
from dodal.devices.beamlines.i06_1 import DiffractionDichroism
from dodal.devices.beamlines.i06_1.magnets import (
MagnetThreeAxesRampRateController,
SuperConductingMagnetController,
)
from dodal.devices.motors import XYThetaStage
from dodal.devices.temperture_controller import Lakeshore336
from dodal.log import set_beamline as set_log_beamline
Expand All @@ -17,12 +21,12 @@


@devices.factory()
def diff_cooling_temperature_controller() -> Lakeshore336:
def ls336_cooling() -> Lakeshore336:
return Lakeshore336(prefix=f"{PREFIX.beamline_prefix}-EA-TCTRL-02:")


@devices.factory()
def diff_heating_temperature_controller() -> Lakeshore336:
def ls336_heating() -> Lakeshore336:
return Lakeshore336(prefix=f"{PREFIX.beamline_prefix}-EA-TCTRL-03:")


Expand All @@ -34,3 +38,17 @@ def xabs() -> XYThetaStage:
@devices.factory()
def dd() -> DiffractionDichroism:
return DiffractionDichroism(f"{PREFIX.beamline_prefix}-EA-DDIFF-01:")


@devices.factory()
def mag_ramp_rate() -> MagnetThreeAxesRampRateController:
return MagnetThreeAxesRampRateController(f"{PREFIX.beamline_prefix}-EA-SMC")


@devices.factory()
def scmc(
mag_ramp_rate: MagnetThreeAxesRampRateController,
) -> SuperConductingMagnetController:
return SuperConductingMagnetController(
f"{PREFIX.beamline_prefix}-EA-MAG-01:", mag_ramp_rate
)
27 changes: 27 additions & 0 deletions src/dodal/devices/beamlines/i06_1/magnets/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from .movement import MagnetPosition, MagnetPositionError, MagnetSphericalPosition
from .ramp_controller import (
MagnetAxisRampRateController,
MagnetThreeAxesRampRateController,
)
from .superconducting_magnet import (
FlyMagnetInfo,
MagnetAxis,
MagnetLimitStatus,
MagnetModes,
MagnetRampStatus,
SuperConductingMagnetController,
)

__all__ = [
"MagnetPosition",
"MagnetPositionError",
"MagnetSphericalPosition",
"MagnetAxisRampRateController",
"MagnetThreeAxesRampRateController",
"FlyMagnetInfo",
"MagnetAxis",
"MagnetLimitStatus",
"MagnetModes",
"MagnetRampStatus",
"SuperConductingMagnetController",
]
22 changes: 22 additions & 0 deletions src/dodal/devices/beamlines/i06_1/magnets/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from ophyd_async.core import StrictEnum


class MagnetModes(StrictEnum):
UNIAXIAL_X = "UNIAXIAL_X"
UNIAXIAL_Y = "UNIAXIAL_Y"
UNIAXIAL_Z = "UNIAXIAL_Z"
SPHERICAL = "SPHERICAL"
PLANAR_XZ = "PLANAR_XZ"
QUADRANT_XY = "QUADRANT_XY"
CUBIC = "CUBIC"
UNDEFINED = "UNDEFINED"


class MagnetRampStatus(StrictEnum):
RAMP_MADE = "RAMP MADE"
RAMPING = "RAMPING"


class MagnetLimitStatus(StrictEnum):
OK = "OK"
VIOLTATION = "VIOLATION"
187 changes: 187 additions & 0 deletions src/dodal/devices/beamlines/i06_1/magnets/movement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass


def read_theta(x: float, z: float) -> float:
theta = math.degrees(math.atan2(-x, z))
return theta % 360


def read_rho(x: float, y: float, z: float) -> float:
return math.hypot(x, y, z)


def read_phi(x: float, y: float, z: float) -> float:
return math.degrees(math.atan2(math.hypot(x, z), y))


@dataclass(kw_only=True)
class MagnetPosition:
x: float
y: float
z: float

def to_spherical(self) -> "MagnetSphericalPosition":
return MagnetSphericalPosition(
rho=read_rho(self.x, self.y, self.z),
theta=read_theta(self.x, self.z),
phi=read_phi(self.x, self.y, self.z),
)


@dataclass(kw_only=True)
class MagnetSphericalPosition:
rho: float
theta: float # degrees
phi: float # degrees

def to_cartesian(self) -> MagnetPosition:
theta = math.radians(self.theta)
phi = math.radians(self.phi)

return MagnetPosition(
x=-self.rho * math.sin(phi) * math.sin(theta),
y=self.rho * math.cos(phi),
z=self.rho * math.sin(phi) * math.cos(theta),
)


@dataclass(frozen=True, kw_only=True)
class MagnetStep:
x: float | None = None
y: float | None = None
z: float | None = None


class MagnetPositionError(Exception):
pass


class MovementStrategy(ABC):
@abstractmethod
def moves(
self, current: MagnetPosition, target: MagnetPosition
) -> list[MagnetStep]:
"""Return a sequence of safe moves."""


class SphericalMovement(MovementStrategy):
def moves(
self, current: MagnetPosition, target: MagnetPosition
) -> list[MagnetStep]:
steps: list[MagnetStep] = []

decreases = [
("z", current.z, target.z),
("x", current.x, target.x),
("y", current.y, target.y),
]

for axis, old, new in decreases:
if abs(new) < abs(old):
kwargs = {axis: new}
steps.append(MagnetStep(**kwargs))

if any(abs(new) > abs(old) for _, old, new in decreases):
steps.append(MagnetStep(x=target.x, y=target.y, z=target.z))

return steps


class CubicMovement(MovementStrategy):
LIMIT = 1.5

def moves(
self, current: MagnetPosition, target: MagnetPosition
) -> list[MagnetStep]:
for axis in (target.x, target.y, target.z):
if abs(axis) > self.LIMIT:
raise MagnetPositionError(
f"Target {target} outside cubic operating region limit of {self.LIMIT}"
)

return [MagnetStep(x=target.x, y=target.y, z=target.z)]


class PlanarXZMovement(MovementStrategy):
LIMIT = 2.0

def moves(
self, current: MagnetPosition, target: MagnetPosition
) -> list[MagnetStep]:
if target.y != 0:
raise MagnetPositionError(
f"Y must remain zero in XZ mode. Requested value was {target.y}"
)

hypot = math.hypot(target.x, target.z)
if hypot > self.LIMIT:
raise MagnetPositionError(
f"Outside XZ operating region. math.hypot(x={target.x}, y={target.y}) = {hypot}. Limit is {self.LIMIT}"
)

return [MagnetStep(x=target.x, z=target.z)]


@dataclass
class UniaxialMovement(MovementStrategy):
axis: str
limit: float

def moves(
self, current: MagnetPosition, target: MagnetPosition
) -> list[MagnetStep]:
coords = {"x": target.x, "y": target.y, "z": target.z}
for axis, value in coords.items():
if axis == self.axis:
abs_value = abs(value)
if abs_value > self.limit:
raise MagnetPositionError(
f"{axis} value of {abs_value} exceeds limit of {self.limit}"
)
elif value != 0:
raise MagnetPositionError(
f"{axis} must remain zero in {self.axis.upper()} mode. Requested value was {value} "
)

return [MagnetStep(**{self.axis: coords[self.axis]})]


class QuadrantXYMovement(MovementStrategy):
"""Start from zero field in all axes. Appy 2.o T in + y direction. Move field
direction through positive quadrant to 2.0 T in the +x direction. At all times
| B | <= 2.0 T. Ramp field back to zero in x direction.
"""

LIMIT = 2.0

def moves(
self, current: MagnetPosition, target: MagnetPosition
) -> list[MagnetStep]:
if target.z != 0:
raise MagnetPositionError(
f"Z must remain zero in QUADRANT_XY mode. You provided {target.z}"
)

hypot = math.hypot(target.x, target.y)
if hypot > self.LIMIT:
raise MagnetPositionError(
f"Target math.hypot(x={target.x}, y={target.y}) {hypot} is outside the XY operating region limit of {self.LIMIT}."
)

steps: list[MagnetStep] = []

# Step 1: remove X component first
if current.x != 0:
steps.append(MagnetStep(x=0))

# Step 2: move Y
if current.y != target.y:
steps.append(MagnetStep(y=target.y))

# Step 3: move X to its final value
if target.x != 0:
steps.append(MagnetStep(x=target.x))

return steps
76 changes: 76 additions & 0 deletions src/dodal/devices/beamlines/i06_1/magnets/ramp_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from dataclasses import dataclass
from functools import cached_property

from ophyd_async.core import (
InstantMovableMock,
MovableLogic,
SignalR,
StandardMovable,
StandardReadable,
StandardReadableFormat,
TimeoutCalculator,
default_mock_class,
set_mock_value,
)
from ophyd_async.epics.core import epics_signal_rw


@dataclass
class RampRateMovableLogic(MovableLogic[float]):
limit: SignalR[float]

async def check_move(self, new_position: float) -> None:
limit = await self.limit.get_value()
if new_position > limit:
raise ValueError(
f"Requested ramp rate {new_position} exceeds the maximum limit of {limit} for device {self.readback.name}."
)

async def move(self, new_position: float, timeout: TimeoutCalculator):
await self.setpoint.set(new_position, timeout=timeout())

Comment thread
oliwenmandiamond marked this conversation as resolved.

class MockMagnetAxisRampRateController(InstantMovableMock):
async def connect(self, device: StandardMovable):
await super().connect(device)
# Extend to set a sensible default value for the limit.
set_mock_value(device.limit, 2) # type: ignore


@default_mock_class(MockMagnetAxisRampRateController)
class MagnetAxisRampRateController(StandardMovable[float], StandardReadable):
"""Controls the ramp rate of a single superconducting magnet axis.

Exposes the readback ramp rate, demand ramp rate and the maximum
permitted ramp rate for one magnet axis.
"""

def __init__(self, prefix: str, name: str = ""):
with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
Comment thread
oliwenmandiamond marked this conversation as resolved.
self.readback = epics_signal_rw(float, prefix + "STS:RAMPRATE:TPM")
self.demand = epics_signal_rw(float, prefix + "SET:DMD:RAMPRATE:TPM")
self.limit = epics_signal_rw(float, prefix + "LIM:RAMPRATE:TPM")
super().__init__(name)

@cached_property
def movable_logic(self) -> RampRateMovableLogic:
return RampRateMovableLogic(
readback=self.readback, setpoint=self.demand, limit=self.limit
)


class MagnetThreeAxesRampRateController(StandardReadable):
"""Groups the ramp rate controllers for the x, y and z magnet axes.

This device is passed to :class:`SuperConductingMagnetController` so that each
:class:`MagnetAxis` can configure its own ramp rate during preparation for
fly scans.
"""

def __init__(self, prefix: str, name: str = ""):
with self.add_children_as_readables():
self.x = MagnetAxisRampRateController(prefix + "-01:")
self.y = MagnetAxisRampRateController(prefix + "-02:")
self.z = MagnetAxisRampRateController(prefix + "-03:")

super().__init__(name)
Loading
Loading