Skip to content

Support for mod-360 omega#1929

Open
rtuck99 wants to merge 23 commits intomainfrom
mx-bluesky_1598_mod_360
Open

Support for mod-360 omega#1929
rtuck99 wants to merge 23 commits intomainfrom
mx-bluesky_1598_mod_360

Conversation

@rtuck99
Copy link
Copy Markdown
Contributor

@rtuck99 rtuck99 commented Feb 19, 2026

Required for

Introduces a wrapped_omega Readable to XYZOmegaStage which delegates motion to the underlying omega motor.

wrapped_omega has two signals:

  • phase which is read-write and represents the phase angle with respect to an offset, which is a multiple of 360 degrees.
  • offset_and_phase which returns a numpy array containing the offset and phase values together. This can be retrieved in plans and events and used to instantiate an instance of AngleWithPhase which can be used as a convenient value-object helper for conversion and manipulation of phase angles, while retaining information about the mapping to the underlying unwrapped real coordinate.

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}

@codecov
Copy link
Copy Markdown

codecov bot commented Feb 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.09%. Comparing base (fe5c9c0) to head (41e09c5).
⚠️ Report is 5 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1929   +/-   ##
=======================================
  Coverage   99.08%   99.09%           
=======================================
  Files         319      320    +1     
  Lines       12408    12506   +98     
=======================================
+ Hits        12295    12393   +98     
  Misses        113      113           

☔ View full report in Codecov by Sentry.
📢 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
Copy link
Copy Markdown
Contributor

Sorry to jump in here, but I am working on a StandardMovable in ophyd-async which looks like a perfect use case for your ModMotor and caught my eye. Allows us to create Motor like things in a standard way. bluesky/ophyd-async#1200

Secondly, I don't think you should be altering the existing XYZOmegaStage. It will make the type ModMotor | Motor which isn't a good idea when this isn't standard behaviour for beamline stages. This is MX specific behaviour, therefore you should create a specific one like ModMotorXYZOmegaStage for example.

@rtuck99 rtuck99 changed the title Mx bluesky 1598 mod 360 Support for mod-360 omega Feb 24, 2026
Copy link
Copy Markdown
Contributor

@jacob720 jacob720 left a comment

Choose a reason for hiding this comment

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

Looks good! Logic is clean and easy to follow

Comment thread src/dodal/devices/motors.py Outdated
self.omega = Motor(prefix + omega_infix)
real_motor = Motor(prefix + omega_infix)
self.omega = real_motor
self.omega_axis = WrappedAxis(self.omega)
Copy link
Copy Markdown
Contributor

@jacob720 jacob720 Feb 27, 2026

Choose a reason for hiding this comment

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

Should something similar be added to XYZThetaStage or anything else with a rotational axis?

Comment thread tests/devices/test_motors.py
Copy link
Copy Markdown
Contributor

@DominicOram DominicOram left a comment

Choose a reason for hiding this comment

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

Thanks, some comments

