Skip to content

Support composite devices in plans #506

Closed

Description

@olliesilvester

As a user of Blueapi, I would like to run plans which contain composite devices.

In Hyperion, our plans use a lot of devices so we have something like

class FlyScanXRayCentreComposite:
    aperture_scatterguard: ApertureScatterguard = inject(...)
    attenuator: Attenuator = inject(...)
    ...


def flyscan_xray_centre(
    composite: FlyScanXRayCentreComposite,
    ...

BlueAPI doesn't seem to be happy with this kind of thing right now. When doing a blueapi controller plans, the server outputs:
ValueError: Value not declarable with JSON Schema, field: name='aperture_scatterguard' type=ApertureScatterguard required=True

Activity

  1. callumforrester commented on Jun 18, 2024

    @callumforrester
    Contributor

    FlyScanXrayCentreComposite is a dataclass rather than an ophyd/ophyd-async device, which is why blueapi doesn't like it. It checks whether things being injected as arguments are plans or devices as different behaviour is needed for each.

    I guess we have 2 options:

    1. Make blueapi consider dataclasses to be a type of "device"
    2. Make the composite devices actual devices (probably StandardReadables or just plain ophyd-async Devices)

    How would you feel about option 2? I'm not hugely against option 1 but it would be nice to keep blueapi's definition of a device somewhat idomatic for future-proofing purposes.

  2. olliesilvester commented on Jun 18, 2024

    @olliesilvester
    ContributorAuthor

    I can't see any issues with doing 2. I think @DominicOram also mentioned this as a possibility

  3. DominicOram commented on Jun 18, 2024

    @DominicOram
    Contributor

    If it's a Device do we have to do the instantiation of the sub devices inside it? I'm not a big fan of that because we'll end up with potentially multiple instances. Alternatively we could get something like https://github.com/DiamondLightSource/hyperion/blob/main/src/hyperion/utils/context.py#L47 in to blueapi?

  4. callumforrester commented on Jun 18, 2024

    @callumforrester
    Contributor

    Hmm, I don't think so, if the devices already exist and you pass them in via the constructor, they may get .connect() called on them twice, but it is idempotent and cached to reduce network calls so that's not the end of the world. @coretl or @abbiemery can you see any problems with this approach?

  5. coretl commented on Jun 18, 2024

    @coretl
    Contributor

    @coretl or @abbiemery can you see any problems with this approach?

    The other issue is naming. If you name the parent it will attempt to name the child.

    The other option is to do the connect explicitly in a plan as proposed in DiamondLightSource/dodal#415 (comment)

  6. callumforrester commented on Jun 19, 2024

    @callumforrester
    Contributor

    From a discussion on Slack we think the idiomatic way to do this in blueapi is:

    class MyComposite(BlueapiBaseModel):
        x: Motor = inject("x")
        y: Motor = inject("y")
        det: Eiger = inject("det1")
    
    def my_plan(composite: MyComposite) -> MsgGenerator:
        ...

    Unsure if it will do this out of box, if it doesn't, this ticket will become a bug report.

  7. stan-dot commented on Sep 9, 2024

    @stan-dot
    Contributor

    @olliesilvester have you tried the implementation suggested above?

  8. olliesilvester commented on Sep 9, 2024

    @olliesilvester
    ContributorAuthor

    No, I haven't tried this yet

  9. olliesilvester commented on Oct 10, 2024

    @olliesilvester
    ContributorAuthor

    I think #647 is a prerequisite to seeing if this would work

  10. stan-dot commented on Oct 10, 2024

    @stan-dot
    Contributor

    I see, ok

  11. moved this to Candidates for Next Sprint in Hyperionon Jul 26, 2025
  12. DominicOram commented on Jul 29, 2025

    @DominicOram
    Contributor

    From a discussion on Slack we think the idiomatic way to do this in blueapi is:

    class MyComposite(BlueapiBaseModel):
    x: Motor = inject("x")
    y: Motor = inject("y")
    det: Eiger = inject("det1")

    def my_plan(composite: MyComposite) -> MsgGenerator:
    ...

    Unsure if it will do this out of box, if it doesn't, this ticket will become a bug report.

    Is this still what we want to work? This doesn't work out of the box, the test in 2716de2 fails

  13. DiamondJoseph commented on Aug 14, 2025

    @DiamondJoseph
    Contributor

    I made a prototype implementation here (select just the final commit, I merged main into both branches but github hasn't figured it out yet).

    The best way for handling when to inject a Composite, what types should be allowed to be a composite etc. are open for later discussion, but it's proof of concept that with only minor changes we can get it working.

    class MyComposite(BlueapiBaseModel):
        dev_a: FakeDevice = inject(fake_device.name)
        dev_b: FakeDevice = inject(second_fake_device.name)
    
        model_config = {"arbitrary_types_allowed": True}
    
    def injected_device_plan(composite: MyComposite = inject("")) -> MsgGenerator:
        yield from ()
  14. moved this from Candidates for Next Sprint to Todo This Sprint in Hyperionon Oct 1, 2025
  15. self-assigned this
    on Oct 10, 2025
  16. moved this from Todo This Sprint to In Progress in Hyperionon Oct 10, 2025
  17. moved this from In Progress to Review in Hyperionon Oct 14, 2025
  18. rtuck99 commented on Oct 23, 2025

    @rtuck99
    Contributor
  19. DiamondJoseph commented on Oct 28, 2025

    @DiamondJoseph
    Contributor

    @tpoliaw you had some opinions earlier about this re: Composite model/dataclass vs. Device class and inject (a device) vs. assemble (a collection of devices)

  20. tpoliaw commented on Oct 30, 2025

    @tpoliaw
    Contributor

    I don't like inject being used to both get devices from the environment based on name and create new models on the fly. The inject handling is already complex enough that it's not easy to follow what is going on and making it do two things is not appealing.

    On the other hand, after talking through the use cases, I can see the benefit of this approach over creating a plan with many, many parameters so we should support it in some way if possible.

    It could be possible to have an alternative that can be handled separately.

    Ideally something like a type annotation could work, eg composite: Annotated[Assemble, MyComposite] from the example above, although it would be difficult to know where the definition would live if it's needed in both dodal and blueapi. It might be that once blueapi stops depending on dodal, dodal could include a dependency on blueapi (or a module split out of it?) to include that sort of annotation. Maybe we should have gone for the utils suggestion in Dodal #1189

  21. moved this from Review to Done in Hyperionon Dec 17, 2025
  22. abbiemery commented on Feb 25, 2026

    @abbiemery
    Contributor

    Closed as done. #1242

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

c: rest apiREST API changesquestionFurther information is requested

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions