Skip to content

Independent magnet axis - #2153

Draft
Relm-Arrowny wants to merge 105 commits into
mainfrom
independdent_magnet_axis
Draft

Independent magnet axis#2153
Relm-Arrowny wants to merge 105 commits into
mainfrom
independdent_magnet_axis

Conversation

@Relm-Arrowny

@Relm-Arrowny Relm-Arrowny commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fixes sm-blusky#352

@oliwenmandiamond Not clean yet, but this shows the general idea. Running the dodal connect tests helped me see why you wanted to break the axis into two devices, but to me they represent the field and its limits, so they belong together.

Changes

  • Replaced boolean self_moving with asyncio.Lock() and updated set_within_boundary.
  • Removed ramp_controller and integrated it into magnet axis as simple signal.
  • Made magnet axis standardReadable and without controller's set_within_boundary injection making it impossible to make a field movement without accessing controller._start_ramp
  • Added dynamic hardware limit checking so MovementStrategy checks live limits rather than hardcoded limits.
  • Move flyable into controller allow flyscan in cube and uniaxial mode.
  • Added dynamic step timeout calculations (calculate_timeout_per_step)
  • Added SuperConductingMagnet wrapper to preserve the user interface.

Instructions to reviewer on how to test:

  1. Do thing x
  2. Confirm thing y happens

Checks for reviewer

  • Would the PR title make sense to a scientist on a set of release notes
  • If a new device has been added does it follow the standards
  • If changing the API for a pre-existing device, ensure that any beamlines using this device have updated their Bluesky plans accordingly
  • Have the connection tests for the relevant beamline(s) been run via dodal connect ${BEAMLINE}

@Relm-Arrowny Relm-Arrowny changed the title Independdent magnet axis Independent magnet axis Aug 4, 2026
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.18%. Comparing base (1bad38a) to head (615a3e9).

Additional details and impacted files
@@               Coverage Diff                @@
##           add_i06_magnets    #2153   +/-   ##
================================================
  Coverage            99.18%   99.18%           
================================================
  Files                  359      358    -1     
  Lines                14219    14243   +24     
================================================
+ Hits                 14103    14127   +24     
  Misses                 116      116           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@oliwenmandiamond oliwenmandiamond left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see my comments.

Comment on lines +94 to 99
limits: MagnetPosition,
) -> None:

mag_pos_after_move = target.resolve_pos(current_readback)
self._check_epics_hardware_limits(mag_pos_after_move, limits)
if mag_pos_after_move.field_magnitude > self.LIMIT:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check_within_limits is done for every step that is produced by move_steps.

_check_epics_hardware_limits should be done once and independent of the these limits. The movement strategy is only checking if the total field or other bits that are mandated by the mode that the IOC does not check.

You have also duplicated the entire API calls. If this either done outside of this entirely, it is done once or if it put in a new method to the base MovementStrategy and then have the API call it once rather making every movement strategy implementation check it (which might be forgotton).

Comment on lines +92 to +99
self.readback = epics_signal_r(float, f"{prefix}-MAG-01:{axis}:RBV")
self.demand = epics_signal_rw(float, f"{prefix}-MAG-01:{axis}:DMD")
self.limit = epics_signal_r(float, f"{prefix}-SMC-0{axis_number}:LIM:FIELD:NOW")
with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
self.ramp_rate = epics_signal_rw(
float,
read_pv=f"{prefix}-SMC-0{axis_number}:STS:RAMPRATE:TPM",
write_pv=f"{prefix}-SMC-0{axis_number}:SET:DMD:RAMPRATE:TPM",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned before, this should not be done. You are combining lots of different base PV's to represent one device when this does not reflect the hardware.

Each magnet axis has a power supply associated with it which then defines the limits and ramp rates. This should be it's own device using -SMC-0X: and then use pass it in as reference to the axis of PV -MAG-01:

Comment on lines +347 to +403
@AsyncStatus.wrap
async def prepare(self, value: FlyVectorMagnetInfo) -> None:
"""Prepare the magnet for a fly scan.
The requested target position is validated against the limits of the current
magnet operating mode. The corresponding movement strategy then generates a
sequence of intermediate movement steps.
Each movement step is validated against the latest magnet readback before it
is applied. The readback position is updated after each step, ensuring that
subsequent steps are validated against the magnet's actual current position.
"""
self._fly_info = value
current_readback, mode, field_limit, ramp_rate_limit = await asyncio.gather(
self.get_readback_position(),
self.mode.get_value(),
self.get_field_limit(),
self.get_ramp_rate_limit(),
)
if mode in {
MagnetMode.UNIAXIAL_X,
MagnetMode.UNIAXIAL_Y,
MagnetMode.UNIAXIAL_Z,
MagnetMode.CUBIC,
}:
movement_strategy = self._MODE_MOVEMENT_STRATEGY.get(mode)
max_ramp_rate: float = getattr(ramp_rate_limit, value.fly_axis)
if movement_strategy is None:
raise ValueError(
f"No movement strategy has been configured for device {self.name} for mode {mode}."
)
if max_ramp_rate < value.ramp_rate or value.ramp_rate <= 0:
raise ValueError(
f"Requested ramp rate {value.ramp_rate} exceeds the ramp rate limit"
f" of {max_ramp_rate} for axis {value.fly_axis}."
)

start_req = MagnetRequest(**{value.fly_axis: value.start_position})
end_req = MagnetRequest(**{value.fly_axis: value.end_position})

movement_strategy.check_within_limits(
current_readback, start_req, field_limit
)

start_readback = dataclasses.replace(
current_readback, **{value.fly_axis: value.start_position}
)

movement_strategy.check_within_limits(start_readback, end_req, field_limit)

else:
raise ValueError(
f"Cannot prepare fly scan in mode {mode}. Only uniaxial and cubic modes are supported for now."
)
start_position = MagnetRequest(**{value.fly_axis: value.start_position})
await self.set_within_boundary(start_position)
fly_axis = getattr(self, value.fly_axis)
await fly_axis.ramp_rate.set(value.ramp_rate)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't agree with this approach.

If i go to fly the magnet controller and I forget to change mode, I might fly scan something completely unexpected.

e.g fly scan scmc, I don't know what I am actually fly scanning when running the plan, I have to have made sure I am in the right mode first which you can very easily not do.

Where as if I fly scan scmc.cart.x, I know for a fact I am always fly scanning x. If i fly scan scmc.cart.z, I am always fly scanning z. It just might throw error if in the wrong mode which is safer. The API is consistent and and never changes where as this one is dynamic and will cause problems down the road.

@Relm-Arrowny Relm-Arrowny Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check FlyVectorMagnetInfo, you tell the controller which axis you want to fly, this make it easy to add the other modes and axises.

class FlyVectorMagnetInfo(ConfinedModel):
    fly_axis: str = Field(frozen=True)
    """Axis to fly along, one of 'x', 'y' or 'z'. Todo theta, phi or rho."""
    start_position: float = Field(frozen=True)
    """Start position of the magnet move. in Tesla"""

    end_position: float = Field(frozen=True)
    """End position of the magnet move, in Tesla."""

    ramp_rate: float = Field(frozen=True, gt=0)
    """Ramp rate of the magnet move, in Tesla/s."""

Comment thread src/dodal/devices/beamlines/i06_1/magnet/superconducting_magnet.py
Base automatically changed from add_i06_magnets to main August 7, 2026 10:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants