-
Notifications
You must be signed in to change notification settings - Fork 6
feat: add depth parameter to devices endpoints #912
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,6 +11,7 @@ | |
Depends, | ||
FastAPI, | ||
HTTPException, | ||
Query, | ||
Request, | ||
Response, | ||
status, | ||
|
@@ -54,7 +55,7 @@ | |
from .runner import WorkerDispatcher | ||
|
||
#: API version to publish in OpenAPI schema | ||
REST_API_VERSION = "0.0.10" | ||
REST_API_VERSION = "0.0.11" | ||
|
||
RUNNER: WorkerDispatcher | None = None | ||
|
||
|
@@ -231,9 +232,18 @@ def get_plan_by_name( | |
@start_as_current_span(TRACER) | ||
def get_devices( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Must: Add another attribute to {
"name": "x",
"address": "stage.x"
"protocols": []
} The alternative is to return an actual tree structure and let the client work out for itself where the device is, but we need to provide that information somehow. I think a list of unique devices with tree address as an attribute is my preference. If you do it that way then no, I don't think There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need to do that? For ophyd-async devices the name is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two reasons:
Blueapi will find
See also a stackexchange thread that I read. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does the client need to reconstruct the tree? I need to know what devices can be used for what plans. If I need to know that these two motors are related, my plan should accept and I should be passing their mutual parent. I'm probably not seeing the big picture here but if I have There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider querying the server for its devices and then running a plan with said devices:
The correct command is -blueapi run count '{"detectors": ["table_x"]}'
+blueapi run count '{"detectors": ["table.x"]}' But you have no way of knowing that from the client output unless you just happen to know to convention or (even worse) code it into your client. The name mismatch is also still an issue here. Finally, is the device tree information that we want to hide? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we preserve and parse ourselves the dot access of child devices, instead of registering the children of the device and allowing references to them by name? It means the call to blueapi uses different arguments than propagate downstream in documents: another name mismatch. It's not that I want to hide the device tree, but it's outside of the scope of being able to run plans on child devices. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So you're saying don't accept [1]: table.children()
["x", "y"]
[2]: RE(bp.count(detectors=[table.x])) Knowing about/inspecting the tree is inherently part of that user experience so it is a bigger leap to completely hide it/expect the user to enter something different and think of the devices in a different way. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. $ blueapi controller devices
table:
protocols:
table_x:
protocols:
Movable:
- float
table_y:
protocols:
Movable:
- float
$ blueapi controller run count '{"detectors": ["table_x"]}' Is it unreasonable to say "I think the user experience has already changed so much that swapping out . for _ doesn't really matter?" Or commit to supporting both. Either way, blueapi devices with some depth will be returning the Device.name, which blueapi considers special and unique, which uses _ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No idea, I would suggest we ask some users |
||
runner: Annotated[WorkerDispatcher, Depends(_runner)], | ||
max_depth: Annotated[ | ||
int, | ||
Query( | ||
description="Maximum depth of children to return, -1 for all", | ||
ge=0, | ||
# https://github.com/fastapi/fastapi/discussions/13473 | ||
json_schema_extra={"description": None}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll make an issue for tracking this if this is the way we want to go |
||
), | ||
] = 0, | ||
) -> DeviceResponse: | ||
"""Retrieve information about all available devices.""" | ||
devices = runner.run(interface.get_devices) | ||
devices = runner.run(interface.get_devices, max_depth) | ||
return DeviceResponse(devices=devices) | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,8 @@ | |
from typing import Annotated, Any | ||
|
||
from bluesky.protocols import HasName | ||
from ophyd import Device as SyncDevice | ||
from ophyd_async.core import Device as AsyncDevice | ||
from pydantic import Field | ||
from pydantic.json_schema import SkipJsonSchema | ||
|
||
|
@@ -34,16 +36,69 @@ class DeviceModel(BlueapiBaseModel): | |
protocols: list[ProtocolInfo] = Field( | ||
description="Protocols that a device conforms to, indicating its capabilities" | ||
) | ||
address: str | ||
|
||
@classmethod | ||
def from_device(cls, device: Device) -> "DeviceModel": | ||
name = device.name if isinstance(device, HasName) else _UNKNOWN_NAME | ||
return cls(name=name, protocols=list(_protocol_info(device))) | ||
return cls(name=name, protocols=list(_protocol_info(device)), address=name) | ||
|
||
@classmethod | ||
def from_device_tree(cls, root: Device, max_depth: int) -> list["DeviceModel"]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should: There is a function in |
||
if isinstance(root, AsyncDevice): | ||
return [ | ||
DeviceModel( | ||
name=device.name, | ||
protocols=list(_protocol_info(device)), | ||
address=address, | ||
) | ||
for address, device in _from_async_device( | ||
root, max_depth=max_depth | ||
).items() | ||
] | ||
if isinstance(root, SyncDevice): | ||
return [ | ||
DeviceModel( | ||
name=device.name, | ||
protocols=list(_protocol_info(device)), | ||
address=address, | ||
) | ||
for address, device in _from_sync_device( | ||
root, max_depth=max_depth | ||
).items() | ||
] | ||
return [DeviceModel.from_device(root)] | ||
|
||
|
||
def _from_async_device(root: AsyncDevice, max_depth: int) -> dict[str, AsyncDevice]: | ||
depth = 0 | ||
devices: dict[str, AsyncDevice] = {root.name: root} | ||
branches: dict[str, AsyncDevice] = {root.name: root} | ||
while branches and (max_depth == -1 or depth < max_depth): | ||
leaves: dict[str, AsyncDevice] = {} | ||
for addr, parent in branches.items(): | ||
for suffix, child in parent.children(): | ||
leaves[f"{addr}.{suffix}"] = child | ||
devices.update(leaves) | ||
branches = leaves | ||
depth += 1 | ||
return devices | ||
|
||
|
||
def _from_sync_device(root: SyncDevice, max_depth: int) -> dict[str, SyncDevice]: | ||
return { | ||
root.name: root, | ||
**{ | ||
k.dotted_name: k.item | ||
for k in root.walk_signals() | ||
if max_depth == -1 or len(k.ancestors) <= max_depth | ||
}, | ||
} | ||
|
||
|
||
def _protocol_info(device: Device) -> Iterable[ProtocolInfo]: | ||
for protocol in BLUESKY_PROTOCOLS: | ||
if isinstance(device, protocol): | ||
if isinstance(device, protocol) and protocol is not AsyncDevice: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent ProtocolInfo having "Device" |
||
yield ProtocolInfo( | ||
name=protocol.__name__, | ||
types=[arg.__name__ for arg in generic_bounds(device, protocol)], | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent any child devices that are not deserializable as have no protocols and so are not registered?