Skip to content

Refactored Device wrapping#37

Merged
IvoVellekoop merged 24 commits intomicro-manager:0.4.0from
IvoVellekoop:refactored-wrapper
Apr 14, 2025
Merged

Refactored Device wrapping#37
IvoVellekoop merged 24 commits intomicro-manager:0.4.0from
IvoVellekoop:refactored-wrapper

Conversation

@IvoVellekoop
Copy link
Collaborator

A completely refactored Device wrapping mechanism, that can wrap openwfs and arbitrary Python objects.

Main changes:

  • ExEngine is no longer a singleton class. Each device explictly takes an ExEngine as input to the constructor. Rationale: the singleton antipattern is problematic for testing and scalability. For example: imagine that a client uses two two packages that both use ExEngine. The first package may shut down its own ExEngine at some point. Of course, this should not shut down the other ExEngine. By dropping the singleton pattern this problem is avoided. Moreover, mocking the ExEngine is simpler and it is clear which parts of the code access the engine and which parts do not.

  • Device base class and wrapping. ExEngine provides a wrapper that makes attribute access thread safe, converts method calls to delayed execution and (not implemented yet) adds metadata, notifications, etc. To wrap an existing object, call engine.register, and make sure never to use the bare (non-wrapped) object. All internal attribute access inside the object itself is unaffected by the wrapper (so no need to monkey patch the threading library). Alternatively, an object can derive from the Device base class. This base class no longer does much, except that it overrides __new__ to call engine.register automatically. Examples are included in test_preferred_thread_annotations.py and test_generic_device.py

  • The option to shutdown queues was implemented through condition variables and complicated the code. In Python 3.13, this functionality is natively present in the Queue and PriorityQueue objects. The new code uses these objects when available, and provides a compatible implementation for Python versions < 3.13

Open issues:

  • Setting thread affinity is not implemented yet: need to rethink?
  • Getting property metadate is not implemented yet: need to rethink the original design with the get_property_limits etc.

@jacopoabramo
Copy link
Contributor

Following up on the discussion, some reference on how you could translate the current ExEngine device APIs into Protocols can be found from bluesky: https://github.com/bluesky/bluesky/blob/main/src/bluesky/protocols.py

I would personally love to switch to a structural typing approach because it's much more versatile than using abstract classes and it would greatly simplify my project implementation as well.

@henrypinkard
Copy link
Contributor

  • ExEngine is no longer a singleton class. Each device explictly takes an ExEngine as input to the constructor. Rationale: the singleton antipattern is problematic for testing and scalability. For example: imagine that a client uses two two packages that both use ExEngine. The first package may shut down its own ExEngine at some point. Of course, this should not shut down the other ExEngine. By dropping the singleton pattern this problem is avoided. Moreover, mocking the ExEngine is simpler and it is clear which parts of the code access the engine and which parts do not.

Makes sense

  • Device base class and wrapping. ExEngine provides a wrapper that makes attribute access thread safe, converts method calls to delayed execution and (not implemented yet) adds metadata, notifications, etc. To wrap an existing object, call engine.register, and make sure never to use the bare (non-wrapped) object. All internal attribute access inside the object itself is unaffected by the wrapper (so no need to monkey patch the threading library). Alternatively, an object can derive from the Device base class. This base class no longer does much, except that it overrides __new__ to call engine.register automatically. Examples are included in test_preferred_thread_annotations.py and test_generic_device.py

I agree this way of doing it makes much better sense, and I'd support removing the inheritance route and making wrapping the only way of using it. If we wanted to make classes that automatically come wrapped when constructed, easy enough to do it in a __new__ method

# Check for method-level preferred thread name first, then class-level
self._thread_name = getattr(self.execute, '_thread_name', None) or getattr(self.__class__, '_thread_name', None)

def __lt__(self, other) -> bool:
Copy link
Contributor

Choose a reason for hiding this comment

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

Are you thinking to make the events in a priority queue? So you could submit an event that gets jumped ahead of existing ones? I had a mechanism for this that perhaps you saw in executor.submit, but I think this makes more sense

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 initially used the mechanism from executor.submit and found that there is a standard class (in 3.13) that already does exactly this, so I refactored it and used a fallback for Python < 3.13.
However, I think the execution order needs some more thought since events, since the engine should be able to re-order some events, and guarantee relative timing between other events. Instead of returning events by enqueueing order, the queue should return events in the order in which they can be executed.
The constraints that determine which event can be run next would be an important aspects of ExEngine that allows users to specify programs like:

  • move stage A to position X
  • simultaneously move stage B to position Y
  • after both A and B have stopped, trigger the camera and grab a frame
  • don't wait for the data transfer of the frame, directly start moving the stages to the next position

perhaps using explicit synchronization?

move1 = a.move_to(pos_x)
move2 = b.move_to(pos_y)
frame = camera.trigger(after=[move1, move2])
a.move_to(pos_x2)

or using OpenWFS-style implicit synchronization, where detectors automatically wait for actuators and vice versa:

a.move_to(pos_x)
b.move_to(pos_y)
frame = camera.trigger()
a.move_to(pos_x2)

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense. To me, the explicit version makes more sense. While the code is a bit more verbose, I think it really makes it much more clear what is going on behind the scenes

@@ -0,0 +1,64 @@
import queue
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be good to give this a more descriptive filename?

@henrypinkard
Copy link
Contributor

Test setup is failing. I think its because you need to do a relative import here:

    File "D:\a\ExEngine\ExEngine\src\exengine\kernel\queue.py", line 6, in <module>
      from exengine.kernel.ex_event_base import ExecutorEvent
  ModuleNotFoundError: No module named 'exengine'
  [end of output]

I think you need from .kernel... or something similar to that

@henrypinkard
Copy link
Contributor

I made some more small comments, but looking really good so far!

One issue I've thought about is what the user facing API for a wrapped object should look like:

is any call to device.some_method() in user code going to block until it completes on an executor thread? Is there a way to have a simple imperative API that gets the benefits of asynchronous programming? Or should this necessarily specifically require creating events and submitting them to the executor

@IvoVellekoop
Copy link
Collaborator Author

IvoVellekoop commented Jan 20, 2025

is any call to device.some_method() in user code going to block until it completes on an executor thread? Is there a way to have a simple imperative API that gets the benefits of asynchronous programming? Or should this necessarily specifically require creating events and submitting them to the executor

At the moment, the method wrapper returns an ExecutionFuture, which the user can ignore or to stop or abort the event (perhaps at some point query the expected remaining duration of the event?). And, as proposed above, I think we should also make it possible to use the Future for explicit synchronization between different events.

@IvoVellekoop IvoVellekoop changed the base branch from main to 0.4.0 April 14, 2025 16:35
@IvoVellekoop IvoVellekoop merged commit ff9fb0d into micro-manager:0.4.0 Apr 14, 2025
1 of 5 checks passed
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