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

New signal typing #594

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ classifiers = [
description = "Asynchronous Bluesky hardware abstraction code, compatible with control systems like EPICS and Tango"
dependencies = [
"networkx>=2.0",
"numpy<2.0.0",
"numpy",
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we not have this working with numpy >=2? That's what's being installed currently right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

These changes will make use of np.dtypes.StringDType() as discussed with @danielballan which is only available in numpy 2, so yes I should make it numpy >= 2

"packaging",
"pint",
"bluesky>=1.13.0a3",
"event_model",
"p4p",
"event-model @ git+https://github.com/bluesky/event-model@main",
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we want to pin main here? Using latest release would incentivize regular event-model patch releases.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I see you're using Limits

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

There will probably be a few other import level changes to event model before I'm done, then we can release event model too and go back to a release here

"p4p>=4.2.0a3",
"pyyaml",
"colorlog",
"pydantic>=2.0",
Expand All @@ -37,10 +37,6 @@ dev = [
"ophyd_async[pva]",
"ophyd_async[sim]",
"ophyd_async[ca]",
"black",
"flake8",
"flake8-isort",
"Flake8-pyproject",
"inflection",
"ipython",
"ipywidgets",
Expand Down
31 changes: 24 additions & 7 deletions src/ophyd_async/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from ._flyer import StandardFlyer, TriggerLogic
from ._hdf_dataset import HDFDataset, HDFFile
from ._log import config_ophyd_async_logging
from ._mock_signal_backend import MockSignalBackend
from ._mock_signal_utils import (
callback_on_mock_put,
get_mock_put,
Expand Down Expand Up @@ -62,22 +61,33 @@
wait_for_value,
)
from ._signal_backend import (
RuntimeSubsetEnum,
Array1D,
SignalBackend,
SubsetEnum,
SignalConnector,
SignalDatatype,
SignalDatatypeT,
make_datakey,
)
from ._soft_signal_backend import (
MockSignalBackend,
SignalMetadata,
SoftSignalBackend,
SoftSignalConnector,
)
from ._soft_signal_backend import SignalMetadata, SoftSignalBackend
from ._status import AsyncStatus, WatchableAsyncStatus, completed_status
from ._table import Table
from ._utils import (
CALCULATE_TIMEOUT,
DEFAULT_TIMEOUT,
CalculatableTimeout,
Callback,
NotConnected,
ReadingValueCallback,
StrictEnum,
SubsetEnum,
T,
WatcherUpdate,
get_dtype,
get_enum_cls,
get_unique,
in_micros,
is_pydantic_model,
Expand Down Expand Up @@ -146,22 +156,29 @@
"soft_signal_r_and_setter",
"soft_signal_rw",
"wait_for_value",
"RuntimeSubsetEnum",
"Array1D",
"SignalBackend",
"SignalConnector",
"make_datakey",
"StrictEnum",
"SubsetEnum",
"SignalDatatype",
"SignalDatatypeT",
"SignalMetadata",
"SoftSignalBackend",
"SoftSignalConnector",
"AsyncStatus",
"WatchableAsyncStatus",
"DEFAULT_TIMEOUT",
"CalculatableTimeout",
"Callback",
"CALCULATE_TIMEOUT",
"NotConnected",
"ReadingValueCallback",
"Table",
"T",
"WatcherUpdate",
"get_dtype",
"get_enum_cls",
"get_unique",
"in_micros",
"is_pydantic_model",
Expand Down
123 changes: 76 additions & 47 deletions src/ophyd_async/core/_device.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,63 @@
"""Base device"""
from __future__ import annotations

import asyncio
import sys
from collections.abc import Coroutine, Generator, Iterator
from abc import abstractmethod
from collections.abc import Callable, Coroutine, Iterator, Mapping
from functools import cached_property
from logging import LoggerAdapter, getLogger
from typing import (
Any,
Optional,
TypeVar,
)
from typing import Any, Generic, TypeVar, cast

from bluesky.protocols import HasName
from bluesky.run_engine import call_in_bluesky_event_loop

from ._utils import DEFAULT_TIMEOUT, NotConnected, wait_for_connection


class Device(HasName):
class DeviceConnector:
@abstractmethod
async def connect(
self, device: Device, mock: bool, timeout: float, force_reconnect: bool
) -> None: ...


class DeviceChildConnector(DeviceConnector):
async def connect(
self, device: Device, mock: bool, timeout: float, force_reconnect: bool
) -> Any:
coretl marked this conversation as resolved.
Show resolved Hide resolved
coros = {
name: child_device.connect(mock, timeout, force_reconnect)
for name, child_device in device.children().items()
coretl marked this conversation as resolved.
Show resolved Hide resolved
}
await wait_for_connection(**coros)


DeviceConnectorType = TypeVar("DeviceConnectorType", bound=DeviceConnector)


class Device(HasName, Generic[DeviceConnectorType]):
"""Common base class for all Ophyd Async Devices.

By default, names and connects all Device children.
"""

_name: str = ""
#: The parent Device if it exists
parent: Optional["Device"] = None
parent: Device | None = None
# None if connect hasn't started, a Task if it has
_connect_task: asyncio.Task | None = None
# The value of the mock arg to connect
_connect_mock: bool | None = None
# The connector to use
coretl marked this conversation as resolved.
Show resolved Hide resolved
_connector: DeviceConnectorType = DeviceChildConnector()

# Used to check if the previous connect was mocked,
# if the next mock value differs then we fail
_previous_connect_was_mock = None

def __init__(self, name: str = "") -> None:
def __init__(
self,
name: str = "",
connector: DeviceConnectorType | None = None,
) -> None:
if connector is not None:
self._connector = connector
self.set_name(name)

@property
Expand All @@ -47,10 +71,12 @@ def log(self):
getLogger("ophyd_async.devices"), {"ophyd_async_device_name": self.name}
)

def children(self) -> Iterator[tuple[str, "Device"]]:
for attr_name, attr in self.__dict__.items():
if attr_name != "parent" and isinstance(attr, Device):
yield attr_name, attr
def children(self) -> dict[str, Device]:
return {
attr_name: attr
for attr_name, attr in self.__dict__.items()
if attr_name != "parent" and isinstance(attr, Device)
}

Comment on lines +84 to +89
Copy link
Contributor

Choose a reason for hiding this comment

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

Since the attributes of a class won't change post init, how about making this a cached property?

The only place we actually setattr on a Device is in PVI and I'd like to revisit that anyway (we could talk about this).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Let's discuss this more

Comment on lines +84 to +89
Copy link
Contributor

Choose a reason for hiding this comment

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

Looking at the rest of the code I've had an idea...

Following from what was said in the meeting (having both Device and DeviceVector inherit from some abstract base class, maybe this could return a DeviceVector? Then the tree connecting/walking would be handled in the DeviceVector generically.

Copy link
Contributor

@evalott100 evalott100 Sep 26, 2024

Choose a reason for hiding this comment

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

Tbh we don't have to define use an abstract class, instead we can:

class Device(HasName):
    @property
    def children(self) -> "DeviceVector":
        return DeviceVector(
            {
                attr_name: attr
                for attr_name, attr in self.__dict__.items()
                if attr_name != "parent" and isinstance(attr, Device)
            }
        )

    def connect(self, ...):
        # DO STUFF
        self.children.connect(...)

class DeviceVector(dict[int, VT], Device):
    def connect(self, *args, **kwargs):
        # Logic for connecting which can be used for by Device
        # For its children
        gather({
            device.connect(*args, **kwargs)
            for device_name, device in self.items():
        })
        

    def children(self) -> DeviceVector:
        return self

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This reduces the code a bit for the current use case, but I think it will break what I had planned for PVI. Let me write that first then we can revisit this and see if this still applies...

def set_name(self, name: str):
"""Set ``self.name=name`` and each ``self.child.name=name+"-child"``.
Expand All @@ -66,7 +92,7 @@ def set_name(self, name: str):
del self.log

self._name = name
for attr_name, child in self.children():
for attr_name, child in self.children().items():
child_name = f"{name}-{attr_name.rstrip('_')}" if name else ""
coretl marked this conversation as resolved.
Show resolved Hide resolved
child.set_name(child_name)
child.parent = self
Expand All @@ -89,40 +115,27 @@ async def connect(
Time to wait before failing with a TimeoutError.
"""

if (
self._previous_connect_was_mock is not None
and self._previous_connect_was_mock != mock
):
raise RuntimeError(
f"`connect(mock={mock})` called on a `Device` where the previous "
f"connect was `mock={self._previous_connect_was_mock}`. Changing mock "
"value between connects is not permitted."
)
self._previous_connect_was_mock = mock

# If previous connect with same args has started and not errored, can use it
can_use_previous_connect = self._connect_task and not (
self._connect_task.done() and self._connect_task.exception()
can_use_previous_connect = (
mock is self._connect_mock
and self._connect_task
and not (self._connect_task.done() and self._connect_task.exception())
)
if force_reconnect or not can_use_previous_connect:
# Kick off a connection
coros = {
name: child_device.connect(
mock, timeout=timeout, force_reconnect=force_reconnect
)
for name, child_device in self.children()
}
self._connect_task = asyncio.create_task(wait_for_connection(**coros))

# Use the connector to make a new connection
self._connect_mock = mock
self._connect_task = asyncio.create_task(
self._connector.connect(self, mock, timeout, force_reconnect)
)
assert self._connect_task, "Connect task not created, this shouldn't happen"
# Wait for it to complete
await self._connect_task


VT = TypeVar("VT", bound=Device)
DeviceType = TypeVar("DeviceType", bound=Device)


class DeviceVector(dict[int, VT], Device):
class DeviceVector(Mapping[int, DeviceType], Device):
"""
Defines device components with indices.

Expand All @@ -131,10 +144,26 @@ class DeviceVector(dict[int, VT], Device):
:class:`~ophyd_async.epics.demo.DynamicSensorGroup`
"""

def children(self) -> Generator[tuple[str, Device], None, None]:
for attr_name, attr in self.items():
if isinstance(attr, Device):
yield str(attr_name), attr
def __init__(
self,
children: dict[int, DeviceType],
name: str = "",
connector: DeviceConnector | None = None,
) -> None:
self._children = children
super().__init__(name, connector)

def __getitem__(self, key: int) -> DeviceType:
return self._children[key]

def __iter__(self) -> Iterator[int]:
yield from self._children

def __len__(self) -> int:
return len(self._children)

def children(self) -> dict[str, Device]:
return {str(key): value for key, value in self.items()}


class DeviceCollector:
Expand Down
84 changes: 0 additions & 84 deletions src/ophyd_async/core/_mock_signal_backend.py

This file was deleted.

Loading
Loading