Comment thread src/dodal/devices/smargon.py Outdated
only come back after the motion on that axis finished.
"""
await self.defer_move.set(DeferMoves.ON)
# TODO something something i03 broken smargon serialise moves workaround
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.

Should: Can this be an issue please?

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.

See #1998

await axis.user_setpoint.get_value(), new_setpoint
)
put_completion = await set_and_wait_for_value(
axis.user_setpoint,
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.

Should: I don't really like the check against the name "omega" above, it would be much cleaner if we can make the omega object more like a thing that just handles the logic itself. I think if we change the phase signal to be user_setpoint in WrappedAxis then we can just remove the logic in here for getting the target value and we can set it like any other motor?

Comment thread src/dodal/devices/motors.py Outdated
self.omega = Motor(prefix + omega_infix)
real_motor = Motor(prefix + omega_infix)
self.omega = real_motor
self.omega_axis = WrappedAxis(self.omega)
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.

Should: I agree with @oliwenmandiamond that we shouldn't be polluting the common XYZOmegaStage with this logic. We should do that overriding when we instantiate the smargon. Maybe the cleanest way to do this is to change these collection classes to protocols?

Copy link
Copy Markdown
Contributor

@oliwenmandiamond oliwenmandiamond Mar 2, 2026

Choose a reason for hiding this comment

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

I think having a protocol is overkill as were effectively defining the device twice, more boilerplate.

It should be done like this:

class MyCustomStage(XYZOmegaStage):

    def __init__(
        self,
        prefix: str,
        name: str = "",
        x_infix: str = _X,
        y_infix: str = _Y,
        z_infix: str = _Z,
        omega_infix: str = _OMEGA,
    ) -> None:
        super().__init__(prefix, name, x_infix, y_infix, z_infix, omega_infix)

        with self.add_children_as_readables():
            self.omega_axis = WrappedAxis(self.omega)

This is how every beamline has defined stage. If it is common and generic, it goes in dodal/devices/motors.py.

If it is beamline specific, it should be defined as dodal/devices/beamlines/iXX/my_device.py. For example i05, https://github.com/DiamondLightSource/dodal/blob/main/src/dodal/devices/beamlines/i05/i05_motors.py and i05_1 https://github.com/DiamondLightSource/dodal/blob/main/src/dodal/devices/beamlines/i05_1/i05_1_motors.py

If this is used mx wide, it should go in dodal/devices/my_mx_specific_device.py

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 think it should be a protocol 'cos I think we should be overriding omega completely, rather than creating a second wrapped axis. I think this helps downstream plans not worry so much about this logic and just use omega, making them more generic across MX

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.

Okay, I see. I was referring to the current implementation.

Yes a protocol could work. If this could wait for [StandardMovable](https://github.com/bluesky/ophyd-async/pull/1200), you could make the protocol

class XYZOmega(Protocol):
    x: StandardMovable
    y: StandardMovable
    z: StandardMovable
    omega: StandardMovable

Then you could implement your own MovableLogic rather than use the MotorMovableLogic, then it doesn't matter what the implementation is for the plan.

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.

I think that making omega motor use a wrapped coordinate system is a Bad Idea - this would be a potentially breaking change for plans that use this motor, and the changes might not be obvious to users that are not consciously considering the implications.

I have updated the PR and now all the plans that are in use by Hyperion are wrapped-omega aware, so a change to make omega a wrapped axis would be relatively trivial. However there would potentially be an impact on other beamlines if they inherit from XYZOmegaStage.

I don't think that Motors with wrapped coordinate systems are appropriate, since speed limits and motor limits don't make sense within the wrapped coordinate space. Certainly it is not a drop-in replacement for the current unwrapped coordinate motor for all the reasons I have listed in wrapped_axis.py and I would not be in favour of it.

If you really want to replace omega with the wrapped equivalent, then I would strongly advise doing it in a device class that is not going to be used outside of MX to minimise the impact. Currently some of the MX plans are used by e.g. the aithre pin tip centring which do not have a standard gonio, therefore the base class we accept is XYZOmegaStage so at a minimum they would need to use whatever base device class we decide to use, and they would have to ensure their plans weren't affected by a change to a wrapped omega. Since having access to only the phase angle represents a loss of information, unless all impacted plans can be adapted to use only the phase angle, it is likely that it will still be necessary to provide access to the underlying unwrapped angle.

As it currently stands adding a new wrapped axis is essentially free to any device and has no implications other than requiring the underlying axis to have unconstrained rotation, and the possibility that wrapped-unaware plans calling wrapped-aware plans may sometimes find the axis 360 degrees from where they might have expected it, which for most cases will not be an issue.

On the other hand, it is also possible to argue that we could do away with WrappedAxis altogether since you can always compute everything from the unwrapped coordinate space. However I do think it is nice to be able to have the phase angle accessible at the device level as a convenience to set directly (since for the majority of moves, this is the coordinate space of interest), and to expose as a Readable in bluesky events, as well as making it more obvious in the code when we are considering phase angle as opposed to absolute angle.

return value

@AsyncStatus.wrap
async def set(self, value: CombinedMove):
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.

Should: I'm not sure I like that the co-ordinate system of this set is different to that of omega.set. I think it is possible to get the underlying motor object to always be moving based on phase.

Copy link
Copy Markdown
Contributor Author

@rtuck99 rtuck99 Apr 7, 2026

Choose a reason for hiding this comment

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

I think making the underlying motor object represent phase is a bad idea for reasons I will state elsewhere in this PR, however I agree that having the omega coordinate as the only wrapped value in CombinedMove is somewhat ugly. The way I see, it the options are:

  • CombinedMove.omega is unwrapped and the plan unwraps the coordinate before specifying it - also ugly, and means you have to read the current position in the plan.
  • CombinedMove.omega_phase becomes a thing and you can specify the phase there, specifying omega_phase
  • We remove support for setting omega from CombinedMove - following Smargon combined moves should perform omega moves separately #1998 it seems the consensus is that omega moves should be serialized anyway because we've decided there are likely hardware limitations that mean we can't reliably do simultaneous fast omega moves at the same time as other axes. I think this is what I will ultimately do in the follow-on PR
  • We implement a separate move_to_robot_load which just moves to 0,0,0,0,0,mod-360 - since this is the only time we need to do the combined move apart from moving to the next xtal. We could do this in conjunction with # 3 if we want to make this an explicit smargon feature, but I think it's not strictly necessary.

Comment thread src/dodal/common/maths.py Outdated
Comment on lines +211 to +253
class WrappedAxis(StandardReadable):
def __init__(self, real_motor: Motor, name=""):
self._real_motor = Reference(real_motor)
with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
self.offset_and_phase = derived_signal_rw(
self._get_motor_offset_and_phase,
self._set_motor_offset_and_phase,
motor_pos=real_motor,
)
with self.add_children_as_readables():
self.phase = derived_signal_rw(
self._get_phase, self._set_phase, offset_and_phase=self.offset_and_phase
)
super().__init__(name=name)

def _get_motor_offset_and_phase(self, motor_pos: float) -> MotorOffsetAndPhase:
angle = AngleWithPhase.wrap(motor_pos)
return np.array([angle.offset, angle.phase])

async def _set_motor_offset_and_phase(self, value: MotorOffsetAndPhase):
await self._real_motor().set(
AngleWithPhase.from_offset_and_phase(value).unwrap()
)

def _get_phase(self, offset_and_phase: MotorOffsetAndPhase) -> float:
return offset_and_phase[1].item()

async def _set_phase(self, value: float):
"""Set the motor phase to the specified phase value in degrees.
The motor will travel via the shortest distance path.
"""
offset_and_phase = await self.offset_and_phase.get_value()
current_position = AngleWithPhase.from_offset_and_phase(offset_and_phase)
target_value = current_position.nearest_to_phase(value).unwrap()
await self._real_motor().set(target_value)

def distance(self, theta1_deg: float, theta2_deg: float) -> float:
"""Obtain the shortest distance between theta2 and theta1 in degrees.
This will be the shortest distance in mod360 space (i.e. always <= 180 degrees).
"""
return AngleWithPhase.wrap(theta1_deg).phase_distance(
AngleWithPhase.wrap(theta2_deg)
)
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 isn't common maths. This is a device and should live with where you define your custom stage.

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.

I have moved this

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.

Thanks for separating it out from the maths. However, the wrapped_axis.,py still needs to live under dodal/devices/wrapped_axis.py as it is a device.

@rtuck99 rtuck99 force-pushed the mx-bluesky_1598_mod_360 branch from 585d794 to a95aa88 Compare March 18, 2026 10:40
@rtuck99 rtuck99 force-pushed the mx-bluesky_1598_mod_360 branch from a95aa88 to b610d52 Compare April 2, 2026 12:55
@rtuck99 rtuck99 marked this pull request as ready for review April 7, 2026 15:25
@rtuck99 rtuck99 requested a review from a team as a code owner April 7, 2026 15:25
Comment thread src/dodal/devices/wrapped_axis.py
Comment thread src/dodal/devices/motors.py
Comment thread tests/devices/test_motors.py Outdated
Comment thread tests/devices/test_motors.py Outdated
Comment on lines +432 to +435
def values_for_rotation(request):
input_value, current_real_value, expected_real_value = request.param
yield input_value, current_real_value, expected_real_value

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.

Suggested change
def values_for_rotation(request):
input_value, current_real_value, expected_real_value = request.param
yield input_value, current_real_value, expected_real_value
def values_for_rotation(request: pytest.FixtureRequest) -> tuple[float, float, float]:
input_value, current_real_value, expected_real_value = request.param
yield input_value, current_real_value, expected_real_value

Comment thread tests/devices/test_motors.py Outdated
Comment on lines +437 to +444
@pytest.fixture()
async def stage_in_initial_state(values_for_rotation):
input_value, current_real_value, expected_real_value = values_for_rotation

stage = XYZOmegaStage("BL03I-MO-SGON-01:")
await stage.connect(mock=True)
set_mock_value(stage.omega.user_readback, current_real_value)
return stage
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.

Suggested change
@pytest.fixture()
async def stage_in_initial_state(values_for_rotation):
input_value, current_real_value, expected_real_value = values_for_rotation
stage = XYZOmegaStage("BL03I-MO-SGON-01:")
await stage.connect(mock=True)
set_mock_value(stage.omega.user_readback, current_real_value)
return stage
@pytest.fixture()
async def stage_in_initial_state(values_for_rotation: tuple[float, float, float]) -> XYZOmegaStage:
input_value, current_real_value, expected_real_value = values_for_rotation
with init_devices(mock=True):
stage_in_initial_state = XYZOmegaStage("BL03I-MO-SGON-01:")
set_mock_value(stage_in_initial_state.omega.user_readback, current_real_value)
return stage_in_initial_state

Comment thread tests/devices/test_motors.py Outdated
Comment on lines +446 to +449

async def test_mod_360_expected_direction_of_rotation_same_as_apparent_for_moves_apparently_less_than_180(
values_for_rotation, stage_in_initial_state
):
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.

Suggested change
async def test_mod_360_expected_direction_of_rotation_same_as_apparent_for_moves_apparently_less_than_180(
values_for_rotation, stage_in_initial_state
):
async def test_mod_360_expected_direction_of_rotation_same_as_apparent_for_moves_apparently_less_than_180(
values_for_rotation: tuple[float, float, float], stage_in_initial_state: XYZOmegaStage
) -> None:

Comment thread tests/devices/test_motors.py
Comment thread tests/devices/test_motors.py Outdated
Comment thread tests/devices/test_smargon.py Outdated
Comment on lines +249 to +251

def test_smargon_deferred_moves_move_omega_phase_not_absolute():
pass
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.

Remove?

Comment thread src/dodal/devices/smargon.py Outdated
@rtuck99 rtuck99 force-pushed the mx-bluesky_1598_mod_360 branch from e2e258a to 80649ec Compare April 8, 2026 14:36
Copy link
Copy Markdown
Contributor

@oliwenmandiamond oliwenmandiamond left a comment

Choose a reason for hiding this comment

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

This is looking better, thanks. Still needs a bit more work though. Please see my comments but in summary, XYZWrappedOmegaStage appears to be an unnecessary layer. Instead just wrap the axis directly on the device you want it on such as Smargon. Type checking on the tests also need to be improved

Comment thread src/dodal/common/maths.py Outdated
Comment on lines +160 to +190
class AngleWithPhase:
"""Represents a point in an absolute rotational space which is defined by a phase where 0<=phase<360
and an offset from an origin where the absolute coordinate is the sum of the phase and the offset.

