-
Notifications
You must be signed in to change notification settings - Fork 10
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
Support parallel and lazy connection of Devices #415
Comments
Observation: this connection logic seems like an area with significant hidden complexity/code churn. When we decide on an approach, can we make sure we write an ADR documenting exactly what we considered and why we chose that solution? I think we need to consider both "optional" and "permanent" devices in this connection logic - where a given device might be optional or not on any given beamline. I previously wrote a similar issue in blueapi, but I think it makes sense to consider it here if we're going to refactor the connection logic. In fact, I think there might be several cases:
def plan():
mode = yield from bps.rd("some config pv")
if mode == "A":
explicit_connect(device_a)
the_value = yield from bps.rd(device_a) # Where device b isn't available in mode "A"
else:
explicit_connect(device_b)
the_value = yield from bps.rd(device_b) # Where device a isn't available in mode "B"
# Do something with the_value
... Thinking out loud here, I'm thinking of a syntax along the lines of: # dodal/i22.py
@connection.EAGER
def eiger():
# Eager connection when blueapi starts, failure to connect causes blueapi to be "unhappy"
return Eiger("some_prefix")
@connection.DEFERRED
def linkam():
# connection only when a plan actually needs it - blueapi can start without these devices
return Linkam("some_prefix")
@connection.EXPLICIT
def weird_device():
# Never automatically connected by blueapi, has to be connected "manually", either in a plan
# or "manually" before running a specific plan
# e.g. could cover last use case above
... This would also leave us scope for different connection behaviours later on if we've missed a use-case. Happy to bikeshed over names and/or what the default/unannotated should be. Some more specific comments:
I think this is true for hyperion, but less true for i22. For i22, as the plans are in general pretty slow compared to connection times, I'd be willing to trade some performance for better / more ergonomic error reporting.
At what point would connection errors be reported if the connection is happening in a background queue? When you first try to use that device in a plan?
Hmmm. Thinking about the i22 case for example, let's say I start blueAPI at the start of run. when the linkam is available, and then later unplug it / switch off it's IOC. I probably don't want subsequent calls to Maybe we need separate concepts for Q: If all the PV connections are happening in parallel via Sorry if this is a bit long... but you did
;) |
Yes, I think your example is somewhat likely to be desired, but I think it's fine for that to be case where it's up to you to do the explicit connection, it could hardly be otherwise
In my discussion with @coretl yesterday we imagined something like "at the start of the plan, move required & not-yet-connected devices to the front of the queue and block until they are connected/fail", and having everything be "semi-eager" i.e., BlueApi/Dodal will attempt to connect to everything (eventually), but if it can't, it will only report those errors and not exit. It will then only be an error if you attempt to run a plan which uses a device which failed to connect. This seemed good from the point of view of having fast application startup with minimal faff, and the required flexibility for optional devices. I guess it could end up with a lot of logs if there are lots of optional devices?
If there are thousands of them (a whole beamline worth) maybe yeah |
Definitely agree with this, this is my 3rd time having this discussion.
As Tom said, we might not want all the devices for a beamline.
I think the 2nd would be better for Hyperion too. In fact I think the GDA behavior that we have on some beamlines of not being able to start if some devices aren't connected is
This isn't actually that necessary for Hyperion either. We don't care if connect takes some time, as long as it's only done once at start up. We shouldn't be restarting Hyperion often so the time it takes to start up is irrelevant. The logic in
Our view on this would probably be that if the check to see if we're connected takes a small amount of time (<100ms for all devices) then we can block on it at the start of a plan and happily sit there and wait for it to be connected. Otherwise we would probably rather proceed without the check and take the hit when we crash later. I guess circling back to Tom C's original suggestions:
I think I stand by both of those statements still and the suggestion breaks them both. I do think we're going to struggle to get to a one-size-fits all solution here. I do like Tom's decorators on the devices but we might need more customisability as well I think this needs broad discussion, probably in person, and a comprehensive final document on it. |
Agreed, I will set this up, during the hackathon or after?
But I'd like to explore this question first, if |
I vote after, there's already enough going on.
Sorry @coretl, I think I updated my comment above answering some of these question more. |
An example of this currently is the OAV. The OAV function takes a config object (based on a file) on
|
Ok, that is a good example, I think I'm sold on the need for device factories and some variant of @Tom-Willemsen's connection decorators. Will try and propose something in advance of the meeting and we can argue about it then |
I would like to explore this requirement, as there are tricky interactions with the RunEngine event loop that we need to consider, are these Devices ever used without the RE being present, or can we say that this is a requirement? |
To play devil's advocate. There is a 3rd solution to the OAV issue where it takes a I think I basically worry that without the factories future developers will quickly fall into the patterns of work on import or
I think we can say that the RE is a requirement, as you'll probably be using one anyway. I would basically like to keep the barrier to entry of writing some basic plans as low as possible, and at the moment it is: from dodal.my_beamline import device
from bluesky import RunEngine
RE = RunEngine()
my_device = device()
def my_plan():
yield from ...
RE(my_plan()) Introducing BlueAPI as a dependency here gives nothing to the scientist that wants to try something out quickly |
I agree with Dom's comment about minimising the barrier of entry to basic plans.
From a support perspective, I'd really like to try and minimise "tricky interactions" - whether that's for devs or users. So if making the |
This makes sense, and was the only non-blueapi solution I was planning to explicitly support. If we do something like: def device_factory(connect=DeviceConnection.EAGER, set_name=True):
def wrapper(f):
def factory(sim=False, timeout=DEFAULT_TIMEOUT):
if f in global_devices:
return global_devices[f]
device = f()
global_devices[f] = device
if set_name:
device.set_name(f.__name__)
call_in_bluesky_event_loop(device.connect(sim, timeout))
return device
global_factories[factory] = connect
return factory
return wrapper
@device_factory(connect=DeviceConnection.EAGER)
def device():
return MyDevice("MY-EPICS-PREFIX") Then in the absence of blueapi it will serially connect the device on first call of the factory. We can also provide functions like |
We had a meeting on this, and based on this I now propose:
This means we have: # i22.py
@device_factory(connect=DeviceConnection.EAGER):
def saxs() -> PilatusDetector:
return PilatusDetector("BL22I-EA-PILAT-01")
# my_plans.py
from dodal.beamlines import i22
def no_arg_plan():
# plan does connect
saxs = yield from ensure_connected(i22.saxs())
yield from bps.count([saxs])
def default_arg_plan(det = i22.saxs()):
# blueapi does connect
yield from bps.count([det])
def require_arg_plan(det: Readable):
# blueapi does connect
yield from bps.count([det])
# my_testing_script.py
from dodal.beamlines import i22
from bluesky import RunEngine
RE = RunEngine()
saxs = i22.saxs(connect=True)
def my_plan(det: Readable):
yield from bps.count([det])
RE(my_plan(saxs)) Is everyone happy with this? |
I guess you could do composites like this: def get_connected_devices() -> Tuple[Eiger, Panda, Goniometer]:
devices = yield from ensure_connected(ixx.eiger(), ixx.panda(), ixx.goniometer)
return devices |
Re: getting rid of composites, I would like it if it would support class PlanDevices(TypedDict):
backlight: Backlight
gonio: Gonio
detector: Detector
zebra: Zebra
def inner_plan(parameters, **kwargs: Unpack[PlanDevices]):
yield from do_some_stuff(kwargs["zebra"])
yield from do_some_other_stuff(kwargs["gonio"])
def outer_plan(parameters, **kwargs: Unpack[PlanDevices]):
yield from move_backlight(kwargs["backlight"])
yield from inner_plan(parameters, **kwargs)
yield from do_some_detector(kwargs["detector"]) to stop having enormous parameter lists when there are a lot of devices and nested plan sections using them |
Would you actually use class PlanDevices(TypedDict):
backlight: Backlight
gonio: Gonio
detector: Detector
zebra: Zebra
def inner_plan(parameters, **kwargs: Unpack[PlanDevices]):
yield from do_some_stuff(kwargs["zebra"])
yield from do_some_other_stuff(kwargs["gonio"])
def outer_plan(parameters):
kwargs = ensure_connected(
backlight=ixx.backlight(),
gonio=ixx.gonio(),
detector=ixx.eiger(),
zebra=ixx.zebra(),
)
yield from move_backlight(kwargs["backlight"])
yield from inner_plan(parameters, **kwargs)
yield from do_some_detector(kwargs["detector"]) For this to work with typing we'd have to make sure type checkers are happy with this: def ensure_connected(**kwargs: Unpack[T]) -> T: ... |
the idea is that it is nice shorthand for def outer_plan(parameters, *, backlight: Backlight, zebra: Zebra, gonio: Gonio, detector: Detector):
... so that blueAPI can give them to us - in the case where we explicitly call |
Or we could keep the composite: @dataclass
class PlanDevices:
backlight: Backlight = ixx.backlight()
gonio: Gonio = ixx.gonio()
detector: Detector = ixx.eiger()
zebra: Zebra = ixx.zebra()
def inner_plan(parameters, devices: PlanDevices):
yield from do_some_stuff(devices.zebra)
yield from do_some_other_stuff(devices.gonio)
def outer_plan(parameters):
devices = PlanDevices()
yield from ensure_connected(*asdict(devices).values())
yield from move_backlight(devices.backlight)
yield from inner_plan(parameters, devices)
yield from do_some_detector(devices.detector) |
yeah, that's very close to what we do now, seems fine |
More conversations with @callumforrester and other DAQ members led us to this proposal:
We can then iterate on this if the parallel connect causes a broadcast storm. We could also in future add a monitor to a heartbeat PV per device in @DominicOram are you happy with this approach? If so I'll go ahead and make the tickets in the 3 repos for these items and write and ADR in dodal. |
I've made 3 more targeted issues so closing this one |
At the moment we have something like:
dodal/src/dodal/beamlines/i03.py
Lines 46 to 57 in 4f646f8
This allows optional serial connection and creation of fake devices instead of real devices via
device_instantiation
. The ophyd-async mechanism for this is using theconnect
method:async def connect(self, sim: bool = False, timeout: float = DEFAULT_TIMEOUT):
(you could argue that
fake
would be a better name thansim
, so that could also be changed)I would like to be able to:
I will make some suggestions and I'd like to encourage a vigorous discussion of the alternatives here!
Suggestion 1: Background idempotent connect
Rather than an explicit connect when creating the Device, add it to a background queue so it will be connected eventually. I would also suggest we make
connect()
idempotent so that it will complete immediately if it has already been run successfully. If we also use Devices as arguments to plans then blueapi could callconnect()
on all the Devices that are plan ags (or some other decorator solution a-la hyperion composites).Suggestion 2: Remove device factories
If connect is no longer happening at Device instantiation then it should be safe to always have them created at startup, then we can just import them with the minimum boilerplate. Connection can be done outside this function.
Not sure if
connect_and_name
should be done at the bottom of i22.py or not...Acceptance Criteria
The text was updated successfully, but these errors were encountered: