fastcs-pandablocks 0.2.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ """
2
+ This method has a `RawPanda` which handles all the io with the client.
3
+ """
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncGenerator
7
+ from pprint import pformat
8
+
9
+ from fastcs.datatypes import DataType
10
+ from fastcs.logging import bind_logger
11
+ from pandablocks.asyncio import AsyncioClient
12
+ from pandablocks.commands import (
13
+ Arm,
14
+ ChangeGroup,
15
+ Disarm,
16
+ Get,
17
+ GetBlockInfo,
18
+ GetChanges,
19
+ GetFieldInfo,
20
+ Put,
21
+ )
22
+ from pandablocks.responses import Data
23
+
24
+ from fastcs_pandablocks.types import (
25
+ PandaName,
26
+ RawBlocksType,
27
+ RawFieldsType,
28
+ RawInitialValuesType,
29
+ )
30
+
31
+ logger = bind_logger(__name__)
32
+
33
+
34
+ class RawPanda:
35
+ """A wrapper for interacting with pandablocks-client."""
36
+
37
+ def __init__(self, hostname: str):
38
+ self._client = AsyncioClient(host=hostname)
39
+
40
+ async def connect(self):
41
+ await self._client.connect()
42
+
43
+ async def disconnect(self):
44
+ await self._client.close()
45
+
46
+ async def put_value_to_panda(
47
+ self,
48
+ panda_name: PandaName,
49
+ fastcs_datatype: DataType,
50
+ value: str | list[str],
51
+ ) -> None:
52
+ await self.send(str(panda_name), value)
53
+
54
+ async def introspect(
55
+ self,
56
+ ) -> tuple[
57
+ RawBlocksType, RawFieldsType, RawInitialValuesType, RawInitialValuesType
58
+ ]:
59
+ blocks, fields, labels, initial_values = {}, [], {}, {}
60
+
61
+ raw_blocks = await self._client.send(GetBlockInfo())
62
+ blocks = {
63
+ PandaName.from_string(name): block_info
64
+ for name, block_info in raw_blocks.items()
65
+ }
66
+ formatted_blocks = pformat(blocks, indent=4).replace("\n", "\n ")
67
+ logger.debug(f"BLOCKS RECEIVED:\n {formatted_blocks}")
68
+
69
+ raw_fields = await asyncio.gather(
70
+ *[self._client.send(GetFieldInfo(str(block))) for block in blocks]
71
+ )
72
+ fields = [
73
+ {
74
+ PandaName(field=name): field_info
75
+ for name, field_info in block_values.items()
76
+ }
77
+ for block_values in raw_fields
78
+ ]
79
+ logger.debug("FIELDS RECEIVED (TOO VERBOSE TO LOG)")
80
+
81
+ field_data = await self.get_changes()
82
+
83
+ for field_name, value in field_data.items():
84
+ if field_name.startswith("*METADATA"):
85
+ field_name_without_prefix = field_name.removeprefix("*METADATA.")
86
+ if field_name_without_prefix == "DESIGN":
87
+ continue # TODO: Handle design.
88
+ elif not field_name_without_prefix.startswith("LABEL_"):
89
+ logger.warning(
90
+ "Ignoring received metadata not corresponding to a `LABEL_`",
91
+ field_name=field_name,
92
+ value=value,
93
+ )
94
+ labels[
95
+ PandaName.from_string(
96
+ field_name_without_prefix.removeprefix("LABEL_")
97
+ )
98
+ ] = value
99
+ else: # Field is a default value
100
+ initial_values[PandaName.from_string(field_name)] = value
101
+
102
+ formatted_initial_values = pformat(initial_values, indent=4).replace(
103
+ "\n", "\n "
104
+ )
105
+ logger.debug(f"INITIAL VALUES:\n {formatted_initial_values}")
106
+ formatted_labels = pformat(labels, indent=4).replace("\n", "\n ")
107
+ logger.debug(f"LABELS:\n {formatted_labels}")
108
+
109
+ return blocks, fields, labels, initial_values
110
+
111
+ async def send(self, name: str, value: str | list[str]):
112
+ logger.debug(f"SENDING TO PANDA:\n {name} = {pformat(value, indent=4)}")
113
+ await self._client.send(Put(name, value))
114
+
115
+ async def get(self, name: str) -> str | list[str]:
116
+ received = await self._client.send(Get(name))
117
+ formatted_received = pformat(received, indent=4).replace("\n", "\n ")
118
+ logger.debug(f"RECEIVED FROM PANDA:\n {name} = {formatted_received}")
119
+ return received
120
+
121
+ async def get_changes(self) -> dict[str, str | list[str]]:
122
+ changes = await self._client.send(GetChanges(ChangeGroup.ALL, True))
123
+ single_and_multiline_changes = {
124
+ **changes.values,
125
+ **changes.multiline_values,
126
+ }
127
+ formatted_received = pformat(single_and_multiline_changes, indent=4).replace(
128
+ "\n", "\n "
129
+ )
130
+ logger.debug(f"RECEIVED CHANGES:\n {formatted_received}")
131
+ return single_and_multiline_changes
132
+
133
+ async def arm(self):
134
+ await self._client.send(Arm())
135
+
136
+ async def disarm(self):
137
+ await self._client.send(Disarm())
138
+
139
+ async def data(
140
+ self, scaled: bool, flush_period: float
141
+ ) -> AsyncGenerator[Data, None]:
142
+ async for data in self._client.data(scaled=scaled, flush_period=flush_period):
143
+ yield data
@@ -0,0 +1,10 @@
1
+ ## yaml-language-server: $schema=schema.json
2
+
3
+ controller:
4
+ address: "172.23.252.201"
5
+
6
+ transport:
7
+ - epicspva:
8
+ pv_prefix: TEST-PANDA
9
+ gui:
10
+ output_path: /workspaces/opi/fastcs-Panda.bob
@@ -0,0 +1,37 @@
1
+ import enum
2
+ import logging
3
+ from collections.abc import Callable, Coroutine
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from fastcs.attributes import (
8
+ AttributeIO,
9
+ AttributeIORef,
10
+ AttrW,
11
+ )
12
+ from fastcs.datatypes import DType_T
13
+
14
+
15
+ class ArmCommand(enum.Enum):
16
+ """Enum class for PandA arm fields."""
17
+
18
+ DISARM = "Disarm"
19
+ ARM = "Arm"
20
+
21
+
22
+ @dataclass
23
+ class ArmIORef(AttributeIORef):
24
+ arm: Callable[[], Coroutine[None, None, None]]
25
+ disarm: Callable[[], Coroutine[None, None, None]]
26
+
27
+
28
+ class ArmIO(AttributeIO[DType_T, ArmIORef]):
29
+ """A sender for arming and disarming the Pcap."""
30
+
31
+ async def send(self, attr: AttrW[DType_T, ArmIORef], value: Any):
32
+ if value is ArmCommand.ARM:
33
+ logging.info("Arming PandA.")
34
+ await attr.io_ref.arm()
35
+ else:
36
+ logging.info("Disarming PandA.")
37
+ await attr.io_ref.disarm()
@@ -0,0 +1,43 @@
1
+ import asyncio
2
+ import enum
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from fastcs.attributes import (
7
+ AttrRW,
8
+ )
9
+ from fastcs.datatypes import DType_T, Enum
10
+
11
+
12
+ async def _set_attr_if_not_already_value(attribute: AttrRW[DType_T], value: DType_T):
13
+ if attribute.get() != value:
14
+ await attribute.update(value)
15
+
16
+
17
+ @dataclass
18
+ class BitGroupOnUpdate:
19
+ """Bits are tied together in bit groups so that when one is set for capture,
20
+ they all are.
21
+
22
+ This callback sets all capture attributes in the group when one of them is set.
23
+ """
24
+
25
+ capture_attribute: AttrRW[enum.Enum]
26
+ bit_attributes: list[AttrRW[bool]]
27
+
28
+ async def __call__(self, value: Any):
29
+ if isinstance(value, enum.Enum):
30
+ bool_value = bool(self.capture_attribute.datatype.index_of(value)) # type: ignore
31
+ enum_value = value
32
+ else:
33
+ bool_value = value
34
+ assert isinstance(self.capture_attribute.datatype, Enum)
35
+ enum_value = self.capture_attribute.datatype.members[int(value)]
36
+
37
+ await asyncio.gather(
38
+ *[
39
+ _set_attr_if_not_already_value(bit_attr, bool_value)
40
+ for bit_attr in self.bit_attributes
41
+ ],
42
+ _set_attr_if_not_already_value(self.capture_attribute, enum_value),
43
+ )
@@ -0,0 +1,36 @@
1
+ from collections.abc import Callable, Coroutine
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ from fastcs.attributes import (
6
+ AttributeIO,
7
+ AttributeIORef,
8
+ AttrW,
9
+ )
10
+ from fastcs.datatypes import DataType, DType_T
11
+
12
+ from fastcs_pandablocks.panda.utils import (
13
+ attribute_value_to_panda_value,
14
+ )
15
+ from fastcs_pandablocks.types import PandaName
16
+
17
+
18
+ @dataclass
19
+ class DefaultFieldIORef(AttributeIORef):
20
+ panda_name: PandaName
21
+ put_value_to_panda: Callable[
22
+ [PandaName, DataType, Any], Coroutine[None, None, None]
23
+ ]
24
+
25
+
26
+ class DefaultFieldIO(AttributeIO[DType_T, DefaultFieldIORef]):
27
+ """Default IO for sending and updating introspected attributes."""
28
+
29
+ async def send(
30
+ self, attr: AttrW[DType_T, DefaultFieldIORef], value: DType_T
31
+ ) -> None:
32
+ await attr.io_ref.put_value_to_panda(
33
+ attr.io_ref.panda_name,
34
+ attr.datatype,
35
+ attribute_value_to_panda_value(attr.datatype, value),
36
+ )
@@ -0,0 +1,38 @@
1
+ from collections.abc import Callable, Coroutine
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ from fastcs.attributes import (
6
+ AttributeIO,
7
+ AttributeIORef,
8
+ AttrW,
9
+ )
10
+ from fastcs.datatypes import DataType, DType_T
11
+ from pandablocks.responses import TableFieldInfo
12
+ from pandablocks.utils import table_to_words
13
+
14
+ from fastcs_pandablocks.panda.utils import (
15
+ attribute_value_to_panda_value,
16
+ )
17
+ from fastcs_pandablocks.types import PandaName
18
+
19
+
20
+ @dataclass
21
+ class TableFieldIORef(AttributeIORef):
22
+ panda_name: PandaName
23
+ field_info: TableFieldInfo
24
+ put_value_to_panda: Callable[
25
+ [PandaName, DataType, Any], Coroutine[None, None, None]
26
+ ]
27
+
28
+
29
+ class TableFieldIO(AttributeIO[DType_T, TableFieldIORef]):
30
+ """An IO for updating Table valued attributes."""
31
+
32
+ async def send(self, attr: AttrW[DType_T, TableFieldIORef], value: DType_T) -> None:
33
+ attr_value = attribute_value_to_panda_value(attr.datatype, value)
34
+ assert isinstance(attr_value, dict)
35
+ panda_words = table_to_words(attr_value, attr.io_ref.field_info)
36
+ await attr.io_ref.put_value_to_panda(
37
+ attr.io_ref.panda_name, attr.datatype, panda_words
38
+ )
@@ -0,0 +1,49 @@
1
+ import enum
2
+ from collections.abc import Callable, Coroutine
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from fastcs.attributes import (
7
+ AttributeIO,
8
+ AttributeIORef,
9
+ AttrRW,
10
+ AttrW,
11
+ )
12
+ from fastcs.datatypes import DataType, Float
13
+
14
+ from fastcs_pandablocks.panda.utils import (
15
+ attribute_value_to_panda_value,
16
+ )
17
+ from fastcs_pandablocks.types import PandaName
18
+
19
+
20
+ class TimeUnit(enum.Enum):
21
+ """Enum class for PandA time fields."""
22
+
23
+ min = "min"
24
+ s = "s"
25
+ ms = "ms"
26
+ us = "us"
27
+
28
+
29
+ @dataclass
30
+ class UnitsIORef(AttributeIORef):
31
+ attribute_to_scale: AttrRW
32
+ current_scale: TimeUnit
33
+ panda_name: PandaName
34
+ put_value_to_panda: Callable[
35
+ [PandaName, DataType, Any], Coroutine[None, None, None]
36
+ ]
37
+
38
+
39
+ class UnitsIO(AttributeIO[enum.Enum, UnitsIORef]):
40
+ """A sender for arming and disarming the Pcap."""
41
+
42
+ async def send(self, attr: AttrW[enum.Enum, UnitsIORef], value: enum.Enum):
43
+ await attr.io_ref.put_value_to_panda(
44
+ attr.io_ref.panda_name,
45
+ attr.datatype,
46
+ attribute_value_to_panda_value(attr.datatype, value),
47
+ )
48
+
49
+ attr.io_ref.attribute_to_scale.update_datatype(Float(units=value.name, prec=5))
@@ -0,0 +1,125 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ from fastcs.attributes import Attribute, AttrR
6
+ from fastcs.controllers import Controller
7
+ from fastcs.datatypes import Table
8
+ from fastcs.logging import bind_logger
9
+ from fastcs.methods import scan
10
+ from pandablocks.utils import words_to_table
11
+
12
+ from fastcs_pandablocks.panda.blocks import Blocks
13
+ from fastcs_pandablocks.panda.client_wrapper import RawPanda
14
+ from fastcs_pandablocks.panda.io.arm import ArmIO
15
+ from fastcs_pandablocks.panda.io.default import DefaultFieldIO
16
+ from fastcs_pandablocks.panda.io.table import TableFieldIO, TableFieldIORef
17
+ from fastcs_pandablocks.panda.io.units import UnitsIO
18
+ from fastcs_pandablocks.panda.utils import panda_value_to_attribute_value
19
+ from fastcs_pandablocks.types import PandaName
20
+
21
+ logger = bind_logger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class PandaControllerSettings:
26
+ address: str
27
+
28
+
29
+ class PandaController(Controller):
30
+ """Controller for polling data from the panda through pandablocks-client.
31
+
32
+ Changes are received at a given poll period and passed to sub-controllers.
33
+ """
34
+
35
+ def __init__(self, settings: PandaControllerSettings) -> None:
36
+ # TODO https://github.com/DiamondLightSource/FastCS/issues/62
37
+
38
+ self._raw_panda = RawPanda(settings.address)
39
+ self._ios = [ArmIO(), DefaultFieldIO(), TableFieldIO(), UnitsIO()]
40
+ self._blocks: Blocks = Blocks(self._raw_panda, ios=self._ios)
41
+ self.connected = False
42
+
43
+ super().__init__(ios=self._ios)
44
+
45
+ async def connect(self) -> None:
46
+ if self.connected:
47
+ # `connect` needs to be called in `initialise`,
48
+ # then FastCS will attempt to call it again.
49
+ return
50
+ await self._raw_panda.connect()
51
+ await self._blocks.parse_introspected_data()
52
+ await self._blocks.setup_post_introspection()
53
+ self.connected = True
54
+
55
+ async def initialise(self) -> None:
56
+ await self.connect()
57
+
58
+ for block_name, block in self._blocks.controllers():
59
+ # Numerically named controllers are registered to
60
+ # alphabetically named ControllerVectors, so only
61
+ # alphabetically named controllers
62
+ # should be registed to top level Controller
63
+ if str(block_name).isalpha():
64
+ self.add_sub_controller(block_name.lower(), block)
65
+ await block.initialise()
66
+
67
+ async def update_field_value(self, panda_name: PandaName, value: str | list[str]):
68
+ """Update a panda field with either a single value or a list of words."""
69
+
70
+ attribute = self._blocks.get_attribute(panda_name)
71
+ assert isinstance(attribute, AttrR)
72
+ if attribute is None:
73
+ logger.error(f"Couldn't find panda field for {panda_name}.")
74
+ return
75
+
76
+ try:
77
+ attribute_value = self._coerce_value_to_panda_type(attribute, value)
78
+ except ValueError:
79
+ logger.opt(exception=True).error("Coerce failed")
80
+ return
81
+
82
+ await self.update_attribute(attribute, attribute_value)
83
+
84
+ def _coerce_value_to_panda_type(
85
+ self, attribute: Attribute, value: str | list[str]
86
+ ) -> Any:
87
+ """Convert a provided value into an attribute_value for this panda attribute."""
88
+ match value:
89
+ case list() as words:
90
+ if not isinstance(attribute.datatype, Table):
91
+ raise ValueError(f"{attribute} is not a Table attribute")
92
+ io_ref = attribute.io_ref
93
+ if not isinstance(io_ref, TableFieldIORef):
94
+ raise ValueError(
95
+ f"AttributeIORef for {attribute} is not TableFieldIORef"
96
+ )
97
+ table_values = words_to_table(
98
+ words, io_ref.field_info, convert_enum_indices=True
99
+ )
100
+ return panda_value_to_attribute_value(attribute.datatype, table_values)
101
+ case _:
102
+ return panda_value_to_attribute_value(attribute.datatype, value)
103
+
104
+ async def update_attribute(self, attribute: AttrR, attribute_value: Any) -> None:
105
+ """Dispatch setting logic based on attribute type."""
106
+ value = attribute.datatype.validate(attribute_value)
107
+ await attribute.update(value)
108
+
109
+ @scan(0.1)
110
+ async def update(self):
111
+ try:
112
+ changes = await self._raw_panda.get_changes()
113
+ await asyncio.gather(
114
+ *[
115
+ self.update_field_value(
116
+ PandaName.from_string(raw_panda_name), value
117
+ )
118
+ for raw_panda_name, value in changes.items()
119
+ ]
120
+ )
121
+ # TODO: General exception is not ideal; narrow this dowm.
122
+ except Exception as e:
123
+ raise RuntimeError(
124
+ "Failed to update changes from PandaBlocks client"
125
+ ) from e
@@ -0,0 +1,52 @@
1
+ from typing import Any
2
+
3
+ import numpy as np
4
+ from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, Table
5
+
6
+
7
+ def panda_value_to_attribute_value(fastcs_datatype: DataType, value: str | dict) -> Any:
8
+ """Converts from a value received from the panda to the attribute value."""
9
+
10
+ match fastcs_datatype:
11
+ case String():
12
+ return fastcs_datatype.validate(value)
13
+ case Bool():
14
+ assert isinstance(value, str)
15
+ return fastcs_datatype.validate(int(value))
16
+ case Int() | Float():
17
+ return fastcs_datatype.validate(value)
18
+ case Enum():
19
+ return fastcs_datatype.enum_cls[value] # type: ignore
20
+ case Table():
21
+ assert isinstance(value, dict)
22
+ num_rows = len(next(iter(value.values())))
23
+ structured_datatype = fastcs_datatype.structured_dtype
24
+ attribute_value = np.zeros(num_rows, fastcs_datatype.structured_dtype)
25
+ for field_name, _ in structured_datatype:
26
+ attribute_value[field_name] = value[field_name.upper()]
27
+ return attribute_value
28
+
29
+ case _:
30
+ raise NotImplementedError(f"Unknown datatype {fastcs_datatype}")
31
+
32
+
33
+ def attribute_value_to_panda_value(fastcs_datatype: DataType, value: Any) -> str | dict:
34
+ """Converts from an attribute value to a value that can be sent to the panda."""
35
+
36
+ match fastcs_datatype:
37
+ case String():
38
+ return value
39
+ case Bool():
40
+ return str(int(value))
41
+ case Int() | Float():
42
+ return str(value)
43
+ case Enum():
44
+ return value.name
45
+ case Table():
46
+ assert isinstance(value, np.ndarray)
47
+ panda_value = {}
48
+ for field_name, _ in fastcs_datatype.structured_dtype:
49
+ panda_value[field_name.upper()] = value[field_name].tolist()
50
+ return panda_value
51
+ case _:
52
+ raise NotImplementedError(f"Unknown datatype {fastcs_datatype}")
@@ -0,0 +1,34 @@
1
+ from enum import Enum
2
+
3
+ from ._annotations import (
4
+ RawBlocksType,
5
+ RawFieldsType,
6
+ RawInitialValuesType,
7
+ ResponseType,
8
+ )
9
+ from ._string_types import (
10
+ PANDA_SEPARATOR,
11
+ PandaName,
12
+ )
13
+
14
+
15
+ class WidgetGroup(Enum):
16
+ """Group that an attribute will be added to on the screen."""
17
+
18
+ NONE = None
19
+ PARAMETERS = "Parameters"
20
+ OUTPUTS = "Outputs"
21
+ INPUTS = "Inputs"
22
+ READBACKS = "Readbacks"
23
+ CAPTURE = "Capture"
24
+
25
+
26
+ __all__ = [
27
+ "PANDA_SEPARATOR",
28
+ "PandaName",
29
+ "ResponseType",
30
+ "RawBlocksType",
31
+ "RawFieldsType",
32
+ "RawInitialValuesType",
33
+ "WidgetGroup",
34
+ ]
@@ -0,0 +1,42 @@
1
+ from typing import Union
2
+
3
+ from pandablocks.responses import (
4
+ BitMuxFieldInfo,
5
+ BitOutFieldInfo,
6
+ BlockInfo,
7
+ EnumFieldInfo,
8
+ ExtOutBitsFieldInfo,
9
+ ExtOutFieldInfo,
10
+ FieldInfo,
11
+ PosMuxFieldInfo,
12
+ PosOutFieldInfo,
13
+ ScalarFieldInfo,
14
+ SubtypeTimeFieldInfo,
15
+ TableFieldInfo,
16
+ TimeFieldInfo,
17
+ UintFieldInfo,
18
+ )
19
+
20
+ from ._string_types import PandaName
21
+
22
+ # Pyright gives us variable not allowed in type expression error
23
+ # if we try to use the new (|) syntax
24
+ ResponseType = Union[
25
+ BitMuxFieldInfo,
26
+ BitOutFieldInfo,
27
+ EnumFieldInfo,
28
+ ExtOutBitsFieldInfo,
29
+ ExtOutFieldInfo,
30
+ FieldInfo,
31
+ PosMuxFieldInfo,
32
+ PosOutFieldInfo,
33
+ ScalarFieldInfo,
34
+ SubtypeTimeFieldInfo,
35
+ TableFieldInfo,
36
+ TimeFieldInfo,
37
+ UintFieldInfo,
38
+ ]
39
+
40
+ RawBlocksType = dict[PandaName, BlockInfo]
41
+ RawFieldsType = list[dict[PandaName, ResponseType]]
42
+ RawInitialValuesType = dict[PandaName, str]