Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Artemis 262 move arming out of stage #43

Merged
merged 40 commits into from
Jun 12, 2023

Conversation

olliesilvester
Copy link
Collaborator

@olliesilvester olliesilvester commented Apr 28, 2023

Fixes Artemis issue #262

@codecov
Copy link

codecov bot commented Apr 28, 2023

Codecov Report

Merging #43 (4497b1d) into main (374e513) will increase coverage by 0.30%.
The diff coverage is 98.92%.

@@            Coverage Diff             @@
##             main      #43      +/-   ##
==========================================
+ Coverage   87.71%   88.02%   +0.30%     
==========================================
  Files          54       54              
  Lines        1767     1796      +29     
==========================================
+ Hits         1550     1581      +31     
+ Misses        217      215       -2     
Impacted Files Coverage Δ
src/dodal/devices/eiger.py 99.30% <98.41%> (-0.70%) ⬇️
src/dodal/devices/status.py 100.00% <100.00%> (ø)
src/dodal/devices/utils.py 100.00% <100.00%> (ø)

... and 1 file with indirect coverage changes

📣 We’re building smart automated test selection to slash your CI/CD build times. Learn more

Copy link
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! With the requested changes it looks like a good start that we could at least test on the beamline

Comment on lines 28 to 30
class ArmingSignal(Signal):
def set(self, value, *, timeout=None, settle_time=None, **kwargs):
return self.parent.stage()
Copy link
Contributor

Choose a reason for hiding this comment

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

Must: This creates the class but you don't actually have an instance of the signal

@@ -85,7 +91,7 @@ def stage(self):
LOGGER.info("Waiting on parameter callbacks")
status.wait(self.STALE_PARAMS_TIMEOUT)

self.arm_detector()
return self.arm_detector()
Copy link
Contributor

Choose a reason for hiding this comment

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

Must: With this now being asynchronous it no longer conforms to the interface for stage so we should rename it.


def forward_bit_depth_to_filewriter(self):
bit_depth = self.bit_depth.get()
self.odin.file_writer.data_type.put(f"UInt{bit_depth}")

def arm_detector(self):
def arm_detector(self) -> Status:
statuses = [Status(1), Status(2), Status(3), Status(4)]
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: Each function that we pass these to is overwriting the entry so it's a bit confusing having these here. Might be better to start with an empty list then each function can just append to the list.

LOGGER.info("Waiting on stale parameters to go low")
self.wait_for_stale_parameters()
self.wait_for_stale_parameters(statuses) # Starts the chain of arming functions

self.forward_bit_depth_to_filewriter()
Copy link
Contributor

Choose a reason for hiding this comment

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

Must: This needs to be async too


LOGGER.info("Setting aquire")
self.cam.acquire.set(1).wait(timeout=10)

self.filewriters_finished = self.odin.create_finished_status()
Copy link
Contributor

Choose a reason for hiding this comment

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

Must: This also needs to be async

@@ -24,6 +25,11 @@ class EigerTriggerNumber(str, Enum):


class EigerDetector(Device):
class ArmingSignal(Signal):
def set(self, value, *, timeout=None, settle_time=None, **kwargs):
return self.parent.stage()
Copy link
Contributor

Choose a reason for hiding this comment

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

Must: The function has been renamed so we need t change it here

@DominicOram
Copy link
Contributor

DominicOram commented May 22, 2023

@callumforrester and @coretl, you might have some opinions on this. Callum, I know you weren't keen on this approach but I think it makes the plan side quite elegant (https://github.com/DiamondLightSource/python-artemis/pull/631/files). I'm particularly interested in:

  • It feels like there might be a cleaner way of writing wrap_and_do_funcs?
  • Do we think wrap_and_do_funcs is worth pushing into core ophyd? Seems generically useful

(I am currently reviewing it for style etc. so if you do have comments around that they may be similar to mine in a minute...)

Copy link
Contributor

@callumforrester callumforrester left a comment

Choose a reason for hiding this comment

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

@DominicOram I can see the elegance of the plan side, but you've actually done two things:

  1. Move arming out of stage
  2. Remove automatic staging/unstaging from the plan and do it manually

Can we not achieve the same just by doing 2.?

i.e.

yield from bps.stage(eiger)
...
def do_fgs():
    try:
        yield from bps.wait()  # Wait for all moves to complete
        yield from bps.kickoff(fgs_motors)
        yield from bps.complete(fgs_motors, wait=True)
    finally:
        yield from bps.unstage(fgs_composite.eiger)

Regardless of that, wrap_and_do_funcs does look like a useful addition, see my other comment for musings on implementation.

