Skip to content

Implement SubControllerVector #192

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

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
4 changes: 3 additions & 1 deletion src/fastcs/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ def build_controller_api(controller: Controller) -> ControllerAPI:
return _build_controller_api(controller, [])


def _build_controller_api(controller: BaseController, path: list[str]) -> ControllerAPI:
def _build_controller_api(
controller: BaseController, path: list[str | int]
) -> ControllerAPI:
"""Build a `ControllerAPI` for a `BaseController` and its sub controllers"""
scan_methods: dict[str, Scan] = {}
put_methods: dict[str, Put] = {}
Expand Down
62 changes: 54 additions & 8 deletions src/fastcs/controller.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
from collections.abc import Iterator, Mapping, MutableMapping
from copy import deepcopy
from typing import get_type_hints

Expand All @@ -16,7 +17,7 @@ class BaseController:
description: str | None = None

def __init__(
self, path: list[str] | None = None, description: str | None = None
self, path: list[str | int] | None = None, description: str | None = None
) -> None:
if (
description is not None
Expand All @@ -25,8 +26,8 @@ def __init__(

if not hasattr(self, "attributes"):
self.attributes = {}
self._path: list[str] = path or []
self.__sub_controller_tree: dict[str, SubController] = {}
self._path: list[str | int] = path or []
self.__sub_controller_tree: dict[str | int, SubController] = {}

self._bind_attrs()

Expand All @@ -45,11 +46,11 @@ async def attribute_initialise(self) -> None:
await controller.attribute_initialise()

@property
def path(self) -> list[str]:
def path(self) -> list[str | int]:
"""Path prefix of attributes, recursively including parent Controllers."""
return self._path

def set_path(self, path: list[str]):
def set_path(self, path: list[str | int]):
if self._path:
raise ValueError(f"SubController is already registered under {self.path}")

Expand Down Expand Up @@ -98,7 +99,7 @@ class method and a controller instance, so that it can be called from any
elif isinstance(attr, UnboundPut | UnboundScan | UnboundCommand):
setattr(self, attr_name, attr.bind(self))

def register_sub_controller(self, name: str, sub_controller: SubController):
def register_sub_controller(self, name: str | int, sub_controller: SubController):
if name in self.__sub_controller_tree.keys():
raise ValueError(
f"Controller {self} already has a SubController registered as {name}"
Expand All @@ -114,9 +115,9 @@ def register_sub_controller(self, name: str, sub_controller: SubController):
f"on the parent controller `{type(self).__name__}` "
f"as it already has an attribute of that name."
)
self.attributes[name] = sub_controller.root_attribute
self.attributes[str(name)] = sub_controller.root_attribute

def get_sub_controllers(self) -> dict[str, SubController]:
def get_sub_controllers(self) -> dict[str | int, SubController]:
return self.__sub_controller_tree


Expand Down Expand Up @@ -147,3 +148,48 @@ class SubController(BaseController):

def __init__(self, description: str | None = None) -> None:
super().__init__(description=description)


class SubControllerVector(MutableMapping[int, SubController], SubController):
"""A collection of SubControllers, with an arbitrary integer index.
An instance of this class can be registered with a parent ``Controller`` to include
it's children as part of a larger controller. Each child of the vector will keep
a string name of the vector.
"""

def __init__(
self, children: Mapping[int, SubController], description: str | None = None
) -> None:
self._children: dict[int, SubController] = {}
self.update(children)
super().__init__(description=description)
for index, child in children.items():
self.register_sub_controller(index, child)

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

def __setitem__(self, key: int, value: SubController) -> None:
if not isinstance(key, int):
msg = f"Expected int, got {key}"
raise TypeError(msg)
if not isinstance(value, SubController):
msg = f"Expected SubController, got {value}"
raise TypeError(msg)
self._children[key] = value

def __delitem__(self, key: int) -> None:
del self._children[key]

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

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

def children(self) -> Iterator[tuple[str, SubController]]:
for key, child in self._children.items():
yield str(key), child

def __hash__(self):
return hash(id(self))
4 changes: 2 additions & 2 deletions src/fastcs/controller_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
class ControllerAPI:
"""Attributes, bound methods and sub APIs of a `Controller` / `SubController`"""

path: list[str] = field(default_factory=list)
path: list[str | int] = field(default_factory=list)
"""Path within controller tree (empty if this is the root)"""
attributes: dict[str, Attribute] = field(default_factory=dict)
command_methods: dict[str, Command] = field(default_factory=dict)
put_methods: dict[str, Put] = field(default_factory=dict)
scan_methods: dict[str, Scan] = field(default_factory=dict)
sub_apis: dict[str, "ControllerAPI"] = field(default_factory=dict)
sub_apis: dict[str | int, "ControllerAPI"] = field(default_factory=dict)
"""APIs of the sub controllers of the `Controller` this API was built from"""
description: str | None = None

Expand Down
10 changes: 5 additions & 5 deletions src/fastcs/transport/epics/ca/ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,11 @@ def _add_sub_controller_pvi_info(pv_prefix: str, parent: ControllerAPI):
parent: Controller to add PVI refs for

"""
parent_pvi = ":".join([pv_prefix] + parent.path + ["PVI"])
parent_pvi = ":".join([pv_prefix] + [str(node) for node in parent.path] + ["PVI"])

for child in parent.sub_apis.values():
child_pvi = ":".join([pv_prefix] + child.path + ["PVI"])
child_name = child.path[-1].lower()
child_pvi = ":".join([pv_prefix] + [str(node) for node in child.path] + ["PVI"])
child_name = str(child.path[-1]).lower()

_add_pvi_info(child_pvi, parent_pvi, child_name)

Expand All @@ -119,7 +119,7 @@ def _create_and_link_attribute_pvs(
pv_prefix: str, root_controller_api: ControllerAPI
) -> None:
for controller_api in root_controller_api.walk_api():
path = controller_api.path
path = [str(node) for node in controller_api.path]
for attr_name, attribute in controller_api.attributes.items():
pv_name = snake_to_pascal(attr_name)
_pv_prefix = ":".join([pv_prefix] + path)
Expand Down Expand Up @@ -218,7 +218,7 @@ def _create_and_link_command_pvs(
pv_prefix: str, root_controller_api: ControllerAPI
) -> None:
for controller_api in root_controller_api.walk_api():
path = controller_api.path
path = [str(node) for node in controller_api.path]
for attr_name, method in controller_api.command_methods.items():
pv_name = snake_to_pascal(attr_name)
_pv_prefix = ":".join([pv_prefix] + path)
Expand Down
12 changes: 7 additions & 5 deletions src/fastcs/transport/epics/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ def __init__(self, controller_api: ControllerAPI, pv_prefix: str) -> None:
self._controller_api = controller_api
self._pv_prefix = pv_prefix

def _get_pv(self, attr_path: list[str], name: str):
def _get_pv(self, attr_path: list[str | int], name: str):
attr_prefix = ":".join(
[self._pv_prefix] + [snake_to_pascal(node) for node in attr_path]
[self._pv_prefix] + [snake_to_pascal(str(node)) for node in attr_path]
)
return f"{attr_prefix}:{snake_to_pascal(name)}"

Expand Down Expand Up @@ -88,7 +88,7 @@ def _get_write_widget(self, fastcs_datatype: DataType) -> WriteWidgetUnion | Non
raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}")

def _get_attribute_component(
self, attr_path: list[str], name: str, attribute: Attribute
self, attr_path: list[str | int], name: str, attribute: Attribute
) -> SignalR | SignalW | SignalRW | None:
pv = self._get_pv(attr_path, name)
name = snake_to_pascal(name)
Expand Down Expand Up @@ -118,7 +118,7 @@ def _get_attribute_component(
case _:
raise FastCSException(f"Unsupported attribute type: {type(attribute)}")

def _get_command_component(self, attr_path: list[str], name: str):
def _get_command_component(self, attr_path: list[str | int], name: str):
pv = self._get_pv(attr_path, name)
name = snake_to_pascal(name)

Expand Down Expand Up @@ -149,6 +149,8 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree:
components: Tree = []

for name, api in controller_api.sub_apis.items():
if isinstance(name, int):
name = f"{controller_api.path[-1]}{name}"
components.append(
Group(
name=snake_to_pascal(name),
Expand Down Expand Up @@ -205,7 +207,7 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree:
class PvaEpicsGUI(EpicsGUI):
"""For creating gui in the PVA EPICS transport."""

def _get_pv(self, attr_path: list[str], name: str):
def _get_pv(self, attr_path: list[str | int], name: str):
return f"pva://{super()._get_pv(attr_path, name)}"

def _get_read_widget(self, fastcs_datatype: DataType) -> ReadWidgetUnion | None:
Expand Down
4 changes: 2 additions & 2 deletions src/fastcs/transport/epics/pva/ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ def _attribute_to_access(attribute: Attribute) -> AccessModeType:
raise ValueError(f"Unknown attribute type {type(attribute)}")


def get_pv_name(pv_prefix: str, *attribute_names: str) -> str:
def get_pv_name(pv_prefix: str, *attribute_names: str | int) -> str:
"""Converts from an attribute name to a pv name."""
pv_formatted = ":".join([snake_to_pascal(attr) for attr in attribute_names])
pv_formatted = ":".join([snake_to_pascal(str(attr)) for attr in attribute_names])
return f"{pv_prefix}:{pv_formatted}" if pv_formatted else pv_prefix


Expand Down
27 changes: 21 additions & 6 deletions src/fastcs/transport/epics/pva/pvi_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,29 @@ def _make_p4p_raw_value(self) -> dict:
stripped_leaf = pv_leaf.rstrip(":PVI")
is_controller = stripped_leaf != pv_leaf
pvi_name, number = _pv_to_pvi_name(stripped_leaf or pv_leaf)
if is_controller and number is not None:
if signal_info.access not in p4p_raw_value[pvi_name]:
p4p_raw_value[pvi_name][signal_info.access] = {}
p4p_raw_value[pvi_name][signal_info.access][f"v{number}"] = (
if is_controller and number is not None and not pvi_name:
pattern = rf"(?:(?<=:)|^)([^:]+)(?=:{re.escape(str(number))}(?:[:]|$))"
match = re.search(pattern, signal_info.pv)

if not match:
raise RuntimeError(
"Failed to extract parent SubControllerVector name "
f"from Subcontroller pv {signal_info.pv}"
)
if (
signal_info.access
not in p4p_raw_value[_pascal_to_snake(match.group(1))]
):
p4p_raw_value[_pascal_to_snake(match.group(1))][
signal_info.access
] = {}
p4p_raw_value[_pascal_to_snake(match.group(1))][signal_info.access][
f"v{number}"
] = signal_info.pv
elif is_controller:
p4p_raw_value[_pascal_to_snake(stripped_leaf)][signal_info.access] = (
signal_info.pv
)
elif is_controller:
p4p_raw_value[pvi_name][signal_info.access] = signal_info.pv
else:
attr_pvi_name = f"{pvi_name}{'' if number is None else number}"
p4p_raw_value[attr_pvi_name][signal_info.access] = signal_info.pv
Expand Down
2 changes: 1 addition & 1 deletion src/fastcs/transport/graphQL/graphQL.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def _process_commands(self, controller_api: ControllerAPI):
def _process_sub_apis(self, root_controller_api: ControllerAPI):
"""Recursively add fields from the queries and mutations of sub apis"""
for controller_api in root_controller_api.sub_apis.values():
name = "".join(controller_api.path)
name = "".join([str(node) for node in controller_api.path])
child_tree = GraphQLAPI(controller_api)
if child_tree.queries:
self.queries.append(
Expand Down
4 changes: 2 additions & 2 deletions src/fastcs/transport/rest/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ async def attr_get() -> Any: # Must be any as response_model is set

def _add_attribute_api_routes(app: FastAPI, root_controller_api: ControllerAPI) -> None:
for controller_api in root_controller_api.walk_api():
path = controller_api.path
path = [str(node) for node in controller_api.path]

for attr_name, attribute in controller_api.attributes.items():
attr_name = attr_name.replace("_", "-")
Expand Down Expand Up @@ -149,7 +149,7 @@ async def command() -> None:

def _add_command_api_routes(app: FastAPI, root_controller_api: ControllerAPI) -> None:
for controller_api in root_controller_api.walk_api():
path = controller_api.path
path = [str(node) for node in controller_api.path]

for name, method in root_controller_api.command_methods.items():
cmd_name = name.replace("_", "-")
Expand Down
7 changes: 4 additions & 3 deletions src/fastcs/transport/tango/dsr.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def _collect_dev_attributes(
) -> dict[str, Any]:
collection: dict[str, Any] = {}
for controller_api in root_controller_api.walk_api():
path = controller_api.path
path = [str(node) for node in controller_api.path]

for attr_name, attribute in controller_api.attributes.items():
attr_name = attr_name.title().replace("_", "")
Expand Down Expand Up @@ -109,7 +109,8 @@ def _wrap_command_f(
) -> Callable[..., Awaitable[None]]:
async def _dynamic_f(tango_device: Device) -> None:
tango_device.info_stream(
f"called {'_'.join(controller_api.path)} f method: {method_name}"
f"called {'_'.join([str(node) for node in controller_api.path])} "
f"f method: {method_name}"
)

coro = method()
Expand All @@ -125,7 +126,7 @@ def _collect_dev_commands(
) -> dict[str, Any]:
collection: dict[str, Any] = {}
for controller_api in root_controller_api.walk_api():
path = controller_api.path
path = [str(node) for node in controller_api.path]

for name, method in controller_api.command_methods.items():
cmd_name = name.title().replace("_", "")
Expand Down
26 changes: 20 additions & 6 deletions tests/example_p4p_ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import numpy as np

from fastcs.attributes import AttrHandlerW, AttrR, AttrRW, AttrW
from fastcs.controller import Controller, SubController
from fastcs.controller import Controller, SubController, SubControllerVector
from fastcs.datatypes import Bool, Enum, Float, Int, Table, Waveform
from fastcs.launch import FastCS
from fastcs.transport.epics.options import (
Expand Down Expand Up @@ -76,13 +76,27 @@ async def i(self):
def run(pv_prefix="P4P_TEST_DEVICE"):
p4p_options = EpicsPVAOptions(pva_ioc=EpicsIOCOptions(pv_prefix=pv_prefix))
controller = ParentController()
controller.register_sub_controller(
"Child1", ChildController(description="some sub controller")
)
controller.register_sub_controller(
"Child2", ChildController(description="another sub controller")
# controller.register_sub_controller(
# "Child1", ChildController(description="some sub controller")
# )
# controller.register_sub_controller(
# "Child2", ChildController(description="another sub controller")
# )

class Vector(SubControllerVector):
int: AttrR = AttrR(Int())

sub_controller = Vector(
{
1: ChildController(description="some sub controller"),
2: ChildController(description="another sub controller"),
}
)

controller.register_sub_controller("Child", sub_controller)

fastcs = FastCS(controller, [p4p_options])
fastcs.create_gui()
fastcs.run()


Expand Down
Loading
Loading