Attributes:
offset: The offset of 0 phase from some other unwrapped rotational coordinate space
phase: The phase in degrees relative to this offset.
"""

@overload
def __init__(self, offset_and_phase: Iterable[float], /):
pass # pragma: no cover

@overload
def __init__(self, offset: float, phase: float, /):
pass # pragma: no cover

def __init__(self, offset: float | Iterable[float], phase: float = 0):
"""Construct a normalised representation of the offset and phase, such that
0 <= phase < 360.

Args:
offset (float | Iterable[float]): the offset in degrees, or the
offset and phase as a list or other iterable
phase (float): the phase in degrees
"""
if isinstance(offset, Iterable):
offset, phase = offset
correction = 360 * (phase // 360)
self.offset: float = offset + correction
self.phase: float = phase - correction
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 think this is a better fit for a dataclass

Suggested change
class AngleWithPhase:
"""Represents a point in an absolute rotational space which is defined by a phase where 0<=phase<360
and an offset from an origin where the absolute coordinate is the sum of the phase and the offset.
Attributes:
offset: The offset of 0 phase from some other unwrapped rotational coordinate space
phase: The phase in degrees relative to this offset.
"""
@overload
def __init__(self, offset_and_phase: Iterable[float], /):
pass # pragma: no cover
@overload
def __init__(self, offset: float, phase: float, /):
pass # pragma: no cover
def __init__(self, offset: float | Iterable[float], phase: float = 0):
"""Construct a normalised representation of the offset and phase, such that
0 <= phase < 360.
Args:
offset (float | Iterable[float]): the offset in degrees, or the
offset and phase as a list or other iterable
phase (float): the phase in degrees
"""
if isinstance(offset, Iterable):
offset, phase = offset
correction = 360 * (phase // 360)
self.offset: float = offset + correction
self.phase: float = phase - correction
@dataclass
class AngleWithPhase:
"""Represents a point in an absolute rotational space which is defined by a phase where 0<=phase<360
and an offset from an origin where the absolute coordinate is the sum of the phase and the offset.
Attributes:
offset: The offset of 0 phase from some other unwrapped rotational coordinate space
phase: The phase in degrees relative to this offset.
"""
offset: float
phase: float = 0.0
def __post_init__(self) -> None:
correction = 360 * (self.phase // 360)
self.offset += correction
self.phase -= correction
@classmethod
def from_iterable(cls, values: Iterable[float]) -> Self:
"""Construct a normalised representation of the offset and phase, such that
0 <= phase < 360.
Args:
offset Iterable[float]: the offset and phase as a list or other iterable
"""
offset, phase = values
return cls(offset, phase)

Comment thread src/dodal/devices/motors.py Outdated
Comment thread tests/devices/test_motors.py Outdated
Comment thread tests/devices/test_motors.py Outdated
Comment thread tests/devices/test_motors.py Outdated
[-10000 * 360 - 0.001, 359.999],
],
)
async def test_mod_360_read(real_value: float, expected_phase):
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.

Suggested change
async def test_mod_360_read(real_value: float, expected_phase):
async def test_mod_360_read(xyz_wrapped_omege_stage: XYZWrappedOmegaStage, real_value: float, expected_phase: float):

Why don't we use the already created stage?

Comment thread tests/devices/test_wrapped_axis.py Outdated
Comment thread tests/devices/test_wrapped_axis.py Outdated
Comment thread tests/devices/test_wrapped_axis.py Outdated
Comment on lines +50 to +53
with self.add_children_as_readables():
self.phase = derived_signal_rw(
self._get_phase, self._set_phase, offset_and_phase=self.offset_and_phase
)
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.

We have a signal that has offset_and_phase, why do we need phase as well which gives the same information?

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.

phase is very convenient to directly read and write from plans, most plans are not interested in absolute position, but the extra information is useful when you are.

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.

Would it not make more sense to just have one signal for offset and one for phase?



class Smargon(XYZOmegaStage, Movable):
class Smargon(XYZWrappedOmegaStage, Movable):
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 is the only device that uses the wrapped omega axis. Rather than creating XYZWrappedOmegaStage, you should change it so Smargon still uses XYZOmegaStage and then just wrap the omega axis directly here.

class Smargon(XYZWrappedOmegaStage, Movable[float]):

     def __init__(self, prefix: str, name: str = ""):
         super().__init__(prefix=prefix, name=name)
         with self.add_children_as_readables():
               self.wrapped_omega = WrappedAxis(self.omega)
               # Rest of smargon logic
               ...

Copy link
Copy Markdown
Contributor Author

@rtuck99 rtuck99 Apr 17, 2026

Choose a reason for hiding this comment

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

Goniometer also inherits from XYZWrappedOmegaStage.

Goniometer is used in the same pin tip centring plan that is used with the Smargon, and which following DiamondLightSource/mx-bluesky#1640 does use the wrapped axis, so we need a common abstraction.

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.

Hmmm. My preference would be to still use XYZOmegaStage, and then have your plan include a parameter called wrapped_axis or wrapped_omega. So it looks like this:

@pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True})
class PinTipCentringComposite:
    """All devices which are directly or indirectly required by this plan"""

    oav: OAV
    gonio: XYZOmegaStage
    gonio_wrapped_axis: WrappedAxis
    pin_tip_detection: PinTipDetection

The reason being is this makes it way more generic. Any axis can now be wrapped. This means we don't need to create a new subclass for every possible stage combination which needs an axis wrapped.

However, looking at it more I'm not sure this would be supported by BlueAPI as it is a child device and from what I can tell, composites are extracted from being a unique type (correct me if wrong?).



class Goniometer(XYZOmegaStage):
class Goniometer(XYZWrappedOmegaStage):
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.

Suggested change
class Goniometer(XYZWrappedOmegaStage):
class Goniometer(XYZOmegaStage):
def(
self,
prefix: str,
name: str = ""
...
):
super().__init__(prefix=prefix, name=name, ...)
with self.add_children_as_readables():
self.wrapped_omega = WrappedAxis(self.omega)
...

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.

See my comment below re Smargon

Copy link
Copy Markdown
Contributor

@oliwenmandiamond oliwenmandiamond left a comment

Choose a reason for hiding this comment

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

This looks much better now, thanks! I just have a couple questions

Comment on lines +50 to +53
with self.add_children_as_readables():
self.phase = derived_signal_rw(
self._get_phase, self._set_phase, offset_and_phase=self.offset_and_phase
)
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.

Would it not make more sense to just have one signal for offset and one for phase?



class Smargon(XYZOmegaStage, Movable):
class Smargon(XYZWrappedOmegaStage, Movable):
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.

Hmmm. My preference would be to still use XYZOmegaStage, and then have your plan include a parameter called wrapped_axis or wrapped_omega. So it looks like this:

@pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True})
class PinTipCentringComposite:
    """All devices which are directly or indirectly required by this plan"""

    oav: OAV
    gonio: XYZOmegaStage
    gonio_wrapped_axis: WrappedAxis
    pin_tip_detection: PinTipDetection

The reason being is this makes it way more generic. Any axis can now be wrapped. This means we don't need to create a new subclass for every possible stage combination which needs an axis wrapped.

However, looking at it more I'm not sure this would be supported by BlueAPI as it is a child device and from what I can tell, composites are extracted from being a unique type (correct me if wrong?).

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.

4 participants