src/dodal/devices/utils.py Outdated Show resolved Hide resolved
@@ -12,3 +18,71 @@ def epics_signal_put_wait(pv_name: str, wait: float = 1.0) -> EpicsSignal:
EpicsSignal: An EpicsSignal that will wait for a callback.
"""
return Component(EpicsSignal, pv_name, put_complete=True, write_timeout=wait)


def wrap_and_do_funcs(unwrapped_funcs, timeout=60):
Copy link
Contributor

Choose a reason for hiding this comment

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

@DominicOram Have been thinking about a cleaner implementation, how about something like this?
It's not completely right because some of the tests don't like it, but I think recursion may be the way to go:

def wrap_and_do_funcs(
    unwrapped_funcs: list[Callable[[], Status]],
    timeout: float = 60.0,
    previous_status: Optional[Status] = None,
) -> Status:
    func, other_funcs = unwrapped_funcs[-1], unwrapped_funcs[1:-1]

    def wrapper(previous_status: Optional[Status] = None) -> Status:
        if previous_status is not None and previous_status.exception() is not None:
            # If there is an exception, return the status immediately, ends recursion
            # so no further statuses are evaluated
            LOGGER.error(
                f"{previous_status} has failed with error {previous_status.exception()}"
            )
            return previous_status

        LOGGER.info(f"Doing {func.__name__}")
        next_status = func()
        if len(other_funcs) > 0:
            # Queue up the next function to run when this status completes.
            # Do not queue up more functions if there aren't any, ends recursion
            next_status.add_callback(lambda: wrap_and_do_funcs(other_funcs, timeout))
        return next_status

    return wrapper(previous_status)

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, I feel like we can do it nicer with recursion. I think that might require a bit more thought so let's do a beamline test first @olliesilvester then we'll try and refactor to use recursion

Copy link
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.

This is really good, should speed things up a lot. A few comments in code but most of these probably came from us chopping and changing the solution a few times. Additionally:

  • Can you pull the 10 timeout out into a constant
  • I think we could do with some separate unit tests on wrap_and_do_funcs

Comment on lines 252 to 254
dummy_status = Status()
dummy_status.set_finished()
return dummy_status
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: I think we can use Status(done=True, success=True)


from ophyd.status import SubscriptionStatus

T = TypeVar("T")


def await_value(subscribable: Any, expected_value: T) -> SubscriptionStatus:
def await_value(
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice, thanks

Comment on lines 27 to 30
This function can be used to convert a series of blocking status functions to being asynchronous
by making use of callbacks. It also checks for exceptions on each returned status
For example, instead of blocking on status.wait(), the code will continue running until the status
is marked as finished
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: I think you need to mention something about them run in sequence, at the moment this could read like all of the functions are run in parallel. I also think the bit after For example doesn't add much to the explanation


# The returned status - marked as finished at the end of the callback chain. If any
# intermediate statuses have an exception, the full_status will timeout.
full_status = Status(timeout=timeout)
Copy link
Contributor

Choose a reason for hiding this comment

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

Could: Might be nice to set the full_status to have the exception thrown from individual statuses

Comment on lines 64 to 65
# Once the series of functions have finished with successful statuses, the final action is to mark
# the full_status as finished
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Rather than this comment I would create a closing_func=lambda: full_status.set_finished() then use that. Makes the code a bit more self documenting

energy=detector_params.current_energy
),
self.set_cam_pvs,
self.set_odin_pvs_after_file_writer_set,
Copy link
Contributor

Choose a reason for hiding this comment

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

Must: We're missing the set_odin_pvs step. I also think we could rename those to be a bit nicer. Probably set_number_of_frame_chunks and set_odin_pvs

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thanks I missed this - the function is back in and renamed now

]
)

if not self.armed:
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: As discussed it would be better to just check this before doing anything, probably in the stage itself

if not self.armed:
unwrapped_funcs.extend(
[
lambda: await_value(self.stale_params, 0, 60),
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: we can use STALE_PARAMS_TIMEOUT here


await_value(self.odin.fan.ready, 1).wait(10)
return self.arming_status
Copy link
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 think this is the correct status to return, if we want it to return a status we should change the put to a set. We should actually also do a wait on it before moving on with the arming (even though that wasn't in there previously)

Comment on lines 237 to 240
def _wait_for_cam_acquire(self) -> Status:
LOGGER.info("Setting aquire")
self.cam.acquire.set(1).wait(timeout=10)
this_status = self.cam.acquire.set(1, timeout=10)
return this_status
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: This can probably just be a lambda now

@DominicOram
Copy link
Contributor

@DominicOram I can see the elegance of the plan side, but you've actually done two things:

1. Move arming out of stage

2. Remove automatic staging/unstaging from the plan and do it manually
   Can we not achieve the same just by doing 2.?

i.e.

yield from bps.stage(eiger)
...
def do_fgs():
    try:
        yield from bps.wait()  # Wait for all moves to complete
        yield from bps.kickoff(fgs_motors)
        yield from bps.complete(fgs_motors, wait=True)
    finally:
        yield from bps.unstage(fgs_composite.eiger)

Regardless of that, wrap_and_do_funcs does look like a useful addition, see my other comment for musings on implementation.

Can we? I'm not sure if we can if stage doesn't return a status? We need something to wait on to make sure the stage has completed?

@callumforrester
Copy link
Contributor

In this case, don't we just make stage block on the status?

@callumforrester
Copy link
Contributor

Ah, didn't see that you were waiting for a group, nevermind

@DominicOram
Copy link
Contributor

As discussed offline with @callumforrester, we should tidy up the recursion then put in a PR to ophyd

Copy link
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! I haven't fully reviewed the tests but have some comments on the rest of the changes

Comment on lines 110 to 115
# this is no longer used TODO clean up
def enable_roi_mode(self):
self.change_roi_mode(True)

def disable_roi_mode(self):
self.change_roi_mode(False)
Copy link
Contributor

@DominicOram DominicOram Jun 6, 2023

Choose a reason for hiding this comment

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

Should: Agree, lets remove them in this PR (first do a quick search that they're not used anywhere)

Comment on lines +122 to +136
status = self.cam.roi_mode.set(
1 if enable else 0, timeout=self.GENERAL_STATUS_TIMEOUT
)
status &= self.odin.file_writer.image_height.set(
detector_dimensions.height, timeout=self.GENERAL_STATUS_TIMEOUT
)
status &= self.odin.file_writer.image_width.set(
detector_dimensions.width, timeout=self.GENERAL_STATUS_TIMEOUT
)
status &= self.odin.file_writer.num_row_chunks.set(
detector_dimensions.height, timeout=self.GENERAL_STATUS_TIMEOUT
)
status &= self.odin.file_writer.num_col_chunks.set(
detector_dimensions.width, timeout=self.GENERAL_STATUS_TIMEOUT
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Could: You're right that these should all have a timeout but it's getting messy with them set so many times here. What about something like:

status = Status(timeout=self.GENERAL_STATUS_TIMEOUT) # Overall status to track timeout for the whole operation

status &= self.cam.roi_mode.set(1 if enable else 0)
...

Copy link
Contributor

Choose a reason for hiding this comment

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

And we could repeat the pattern elsewhere

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think if we did this then the first part of the status would never automatically be set as finished. Would attaching a timeout to just one of these statuses give the same effect?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

As discussed we'll leave this as it is for now


def disarm_detector(self):
self.cam.acquire.put(0)
self.armed = False

def make_chained_functions(self) -> Status:
Copy link
Contributor

@DominicOram DominicOram Jun 6, 2023

Choose a reason for hiding this comment

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

Should: I think we could do with a better name here. This is not juts making the chain, it's kicking it off. The name is also too generic, we're doing the arming, not just any chain.

Comment on lines 267 to 268
this_status = await_value(self.odin.fan.ready, 1, self.GENERAL_STATUS_TIMEOUT)
return this_status
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Can do this in one line


await_value(self.odin.fan.ready, 1).wait(10)
unwrapped_funcs.extend(
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: We can just extend it once

Comment on lines 59 to 63
@pytest.fixture
def finished_status():
status = Status()
status.set_finished()
return status
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I wouldn't make this a fixture, it can just be a function you call to get a status when you need one



def wrap_and_do_funcs(
unwrapped_funcs: list[Callable[[], StatusBase]],
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: functions_to_chain rather than unwrapped_funcs maybe?


self.filewriters_finished = self.odin.create_finished_status()
def make_chained_functions(self) -> Status:
unwrapped_funcs = list()
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: unwrapped_funcs is too generic, something like functions_to_do_arm

("fake_eiger._finish_arm"),
],
)
def test_check_callback_error(fake_eiger: EigerDetector, func):
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: I think a more useful test here would be to keep the same list as defined in the eiger but change which one throws an error


fake_eiger.odin.file_writer.capture.sim_put(1)
fake_eiger.odin.file_writer.num_captured.sim_put(2000)
fake_eiger.odin.check_odin_state = MagicMock(return_value=True)

fake_eiger.detector_params.trigger_mode = TriggerMode.FREE_RUN
fake_eiger.armed = True
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: No longer used, remove

Copy link
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.

Much nicer naming, thank you!

@DominicOram DominicOram merged commit b7f5342 into main Jun 12, 2023
16 checks passed
@DominicOram DominicOram deleted the artemis_262_move_arming_out_of_stage branch June 12, 2023 10:50
@DominicOram DominicOram restored the artemis_262_move_arming_out_of_stage branch June 12, 2023 16:12
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.

None yet

3 participants