diff --git a/docs/explanations/controllers.md b/docs/explanations/controllers.md
index 3d18defd5..30a09da0a 100644
--- a/docs/explanations/controllers.md
+++ b/docs/explanations/controllers.md
@@ -37,13 +37,12 @@ sets it back to `True`.
```python
from fastcs.controllers import Controller
from fastcs.attributes import AttrR, AttrRW
-from fastcs.datatypes import Float, String
from fastcs.methods import scan
class TemperatureController(Controller):
- temperature = AttrR(Float(units="degC"))
- setpoint = AttrRW(Float(units="degC"))
+ temperature = AttrR(float, units="degC")
+ setpoint = AttrRW(float, units="degC")
async def connect(self):
self._client = await DeviceClient.connect(self._host, self._port)
@@ -73,7 +72,7 @@ controller also has connection logic, the parent must invoke it explicitly:
```python
class ChannelController(Controller):
- value = AttrR(Float())
+ value = AttrR(float)
async def connect(self):
...
@@ -108,7 +107,7 @@ from fastcs.controllers import Controller, ControllerVector
class ChannelController(Controller):
- value = AttrR(Float())
+ value = AttrR(float)
class RootController(Controller):
diff --git a/docs/explanations/datatypes.md b/docs/explanations/datatypes.md
index fb1d81740..4a2c9e1f9 100644
--- a/docs/explanations/datatypes.md
+++ b/docs/explanations/datatypes.md
@@ -1,81 +1,83 @@
# Datatypes
-FastCS uses a datatype system to map Python types to attributes with additional
-metadata for validation, serialization, and transport handling.
+An attribute's datatype is a **python type**. Everything else that describes the
+attribute - precision, units, limits, array shape - is **metadata**, passed as
+keyword arguments and held on the attribute as `attr.meta`.
+
+```python
+from fastcs.attributes import AttrRW
+
+temperature = AttrRW(float, precision=3, units="degC")
+```
+
+There is no `DataType` object to construct and no wrapper to unwrap: `attr.dtype`
+is `float`, and `attr.meta` is a plain typed dict.
## Supported Types
-FastCS defines `DType` as the union of supported Python types:
+FastCS defines `DType` as the union of supported python types:
-:::{literalinclude} ../../src/fastcs/datatypes/datatype.py
+:::{literalinclude} ../../src/fastcs/datatypes/types.py
:start-at: "DType = ("
:end-at: ")"
:::
-Each has a corresponding `DataType` class.
-
## Scalar Datatypes
-### Int and Float
+`int`, `float`, `bool` and `str` are used directly. Which metadata each accepts is
+given by its `*Meta` typed dict:
-Both inherit from `_Numeric`, which adds support for bounds and alarm limits:
-
-:::{literalinclude} ../../src/fastcs/datatypes/_numeric.py
-:start-at: "@dataclass(frozen=True)"
-:end-at: "max_alarm:"
-:::
+| Datatype | Metadata |
+| -------- | ----------------------------------------------------- |
+| `bool` | `description`, `group` |
+| `int` | `description`, `group`, `units`, `limits` |
+| `float` | `description`, `group`, `units`, `limits`, `precision` |
+| `str` | `description`, `group`, `length` |
-### Bool
+`precision` is the number of decimal places a float is rounded to and displayed
+with; it defaults to 2. `length` truncates a string during validation, and is
+also a hint to transports sizing their records - the EPICS CA transport uses it
+for string waveform records.
-Maps to Python `bool`. Initial value is `False`.
+The constructors are overloaded per datatype, so metadata a datatype has no use
+for is a type error rather than a field silently ignored:
-:::{literalinclude} ../../src/fastcs/datatypes/bool.py
-:pyobject: Bool
-:::
-
-### String
-
-Maps to Python `str`. Has an optional `length` field that truncates values during validation. It is also used as a hint by some transports to configure the size of string records (e.g. EPICS CA string waveform records).
-
-:::{literalinclude} ../../src/fastcs/datatypes/string.py
-:pyobject: String
-:::
+```python
+AttrRW(float, precision=3) # fine
+AttrRW(str, precision=3) # type error, and raises at construction
+```
## Enum Datatype
-Wraps a Python `enum.Enum` class:
-
-:::{literalinclude} ../../src/fastcs/datatypes/enum.py
-:pyobject: Enum
-:::
-
-The `Enum` datatype provides helper properties:
-
-- `members`: List of enum values
-- `names`: List of enum member names
-- `index_of(value)`: Get the index of a value in the members list
-
-:::{note}
-FastCS uses enum **member names** (not values) when exposing choices to transports and
-PVI. This means member names are the user-friendly UI strings while values are the
-strings sent to the device:
+An `enum.Enum` subclass is used directly as the datatype; the choices come from
+the class, so there is no metadata to give:
```python
-class DetectorStatus(StrEnum):
+import enum
+from fastcs.attributes import AttrR
+
+class DetectorStatus(enum.StrEnum):
Idle = "IDLE_STATE"
Running = "RUNNING_STATE"
Error = "ERROR_STATE"
+
+status = AttrR(DetectorStatus)
```
-Clients will see the choices as `["Idle", "Running", "Error"]`.
+:::{note}
+FastCS uses enum **member names** (not values) when exposing choices to transports and
+PVI. This means member names are the user-friendly UI strings while values are the
+strings sent to the device. For the enum above, clients see the choices as
+`["Idle", "Running", "Error"]`.
For UI strings with spaces, use the functional `enum.Enum` API with a dict:
```python
import enum
-from fastcs.datatypes import Enum
-DetectorStatus = Enum(enum.Enum("DetectorStatus", {"Run Finished": "RUN_FINISHED", "In Progress": "IN_PROGRESS"}))
+DetectorStatus = enum.Enum(
+ "DetectorStatus", {"Run Finished": "RUN_FINISHED", "In Progress": "IN_PROGRESS"}
+)
```
Clients will see the choices as `["Run Finished", "In Progress"]`.
@@ -83,91 +85,92 @@ Clients will see the choices as `["Run Finished", "In Progress"]`.
## Array Datatypes
-### Waveform
+### Array1D
-For homogeneous numpy arrays (spectra, images):
+For homogeneous numpy arrays. The element type rides on the datatype itself, and
+the maximum shape is metadata:
-:::{literalinclude} ../../src/fastcs/datatypes/waveform.py
-:pyobject: Waveform
+:::{literalinclude} ../../src/fastcs/datatypes/types.py
+:start-at: "Array1D: TypeAlias"
+:end-before: "class Table"
:::
-Validation ensures the array fits within the declared shape and has the correct dtype.
+```python
+import numpy as np
+from fastcs.attributes import AttrR
+from fastcs.datatypes import Array1D
+
+spectrum = AttrR(Array1D[np.float64], shape=(1000,))
+image = AttrR(np.ndarray, array_dtype=np.uint16, shape=(1024, 1024))
+```
+
+Validation ensures the array fits within the declared shape and has the correct
+element type. `shape` defaults to `(2000,)`.
### Table
For structured numpy arrays with named columns:
-:::{literalinclude} ../../src/fastcs/datatypes/table.py
+:::{literalinclude} ../../src/fastcs/datatypes/types.py
:pyobject: Table
:::
-The `structured_dtype` field is a list of `(name, dtype)` tuples following
+The `structured_dtype` metadata is a list of `(name, dtype)` tuples following
numpy's structured array conventions.
-## Validation
+## Limits
-### Built-in Numeric Validation
+Numeric limits are nested rather than flat, in four categories aligned with the
+bluesky event-model:
-`Int` and `Float` datatypes support min/max limits and alarm thresholds:
+:::{literalinclude} ../../src/fastcs/datatypes/limits.py
+:pyobject: NumericLimits
+:::
```python
from fastcs.attributes import AttrRW
-from fastcs.datatypes import Int, Float
+from fastcs.datatypes import Limits, NumericLimits
-# Integer with bounds
-count = AttrRW(Int(min=0, max=100))
-
-# Float with units and alarm limits
-temperature = AttrRW(Float(
+temperature = AttrRW(
+ float,
units="degC",
- min=-273.15, # Absolute minimum
- max=1000.0, # Absolute maximum
- min_alarm=-50.0, # Warning below this
- max_alarm=200.0, # Warning above this
-))
+ limits=NumericLimits(
+ control=Limits(-273.15, 1000.0), # what it may be driven to
+ display=Limits(0.0, 500.0), # what it is shown as spanning
+ alarm=Limits(-50.0, 200.0), # outside this it is in alarm
+ ),
+)
```
-#### Validation Behavior
+Only the **control** range rejects values. Display, alarm and warning are served
+to clients - EPICS `LOPR`/`HOPR` and `DRVL`/`DRVH`, Tango's attribute
+properties, the PVA display and alarm structures - but do not constrain a write.
-```python
-temp = Float(min=0.0, max=100.0)
-
-temp.validate(50.0) # Returns 50.0
-temp.validate(-10.0) # Raises ValueError: "Value -10.0 is less than minimum 0.0"
-temp.validate(150.0) # Raises ValueError: "Value 150.0 is greater than maximum 100.0"
-```
-
-### String Length
+## Validation
-Limit the display length of strings:
+### Numeric limits
```python
-from fastcs.datatypes import String
+from fastcs.datatypes import Limits, Meta, NumericLimits, validate_value
-# Limit display to 40 characters
-status = AttrR(String(length=40))
-```
+meta = Meta(limits=NumericLimits(control=Limits(0.0, 100.0)))
-:::{note}
-The `length` parameter truncates values during validation and is also used by some
-transports to configure their records, for example the EPICS CA transport uses it to
-set the length of string waveform records.
-:::
+validate_value(float, meta, 50.0) # Returns 50.0
+validate_value(float, meta, -10.0) # Raises ValueError: "Value -10.0 is less than minimum 0.0"
+validate_value(float, meta, 150.0) # Raises ValueError: "Value 150.0 is greater than maximum 100.0"
+```
### Type Coercion
-All datatypes automatically coerce compatible types:
+Values are coerced to the datatype:
```python
-from fastcs.datatypes import Int, Float
+from fastcs.datatypes import Meta, validate_value
-int_type = Int()
-int_type.validate("42") # Returns 42 (str -> int)
-int_type.validate(3.7) # Returns 3 (float -> int, truncated)
-
-float_type = Float()
-float_type.validate("3.14") # Returns 3.14 (str -> float)
-float_type.validate(42) # Returns 42.0 (int -> float)
+validate_value(int, Meta(), "42") # Returns 42 (str -> int)
+validate_value(int, Meta(), 3.7) # Returns 3 (float -> int, truncated)
+validate_value(float, Meta(), "3.14") # Returns 3.14 (str -> float)
+validate_value(float, Meta(), 42) # Returns 42.0 (int -> float)
```
### When Validation Runs
@@ -180,9 +183,9 @@ Validation runs automatically when:
```python
from fastcs.attributes import AttrRW
-from fastcs.datatypes import Int
+from fastcs.datatypes import Limits, NumericLimits
-attr = AttrRW(Int(min=0, max=10), initial_value=5)
+attr = AttrRW(int, limits=NumericLimits(control=Limits(0, 10)), initial_value=5)
# Updates are validated
await attr.update(7) # OK
@@ -193,68 +196,42 @@ await attr.set(3) # OK
await attr.set(-1) # Raises ValueError
```
-## Transport Handling
-
-Transports are responsible for serializing datatypes appropriately for their protocol.
-Each transport must handle all supported datatypes. The datatype's `dtype` property
-and class type are used to determine serialization:
-
-- Scalars (`Int`, `Float`, `Bool`, `String`) serialize directly
-- `Enum` values are typically serialized as integers (index) or strings (name)
-- `Waveform` and `Table` arrays are serialized as lists or protocol-specific array types
+Metadata itself is validated when the attribute is built, so a field that the
+datatype has no use for fails fast even when it arrived without a static check -
+from a declarative extras object, say:
-## Creating Custom Datatypes
-
-All datatypes inherit from `DataType[DType_T]`, a generic frozen dataclass that defines
-the interface for type handling:
-
-:::{literalinclude} ../../src/fastcs/datatypes/datatype.py
-:start-at: "@dataclass(frozen=True)"
-:end-at: "raise NotImplementedError()"
-:::
-
-### Required Properties
-
-To create a custom datatype, subclass `DataType` or one of the existing datatypes and
-implement the required properties:
-
-**`dtype`**: Returns the underlying Python type. This is used for type coercion in
-`validate()` and for transport serialization.
-
-**`initial_value`**: Returns the default value used when an attribute is created
-without an explicit initial value.
-
-### Overriding `validate()`
-
-The base `validate()` implementation attempts to cast incoming values to the target type:
-
-:::{literalinclude} ../../src/fastcs/datatypes/datatype.py
-:pyobject: DataType.validate
-:::
+```python
+AttrR(str, precision=3)
+# TypeError: 'precision' is not valid metadata for str attribute - valid fields
+# are description, group, length
+```
-Subclasses can override this to add validation logic. The pattern is
+## Transport Handling
-1. Coerce input to help type casting succeed - e.g. `Waveform` calls `numpy.asarray(...)`
-2. Call `super().validate(value)` to call parent implementation and perform the type cast
-3. Perform any additional validation such as checking limits - e.g. `_Numeric` adds min/max validation:
+Transports are responsible for serializing values appropriately for their
+protocol, and each must handle every supported datatype. They dispatch on
+`attr.dtype` and read what they serve from `attr.meta`:
-:::{literalinclude} ../../src/fastcs/datatypes/_numeric.py
-:pyobject: _Numeric.validate
-:::
+- Scalars (`int`, `float`, `bool`, `str`) serialize directly
+- Enum values are typically serialized as integers (index) or strings (name)
+- Arrays and tables are serialized as lists or protocol-specific array types
-### Overriding `equal()`
+An array and a table are both held as `np.ndarray`; what separates them is that a
+table's metadata names its columns, so a transport that needs to tell them apart
+checks for `structured_dtype` in `attr.meta`.
-The `equal()` method is used by the `always` flag in attribute callbacks to determine
-if a value has changed. The default uses Python's `==` operator, but array types
-override this to use `numpy.array_equal()`:
+## Adding a Datatype
-:::{literalinclude} ../../src/fastcs/datatypes/waveform.py
-:pyobject: Waveform.equal
-:::
+A datatype is a python type in `DType`, so adding one means widening that union
+and teaching the pieces that dispatch on it:
-### Transport Compatibility
+1. Add the type to `DType` in `fastcs.datatypes.types`, and to `resolve_datatype`
+2. Add a `*Meta` typed dict for the metadata it accepts, and map the datatype to
+ it in `meta_class_for`
+3. Handle it in `validate_value`, `default_value` and `values_equal`
+4. Add an overload to each of `AttrR`, `AttrW` and `AttrRW` so its metadata is
+ statically checked
+5. Handle it in each transport
-When creating a new datatype, existing transports will need to be updated to handle it,
-unless the datatype inherits from a supported type. In the latter case, the transport
-will use the parent class handling, while the custom datatype can add validation or
-other behaviour on top.
+Metadata alone needs much less: a new field on an existing `*Meta` is picked up
+by `validate_meta` automatically, and only the transports that serve it change.
diff --git a/docs/explanations/transports.md b/docs/explanations/transports.md
index 5a99c9ff5..8ac32b038 100644
--- a/docs/explanations/transports.md
+++ b/docs/explanations/transports.md
@@ -100,7 +100,7 @@ layer.
|----------|-----------------|--------------|-----------|---------|
| Readback | `add_readback_callback()` | `attr.update(value)` | Publish ↑ | Update protocol representation when the attribute's readback changes |
| Setpoint | `add_setpoint_callback()` | `attr.set(value)` | Publish ↑ | Update protocol representation when the attribute's setpoint changes |
-| Update Datatype | `add_update_datatype_callback()` | `datatype` property changes | Publish ↑ | Update protocol metadata when datatype changes |
+| Update Metadata | `add_update_meta_callback()` | `meta` property changes | Publish ↑ | Update protocol metadata when it changes |
| Set | `attr.set(value)` | Transport receives user input | Set ↓ | Forward write requests from protocol to attribute |
### Readback Callbacks
@@ -122,9 +122,11 @@ The callback receives the new value and should update the protocol-specific
representation (e.g., posting to a PV, updating a REST endpoint cache, publishing the
change to a subscriber).
-### Update Datatype Callbacks
+### Update Metadata Callbacks
-Use `add_update_datatype_callback()` to update protocol metadata when an attribute's datatype changes. This is useful for protocols that expose datatype metadata (like EPICS record fields).
+Use `add_update_meta_callback()` to update protocol metadata when an attribute's
+metadata changes. This is useful for protocols that expose that metadata (like EPICS
+record fields).
```python
def create_read(name, attribute):
@@ -132,14 +134,18 @@ def create_read(name, attribute):
attribute.add_readback_callback(update_protocol_value)
- def update_protocol_metadata(datatype: DataType):
- protocol_read.set_units(datatype.units)
- protocol_read.set_limits(datatype.min, datatype.max)
+ def update_protocol_metadata(meta: Meta):
+ protocol_read.set_units(meta.get("units"))
+ limits = meta.get("limits")
+ if limits is not None:
+ protocol_read.set_limits(limits.control.low, limits.control.high)
- attribute.add_update_datatype_callback(update_protocol_metadata)
+ attribute.add_update_meta_callback(update_protocol_metadata)
```
-The callback receives the new `DataType` instance and should update the protocol's metadata representation (e.g., EPICS record fields like `EGU`, `HOPR`, `LOPR`).
+The callback receives the new `Meta` and should update the protocol's metadata
+representation (e.g., EPICS record fields like `EGU`, `HOPR`, `LOPR`). Every field is
+optional, so read them with `.get()`.
### Setpoint Callbacks
diff --git a/docs/how-to/arrange-epics-screens.md b/docs/how-to/arrange-epics-screens.md
index d7f1303f2..10b9c1649 100644
--- a/docs/how-to/arrange-epics-screens.md
+++ b/docs/how-to/arrange-epics-screens.md
@@ -16,17 +16,16 @@ box.
```python
from fastcs.attributes import AttrR, AttrRW
from fastcs.controllers import Controller
-from fastcs.datatypes import Float, Int
from fastcs.methods import command
class PowerSupplyController(Controller):
- voltage = AttrRW(Float(), group="Output")
- current = AttrRW(Float(), group="Output")
- power = AttrR(Float(), group="Output")
+ voltage = AttrRW(float, group="Output")
+ current = AttrRW(float, group="Output")
+ power = AttrR(float, group="Output")
- temperature = AttrR(Float(), group="Status")
- fault_code = AttrR(Int(), group="Status")
+ temperature = AttrR(float, group="Status")
+ fault_code = AttrR(int, group="Status")
@command(group="Actions")
async def reset_faults(self) -> None:
@@ -51,14 +50,13 @@ sub-screens.
```python
from fastcs.attributes import AttrR, AttrRW
from fastcs.controllers import Controller
-from fastcs.datatypes import Float, Int
from fastcs.methods import command
class ChannelController(Controller):
- voltage = AttrRW(Float(), group="Output")
- current = AttrRW(Float(), group="Output")
- temperature = AttrR(Float(), group="Status")
+ voltage = AttrRW(float, group="Output")
+ current = AttrRW(float, group="Output")
+ temperature = AttrR(float, group="Status")
@command(group="Actions")
async def enable(self) -> None:
@@ -66,7 +64,7 @@ class ChannelController(Controller):
class MultiChannelPSU(Controller):
- total_power = AttrR(Float())
+ total_power = AttrR(float)
@command()
async def disable_all(self) -> None:
diff --git a/docs/how-to/table-waveform-data.md b/docs/how-to/table-waveform-data.md
index 8a768604f..a9cb4667d 100644
--- a/docs/how-to/table-waveform-data.md
+++ b/docs/how-to/table-waveform-data.md
@@ -1,53 +1,55 @@
-# Work with Table and Waveform Data
+# Work with Table and Array Data
-This guide shows how to use `Waveform` and `Table` datatypes for array-based data.
+This guide shows how to use the `Array1D` and `Table` datatypes for array-based data.
-## Waveform - Homogeneous Arrays
+## Array1D - Homogeneous Arrays
-Use `Waveform` for numpy arrays of a single data type (spectra, time series, images).
-
-### Basic 1D Waveform
+Use `Array1D` for numpy arrays of a single element type (spectra, time series, images).
```python
import numpy as np
from fastcs.attributes import AttrR, AttrRW
from fastcs.controllers import Controller
-from fastcs.datatypes import Waveform
+from fastcs.datatypes import Array1D
class SpectrumController(Controller):
# 1D array of 1000 float64 values
- spectrum: AttrR[np.ndarray] = AttrR(Waveform(np.float64, shape=(1000,)))
+ spectrum = AttrR(Array1D[np.float64], shape=(1000,))
- # Writable waveform
- setpoints: AttrRW[np.ndarray] = AttrRW(Waveform(np.float64, shape=(100,)))
+ # Writable array
+ setpoints = AttrRW(Array1D[np.float64], shape=(100,))
```
-### 2D Waveform (Images)
+### 2D Arrays (Images)
+
+`Array1D` is, as the name says, one dimensional. An array of higher rank has no
+ophyd-async-compatible spelling, so write it as `np.ndarray` with an explicit
+`array_dtype`:
```python
class CameraController(Controller):
# 2D array for images (max 1024x1024 uint16)
- image: AttrR[np.ndarray] = AttrR(Waveform(np.uint16, shape=(1024, 1024)))
+ image = AttrR(np.ndarray, array_dtype=np.uint16, shape=(1024, 1024))
# Smaller region of interest
- roi: AttrRW[np.ndarray] = AttrRW(Waveform(np.uint16, shape=(256, 256)))
+ roi = AttrRW(np.ndarray, array_dtype=np.uint16, shape=(256, 256))
```
-### Waveform Parameters
+### Array Metadata
-| Parameter | Type | Default | Description |
+| Field | Type | Default | Description |
|-----------|------|---------|-------------|
-| `array_dtype` | `DTypeLike` | (required) | Numpy dtype (`np.float64`, `np.int32`, etc.) |
+| `array_dtype` | `DTypeLike` | from the datatype subscript | Numpy element type (`np.float64`, `np.int32`, etc.) |
| `shape` | `tuple[int, ...]` | `(2000,)` | Maximum array dimensions |
-### Updating Waveforms
+### Updating Arrays
```python
from fastcs.methods import scan
class SpectrumController(Controller):
- spectrum: AttrR[np.ndarray] = AttrR(Waveform(np.float64, shape=(1000,)))
+ spectrum = AttrR(Array1D[np.float64], shape=(1000,))
@scan(period=0.1)
async def read_spectrum(self):
@@ -60,16 +62,16 @@ class SpectrumController(Controller):
### Shape Validation
-Waveforms validate that data fits within the declared shape:
+Arrays validate that data fits within the declared shape:
```python
-wave = Waveform(np.float64, shape=(100,))
+spectrum = AttrR(Array1D[np.float64], shape=(100,))
# OK - fits within shape
-wave.validate(np.array([1.0, 2.0, 3.0]))
+spectrum.validate(np.array([1.0, 2.0, 3.0]))
# Error - exceeds maximum shape
-wave.validate(np.arange(200)) # ValueError: shape (200,) exceeds maximum (100,)
+spectrum.validate(np.arange(200)) # ValueError: shape (200,) exceeds maximum (100,)
```
## Table - Structured Arrays
@@ -87,16 +89,19 @@ from fastcs.datatypes import Table
class MeasurementController(Controller):
# Table with columns: name (string), value (float), valid (bool)
- results: AttrR[np.ndarray] = AttrR(Table([
- ("name", "S32"), # 32-character string
- ("value", np.float64),
- ("valid", np.bool_),
- ]))
+ results = AttrR(
+ Table,
+ structured_dtype=[
+ ("name", "S32"), # 32-character string
+ ("value", np.float64),
+ ("valid", np.bool_),
+ ],
+ )
```
-### Table Parameters
+### Table Metadata
-| Parameter | Type | Description |
+| Field | Type | Description |
|-----------|------|-------------|
| `structured_dtype` | `list[tuple[str, DTypeLike]]` | List of (name, dtype) tuples |
@@ -108,11 +113,14 @@ from fastcs.controllers import Controller
from fastcs.datatypes import Table
class ChannelController(Controller):
- channel_data: AttrR[np.ndarray] = AttrR(Table([
- ("channel", np.int32),
- ("temperature", np.float64),
- ("status", "S10"),
- ]))
+ channel_data = AttrR(
+ Table,
+ structured_dtype=[
+ ("channel", np.int32),
+ ("temperature", np.float64),
+ ("status", "S10"),
+ ],
+ )
# Create data using numpy structured array
data = np.array([
diff --git a/docs/how-to/update-attributes-from-device.md b/docs/how-to/update-attributes-from-device.md
index f76fbc04e..b5b710965 100644
--- a/docs/how-to/update-attributes-from-device.md
+++ b/docs/how-to/update-attributes-from-device.md
@@ -15,7 +15,6 @@ and calls any update callbacks; there's no need to call `attr.update` yourself:
```python
from fastcs.attributes import AttrR, AttrRW, NotPolled, Polled
from fastcs.controllers import Controller
-from fastcs.datatypes import Float, String
class MyController(Controller):
@@ -24,14 +23,14 @@ class MyController(Controller):
super().__init__()
self.temperature = AttrR(
- Float(), getter=Polled(self._get_temperature, period=0.5)
+ float, getter=Polled(self._get_temperature, period=0.5)
)
self.setpoint = AttrRW(
- Float(),
+ float,
getter=Polled(self._get_setpoint, period=1.0),
setter=self._set_setpoint,
)
- self.label = AttrR(String(), getter=NotPolled(self._get_label))
+ self.label = AttrR(str, getter=NotPolled(self._get_label))
async def _get_temperature(self) -> float:
response = await self._connection.send_query("T?\r\n")
@@ -78,7 +77,6 @@ sibling attributes whose values have changed:
```python
from fastcs.attributes import AttrR, AttrRW
from fastcs.controllers import Controller
-from fastcs.datatypes import Float
class MyController(Controller):
@@ -87,11 +85,11 @@ class MyController(Controller):
super().__init__()
self.setpoint = AttrRW(
- Float(), getter=self._get_setpoint, setter=self._set_setpoint
+ float, getter=self._get_setpoint, setter=self._set_setpoint
)
- self.actual_temperature = AttrR(Float(), getter=self._get_actual_temperature)
- self.power = AttrR(Float(), getter=self._get_power)
- self.status = AttrR(Float(), getter=self._get_status)
+ self.actual_temperature = AttrR(float, getter=self._get_actual_temperature)
+ self.power = AttrR(float, getter=self._get_power)
+ self.status = AttrR(float, getter=self._get_status)
async def _get_setpoint(self) -> float:
return float((await self._connection.send_query("S?\r\n")).strip())
@@ -134,12 +132,11 @@ import json
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import Float
from fastcs.methods import scan
class ChannelController(Controller):
- voltage = AttrR(Float()) # No getter — updated by parent scan method
+ voltage = AttrR(float) # No getter — updated by parent scan method
def __init__(self, index: int, connection):
super().__init__(f"Ch{index:02d}")
@@ -183,7 +180,6 @@ import json
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import Float
from fastcs.methods import scan
@@ -193,7 +189,7 @@ class ChannelController(Controller):
self._cache = cache
super().__init__(f"Ch{index:02d}")
- self.voltage = AttrR(Float(), getter=Polled(self._get_voltage, period=0.1))
+ self.voltage = AttrR(float, getter=Polled(self._get_voltage, period=0.1))
async def _get_voltage(self) -> float:
return self._cache.get(self._index, 0.0)
@@ -231,11 +227,10 @@ import asyncio
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import Float
class SubscriptionController(Controller):
- temperature = AttrR(Float())
+ temperature = AttrR(float)
def __init__(self, subscription_client):
super().__init__()
diff --git a/docs/how-to/wait-methods.md b/docs/how-to/wait-methods.md
index e9ef09b94..ee59bc67b 100644
--- a/docs/how-to/wait-methods.md
+++ b/docs/how-to/wait-methods.md
@@ -10,12 +10,11 @@ Use `wait_for_value()` to pause execution until an attribute reaches an exact va
```python
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import Int
from fastcs.methods import command
class MotorController(Controller):
- position: AttrR[int] = AttrR(Int())
- target: AttrR[int] = AttrR(Int())
+ position: AttrR[int] = AttrR(int)
+ target: AttrR[int] = AttrR(int)
@command()
async def move_and_wait(self):
@@ -37,11 +36,10 @@ takes the attribute value and returns `True` when the condition is satisfied:
```python
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import Float
from fastcs.methods import command
class TemperatureController(Controller):
- temperature: AttrR[float] = AttrR(Float())
+ temperature: AttrR[float] = AttrR(float)
@command()
async def wait_for_stable(self):
@@ -89,13 +87,12 @@ import asyncio
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import Float
from fastcs.methods import command
class MultiAxisController(Controller):
- x_position = AttrR(Float())
- y_position = AttrR(Float())
- z_position = AttrR(Float())
+ x_position = AttrR(float)
+ y_position = AttrR(float)
+ z_position = AttrR(float)
@command()
async def move_all_and_wait(self):
diff --git a/docs/snippets/dynamic.py b/docs/snippets/dynamic.py
index 9a5cc4f55..4e7c00e72 100644
--- a/docs/snippets/dynamic.py
+++ b/docs/snippets/dynamic.py
@@ -6,7 +6,7 @@
from fastcs.attributes import Attribute, AttrR, AttrRW
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import Bool, DataType, Float, Int, String
+from fastcs.datatypes import DType
from fastcs.launch import FastCS
from fastcs.transports.epics.ca import EpicsCATransport
@@ -35,16 +35,16 @@ class TemperatureControllerParameter(BaseModel):
access_mode: Literal["r", "rw"]
@property
- def fastcs_datatype(self) -> DataType:
+ def fastcs_datatype(self) -> type[DType]:
match self.type:
case "bool":
- return Bool()
+ return bool
case "int":
- return Int()
+ return int
case "float":
- return Float()
+ return float
case "str":
- return String()
+ return str
def create_attributes(
@@ -63,7 +63,7 @@ def create_attributes(
datatype = parameter.fastcs_datatype
command = parameter.command
- async def getter(command=command, dtype=datatype.dtype):
+ async def getter(command=command, dtype=datatype):
return await protocol.send_query(command, dtype)
match parameter.access_mode:
@@ -71,7 +71,7 @@ async def getter(command=command, dtype=datatype.dtype):
attributes[name] = AttrR(datatype, getter=getter)
case "rw":
- async def setter(value, command=command, dtype=datatype.dtype):
+ async def setter(value, command=command, dtype=datatype):
await protocol.send_command(command, value, dtype)
attributes[name] = AttrRW(datatype, getter=getter, setter=setter)
diff --git a/docs/snippets/static03.py b/docs/snippets/static03.py
index ca73b357c..82b2ee5fb 100644
--- a/docs/snippets/static03.py
+++ b/docs/snippets/static03.py
@@ -1,11 +1,10 @@
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import String
from fastcs.launch import FastCS
class TemperatureController(Controller):
- device_id = AttrR(String())
+ device_id = AttrR(str)
fastcs = FastCS(TemperatureController(), [])
diff --git a/docs/snippets/static04.py b/docs/snippets/static04.py
index 345794ea9..c52801fab 100644
--- a/docs/snippets/static04.py
+++ b/docs/snippets/static04.py
@@ -1,12 +1,11 @@
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import String
from fastcs.launch import FastCS
from fastcs.transports.epics.ca.transport import EpicsCATransport
class TemperatureController(Controller):
- device_id = AttrR(String())
+ device_id = AttrR(str)
epics_ca = EpicsCATransport()
diff --git a/docs/snippets/static05.py b/docs/snippets/static05.py
index 0d3b610af..2851dc6d0 100644
--- a/docs/snippets/static05.py
+++ b/docs/snippets/static05.py
@@ -2,14 +2,13 @@
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import String
from fastcs.launch import FastCS
from fastcs.transports.epics import EpicsGUIOptions
from fastcs.transports.epics.ca import EpicsCATransport
class TemperatureController(Controller):
- device_id = AttrR(String())
+ device_id = AttrR(str)
gui_options = EpicsGUIOptions(output_dir=Path("."), title="Demo Temperature Controller")
diff --git a/docs/snippets/static06.py b/docs/snippets/static06.py
index 269e3309e..f7bd33d15 100644
--- a/docs/snippets/static06.py
+++ b/docs/snippets/static06.py
@@ -3,14 +3,13 @@
from fastcs.attributes import AttrR
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import String
from fastcs.launch import FastCS
from fastcs.transports.epics import EpicsGUIOptions
from fastcs.transports.epics.ca import EpicsCATransport
class TemperatureController(Controller):
- device_id = AttrR(String())
+ device_id = AttrR(str)
def __init__(self, settings: IPConnectionSettings):
super().__init__()
diff --git a/docs/snippets/static07.py b/docs/snippets/static07.py
index 3bd0e04f3..2aea3bb76 100644
--- a/docs/snippets/static07.py
+++ b/docs/snippets/static07.py
@@ -3,7 +3,6 @@
from fastcs.attributes import AttrR, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import String
from fastcs.launch import FastCS
from fastcs.transports.epics import EpicsGUIOptions
from fastcs.transports.epics.ca import EpicsCATransport
@@ -16,7 +15,7 @@ def __init__(self, settings: IPConnectionSettings):
super().__init__()
- self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2))
+ self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2))
async def _get_device_id(self) -> str:
response = await self._connection.send_query("ID?\r\n")
diff --git a/docs/snippets/static08.py b/docs/snippets/static08.py
index f2483679e..95363380b 100644
--- a/docs/snippets/static08.py
+++ b/docs/snippets/static08.py
@@ -4,7 +4,6 @@
from fastcs.attributes import AttrR, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import Float, String
from fastcs.launch import FastCS
from fastcs.transports.epics import EpicsGUIOptions
from fastcs.transports.epics.ca import EpicsCATransport
@@ -35,8 +34,8 @@ def __init__(self, settings: IPConnectionSettings):
super().__init__()
- self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2))
- self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2))
+ self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2))
+ self.power = AttrR(float, getter=Polled(self._get_power, period=0.2))
async def _get_device_id(self) -> str:
return await self._protocol.send_query("ID", str)
diff --git a/docs/snippets/static09.py b/docs/snippets/static09.py
index 1a75ea678..10c07b334 100644
--- a/docs/snippets/static09.py
+++ b/docs/snippets/static09.py
@@ -4,7 +4,6 @@
from fastcs.attributes import AttrR, AttrRW, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import Float, String
from fastcs.launch import FastCS
from fastcs.transports.epics import EpicsGUIOptions
from fastcs.transports.epics.ca import EpicsCATransport
@@ -35,10 +34,10 @@ def __init__(self, settings: IPConnectionSettings):
super().__init__()
- self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2))
- self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2))
+ self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2))
+ self.power = AttrR(float, getter=Polled(self._get_power, period=0.2))
self.ramp_rate = AttrRW(
- Float(),
+ float,
getter=Polled(self._get_ramp_rate, period=0.2),
setter=self._set_ramp_rate,
)
diff --git a/docs/snippets/static10.py b/docs/snippets/static10.py
index 3c02c402e..e6ea3292d 100644
--- a/docs/snippets/static10.py
+++ b/docs/snippets/static10.py
@@ -4,7 +4,6 @@
from fastcs.attributes import AttrR, AttrRW, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import Float, Int, String
from fastcs.launch import FastCS
from fastcs.transports.epics import EpicsGUIOptions
from fastcs.transports.epics.ca import EpicsCATransport
@@ -34,14 +33,10 @@ def __init__(self, index: int, connection: IPConnection) -> None:
super().__init__(f"Ramp{suffix}")
self.start = AttrRW(
- Int(),
- getter=Polled(self._get_start, period=0.2),
- setter=self._set_start,
+ int, getter=Polled(self._get_start, period=0.2), setter=self._set_start
)
self.end = AttrRW(
- Int(),
- getter=Polled(self._get_end, period=0.2),
- setter=self._set_end,
+ int, getter=Polled(self._get_end, period=0.2), setter=self._set_end
)
async def _get_start(self) -> int:
@@ -65,10 +60,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings):
super().__init__()
- self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2))
- self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2))
+ self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2))
+ self.power = AttrR(float, getter=Polled(self._get_power, period=0.2))
self.ramp_rate = AttrRW(
- Float(),
+ float,
getter=Polled(self._get_ramp_rate, period=0.2),
setter=self._set_ramp_rate,
)
diff --git a/docs/snippets/static11.py b/docs/snippets/static11.py
index 6dd4359f0..222d0cdda 100644
--- a/docs/snippets/static11.py
+++ b/docs/snippets/static11.py
@@ -5,7 +5,6 @@
from fastcs.attributes import AttrR, AttrRW, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import Enum, Float, Int, String
from fastcs.launch import FastCS
from fastcs.transports.epics import EpicsGUIOptions
from fastcs.transports.epics.ca import EpicsCATransport
@@ -40,17 +39,13 @@ def __init__(self, index: int, connection: IPConnection) -> None:
super().__init__(f"Ramp{suffix}")
self.start = AttrRW(
- Int(),
- getter=Polled(self._get_start, period=0.2),
- setter=self._set_start,
+ int, getter=Polled(self._get_start, period=0.2), setter=self._set_start
)
self.end = AttrRW(
- Int(),
- getter=Polled(self._get_end, period=0.2),
- setter=self._set_end,
+ int, getter=Polled(self._get_end, period=0.2), setter=self._set_end
)
self.enabled = AttrRW(
- Enum(OnOffEnum),
+ OnOffEnum,
getter=Polled(self._get_enabled, period=0.2),
setter=self._set_enabled,
)
@@ -82,10 +77,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings):
super().__init__()
- self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2))
- self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2))
+ self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2))
+ self.power = AttrR(float, getter=Polled(self._get_power, period=0.2))
self.ramp_rate = AttrRW(
- Float(),
+ float,
getter=Polled(self._get_ramp_rate, period=0.2),
setter=self._set_ramp_rate,
)
diff --git a/docs/snippets/static12.py b/docs/snippets/static12.py
index 818f0337c..db7ced42c 100644
--- a/docs/snippets/static12.py
+++ b/docs/snippets/static12.py
@@ -6,7 +6,6 @@
from fastcs.attributes import AttrR, AttrRW, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import Enum, Float, Int, String
from fastcs.launch import FastCS
from fastcs.methods import scan
from fastcs.transports.epics import EpicsGUIOptions
@@ -42,23 +41,19 @@ def __init__(self, index: int, connection: IPConnection) -> None:
super().__init__(f"Ramp{suffix}")
self.start = AttrRW(
- Int(),
- getter=Polled(self._get_start, period=0.2),
- setter=self._set_start,
+ int, getter=Polled(self._get_start, period=0.2), setter=self._set_start
)
self.end = AttrRW(
- Int(),
- getter=Polled(self._get_end, period=0.2),
- setter=self._set_end,
+ int, getter=Polled(self._get_end, period=0.2), setter=self._set_end
)
self.enabled = AttrRW(
- Enum(OnOffEnum),
+ OnOffEnum,
getter=Polled(self._get_enabled, period=0.2),
setter=self._set_enabled,
)
- self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2))
- self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2))
- self.voltage = AttrR(Float())
+ self.target = AttrR(float, getter=Polled(self._get_target, period=0.2))
+ self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2))
+ self.voltage = AttrR(float)
async def _get_start(self) -> int:
return await self._protocol.send_query("S", int)
@@ -93,10 +88,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings):
super().__init__()
- self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2))
- self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2))
+ self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2))
+ self.power = AttrR(float, getter=Polled(self._get_power, period=0.2))
self.ramp_rate = AttrRW(
- Float(),
+ float,
getter=Polled(self._get_ramp_rate, period=0.2),
setter=self._set_ramp_rate,
)
diff --git a/docs/snippets/static13.py b/docs/snippets/static13.py
index 9313a873a..420acd994 100644
--- a/docs/snippets/static13.py
+++ b/docs/snippets/static13.py
@@ -7,7 +7,6 @@
from fastcs.attributes import AttrR, AttrRW, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import Enum, Float, Int, String
from fastcs.launch import FastCS
from fastcs.methods import command, scan
from fastcs.transports.epics import EpicsGUIOptions
@@ -43,23 +42,19 @@ def __init__(self, index: int, connection: IPConnection) -> None:
super().__init__(f"Ramp{suffix}")
self.start = AttrRW(
- Int(),
- getter=Polled(self._get_start, period=0.2),
- setter=self._set_start,
+ int, getter=Polled(self._get_start, period=0.2), setter=self._set_start
)
self.end = AttrRW(
- Int(),
- getter=Polled(self._get_end, period=0.2),
- setter=self._set_end,
+ int, getter=Polled(self._get_end, period=0.2), setter=self._set_end
)
self.enabled = AttrRW(
- Enum(OnOffEnum),
+ OnOffEnum,
getter=Polled(self._get_enabled, period=0.2),
setter=self._set_enabled,
)
- self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2))
- self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2))
- self.voltage = AttrR(Float())
+ self.target = AttrR(float, getter=Polled(self._get_target, period=0.2))
+ self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2))
+ self.voltage = AttrR(float)
async def _get_start(self) -> int:
return await self._protocol.send_query("S", int)
@@ -94,10 +89,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings):
super().__init__()
- self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2))
- self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2))
+ self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2))
+ self.power = AttrR(float, getter=Polled(self._get_power, period=0.2))
self.ramp_rate = AttrRW(
- Float(),
+ float,
getter=Polled(self._get_ramp_rate, period=0.2),
setter=self._set_ramp_rate,
)
diff --git a/docs/snippets/static14.py b/docs/snippets/static14.py
index 8af326fbd..9e25a6418 100644
--- a/docs/snippets/static14.py
+++ b/docs/snippets/static14.py
@@ -7,7 +7,6 @@
from fastcs.attributes import AttrR, AttrRW, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import Enum, Float, Int, String
from fastcs.launch import FastCS
from fastcs.logging import configure_logging, logger
from fastcs.methods import command, scan
@@ -47,23 +46,19 @@ def __init__(self, index: int, connection: IPConnection) -> None:
super().__init__(f"Ramp{suffix}")
self.start = AttrRW(
- Int(),
- getter=Polled(self._get_start, period=0.2),
- setter=self._set_start,
+ int, getter=Polled(self._get_start, period=0.2), setter=self._set_start
)
self.end = AttrRW(
- Int(),
- getter=Polled(self._get_end, period=0.2),
- setter=self._set_end,
+ int, getter=Polled(self._get_end, period=0.2), setter=self._set_end
)
self.enabled = AttrRW(
- Enum(OnOffEnum),
+ OnOffEnum,
getter=Polled(self._get_enabled, period=0.2),
setter=self._set_enabled,
)
- self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2))
- self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2))
- self.voltage = AttrR(Float())
+ self.target = AttrR(float, getter=Polled(self._get_target, period=0.2))
+ self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2))
+ self.voltage = AttrR(float)
async def _get_start(self) -> int:
return await self._protocol.send_query("S", int)
@@ -98,10 +93,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings):
super().__init__()
- self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2))
- self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2))
+ self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2))
+ self.power = AttrR(float, getter=Polled(self._get_power, period=0.2))
self.ramp_rate = AttrRW(
- Float(),
+ float,
getter=Polled(self._get_ramp_rate, period=0.2),
setter=self._set_ramp_rate,
)
diff --git a/docs/snippets/static15.py b/docs/snippets/static15.py
index 35a244115..ac1a1d0d9 100644
--- a/docs/snippets/static15.py
+++ b/docs/snippets/static15.py
@@ -7,7 +7,6 @@
from fastcs.attributes import AttrR, AttrRW, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller
-from fastcs.datatypes import Enum, Float, Int, String
from fastcs.launch import FastCS
from fastcs.logging import LogLevel, configure_logging, logger
from fastcs.methods import command, scan
@@ -55,23 +54,19 @@ def __init__(self, index: int, connection: IPConnection) -> None:
super().__init__(f"Ramp{suffix}")
self.start = AttrRW(
- Int(),
- getter=Polled(self._get_start, period=0.2),
- setter=self._set_start,
+ int, getter=Polled(self._get_start, period=0.2), setter=self._set_start
)
self.end = AttrRW(
- Int(),
- getter=Polled(self._get_end, period=0.2),
- setter=self._set_end,
+ int, getter=Polled(self._get_end, period=0.2), setter=self._set_end
)
self.enabled = AttrRW(
- Enum(OnOffEnum),
+ OnOffEnum,
getter=Polled(self._get_enabled, period=0.2),
setter=self._set_enabled,
)
- self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2))
- self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2))
- self.voltage = AttrR(Float())
+ self.target = AttrR(float, getter=Polled(self._get_target, period=0.2))
+ self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2))
+ self.voltage = AttrR(float)
async def _get_start(self) -> int:
return await self._protocol.send_query("S", int, topic=self.start)
@@ -106,10 +101,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings):
super().__init__()
- self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2))
- self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2))
+ self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2))
+ self.power = AttrR(float, getter=Polled(self._get_power, period=0.2))
self.ramp_rate = AttrRW(
- Float(),
+ float,
getter=Polled(self._get_ramp_rate, period=0.2),
setter=self._set_ramp_rate,
)
diff --git a/docs/tutorials/static-drivers.md b/docs/tutorials/static-drivers.md
index aa447c13f..e90248e9f 100644
--- a/docs/tutorials/static-drivers.md
+++ b/docs/tutorials/static-drivers.md
@@ -80,7 +80,7 @@ doesn't have a connection.
:::
In [1]: controller.device_id
-Out[1]: AttrR(String())
+Out[1]: AttrR(name=device_id, dtype=str)
In [2]: controller.device_id.readback
diff --git a/opis/240798e6-9fad-48f2-8acf-a5f92d820b11.bob b/opis/240798e6-9fad-48f2-8acf-a5f92d820b11.bob
new file mode 100644
index 000000000..c28b83f76
--- /dev/null
+++ b/opis/240798e6-9fad-48f2-8acf-a5f92d820b11.bob
@@ -0,0 +1,142 @@
+
+ Demo Vector
+ 0
+ 0
+ 388
+ 130
+ 4
+ 4
+
+ Title
+ TITLE
+ Demo Vector
+ 0
+ 0
+ 388
+ 25
+
+
+
+
+
+
+
+
+ true
+ 1
+
+
+ Label
+ Child Vector
+ 23
+ 30
+ 150
+ 20
+ No description provided
+
+
+ OpenDisplay
+
+
+ 240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector.bob
+ tab
+ Open Display
+
+
+ Child Vector ⧉
+ 178
+ 30
+ 205
+ 20
+ $(actions)
+
+
+ Label
+ A
+ 23
+ 55
+ 150
+ 20
+ No description provided
+
+
+ TextUpdate
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:A
+ 178
+ 55
+ 205
+ 20
+
+
+
+
+ 1
+ 0
+
+
+ Label
+ B
+ 23
+ 80
+ 150
+ 20
+ No description provided
+
+
+ TextEntry
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:B
+ 178
+ 80
+ 100
+ 20
+ 1
+ 0
+
+
+ TextUpdate
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:B_RBV
+ 283
+ 80
+ 100
+ 20
+
+
+
+
+ 1
+ 0
+
+
+ Label
+ Clamped
+ 23
+ 105
+ 150
+ 20
+ No description provided
+
+
+ TextEntry
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:Clamped
+ 178
+ 105
+ 100
+ 20
+ 1
+ 0
+
+
+ TextUpdate
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:Clamped_RBV
+ 283
+ 105
+ 100
+ 20
+
+
+
+
+ 1
+ 0
+
+
diff --git a/opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector.bob b/opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector.bob
new file mode 100644
index 000000000..e986a75d6
--- /dev/null
+++ b/opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector.bob
@@ -0,0 +1,78 @@
+
+ ChildVector
+ 0
+ 0
+ 388
+ 80
+ 4
+ 4
+
+ Title
+ TITLE
+ ChildVector
+ 0
+ 0
+ 388
+ 25
+
+
+
+
+
+
+
+
+ true
+ 1
+
+
+ Label
+ Child Vector 0
+ 23
+ 30
+ 150
+ 20
+ No description provided
+
+
+ OpenDisplay
+
+
+ 240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector0.bob
+ tab
+ Open Display
+
+
+ Child Vector 0 ⧉
+ 178
+ 30
+ 205
+ 20
+ $(actions)
+
+
+ Label
+ Child Vector 1
+ 23
+ 55
+ 150
+ 20
+ No description provided
+
+
+ OpenDisplay
+
+
+ 240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector1.bob
+ tab
+ Open Display
+
+
+ Child Vector 1 ⧉
+ 178
+ 55
+ 205
+ 20
+ $(actions)
+
+
diff --git a/opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector0.bob b/opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector0.bob
new file mode 100644
index 000000000..97c842fdc
--- /dev/null
+++ b/opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector0.bob
@@ -0,0 +1,73 @@
+
+ ChildVector0
+ 0
+ 0
+ 388
+ 80
+ 4
+ 4
+
+ Title
+ TITLE
+ ChildVector0
+ 0
+ 0
+ 388
+ 25
+
+
+
+
+
+
+
+
+ true
+ 1
+
+
+ Label
+ C
+ 23
+ 30
+ 150
+ 20
+ No description provided
+
+
+ TextEntry
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:ChildVector:0:C
+ 178
+ 30
+ 205
+ 20
+ 1
+ 0
+
+
+ Label
+ D
+ 23
+ 55
+ 150
+ 20
+ No description provided
+
+
+ WritePV
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:ChildVector:0:D
+
+
+ $(pv_name)
+ 1
+ $(name)
+
+
+ D
+ 178
+ 55
+ 205
+ 20
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:ChildVector:0:D = 1
+
+
diff --git a/opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector1.bob b/opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector1.bob
new file mode 100644
index 000000000..ba5b506e3
--- /dev/null
+++ b/opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector1.bob
@@ -0,0 +1,73 @@
+
+ ChildVector1
+ 0
+ 0
+ 388
+ 80
+ 4
+ 4
+
+ Title
+ TITLE
+ ChildVector1
+ 0
+ 0
+ 388
+ 25
+
+
+
+
+
+
+
+
+ true
+ 1
+
+
+ Label
+ C
+ 23
+ 30
+ 150
+ 20
+ No description provided
+
+
+ TextEntry
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:ChildVector:1:C
+ 178
+ 30
+ 205
+ 20
+ 1
+ 0
+
+
+ Label
+ D
+ 23
+ 55
+ 150
+ 20
+ No description provided
+
+
+ WritePV
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:ChildVector:1:D
+
+
+ $(pv_name)
+ 1
+ $(name)
+
+
+ D
+ 178
+ 55
+ 205
+ 20
+ 240798e6-9fad-48f2-8acf-a5f92d820b11:ChildVector:1:D = 1
+
+
diff --git a/opis/index.bob b/opis/index.bob
new file mode 100644
index 000000000..427fb8fc3
--- /dev/null
+++ b/opis/index.bob
@@ -0,0 +1,53 @@
+
+ Demo Vector
+ 0
+ 0
+ 388
+ 55
+ 4
+ 4
+
+ Title
+ TITLE
+ Demo Vector
+ 0
+ 0
+ 388
+ 25
+
+
+
+
+
+
+
+
+ true
+ 1
+
+
+ Label
+ 240798e6-9fad-48f2-8acf-a5f92d820b11
+ 23
+ 30
+ 150
+ 20
+ No description provided
+
+
+ OpenDisplay
+
+
+ 240798e6-9fad-48f2-8acf-a5f92d820b11.bob
+ tab
+ Open Display
+
+
+ SubScreen
+ 178
+ 30
+ 205
+ 20
+ $(actions)
+
+
diff --git a/src/fastcs/attributes/_infer_datatype.py b/src/fastcs/attributes/_infer_datatype.py
index a601781d6..35365c5ad 100644
--- a/src/fastcs/attributes/_infer_datatype.py
+++ b/src/fastcs/attributes/_infer_datatype.py
@@ -1,19 +1,11 @@
from __future__ import annotations
-import enum
import inspect
from collections.abc import Callable
from typing import Any, get_args, get_origin
from fastcs.attributes.update import Update
-from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String
-
-_DEFAULT_DATATYPES: dict[type, Callable[[], DataType]] = {
- int: Int,
- float: Float,
- bool: Bool,
- str: String,
-}
+from fastcs.datatypes import resolve_datatype
def _unwrap_update_annotation(annotation: Any) -> Any:
@@ -23,25 +15,32 @@ def _unwrap_update_annotation(annotation: Any) -> Any:
return annotation
-def _datatype_for_type(py_type: Any) -> DataType | None:
- if py_type in _DEFAULT_DATATYPES:
- return _DEFAULT_DATATYPES[py_type]()
- if isinstance(py_type, type) and issubclass(py_type, enum.Enum):
- return Enum(py_type)
- return None
+def _datatype_for_annotation(annotation: Any) -> Any | None:
+ """The annotation itself, if it is a datatype an attribute can hold.
+
+ The datatype *is* the python type, so inference is just a check that the
+ annotation names one FastCS supports - including subscripted spellings
+ such as ``Array1D[np.int32]``.
+ """
+ try:
+ resolve_datatype(annotation)
+ except TypeError:
+ return None
+
+ return annotation
-def infer_datatype_from_getter(getter: Callable) -> DataType | None:
- """Infer a default ``DataType`` from a getter's return type annotation."""
+def infer_datatype_from_getter(getter: Callable) -> Any | None:
+ """Infer a datatype from a getter's return type annotation."""
signature = inspect.signature(getter, eval_str=True)
annotation = signature.return_annotation
if annotation is inspect.Signature.empty:
return None
- return _datatype_for_type(_unwrap_update_annotation(annotation))
+ return _datatype_for_annotation(_unwrap_update_annotation(annotation))
-def infer_datatype_from_setter(setter: Callable) -> DataType | None:
- """Infer a default ``DataType`` from a setter's value parameter annotation."""
+def infer_datatype_from_setter(setter: Callable) -> Any | None:
+ """Infer a datatype from a setter's value parameter annotation."""
signature = inspect.signature(setter, eval_str=True)
parameters = list(signature.parameters.values())
if not parameters:
@@ -49,4 +48,4 @@ def infer_datatype_from_setter(setter: Callable) -> DataType | None:
annotation = parameters[0].annotation
if annotation is inspect.Signature.empty:
return None
- return _datatype_for_type(annotation)
+ return _datatype_for_annotation(annotation)
diff --git a/src/fastcs/attributes/attr_r.py b/src/fastcs/attributes/attr_r.py
index 3e4243d5a..d0df6567f 100644
--- a/src/fastcs/attributes/attr_r.py
+++ b/src/fastcs/attributes/attr_r.py
@@ -3,13 +3,27 @@
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import KW_ONLY, dataclass, replace
-from typing import Any, Generic
+from typing import Any, Generic, Unpack, overload
from fastcs.attributes._infer_datatype import infer_datatype_from_getter
from fastcs.attributes.attribute import Attribute, AttributeAccessMode
from fastcs.attributes.update import Update
from fastcs.attributes.util import AttrValuePredicate, PredicateEvent
-from fastcs.datatypes import DataType, DType_T
+from fastcs.datatypes import (
+ Array1DMeta,
+ Array_T,
+ BoolMeta,
+ DType_T,
+ Enum_T,
+ EnumMeta,
+ FloatMeta,
+ Inferred_T,
+ IntMeta,
+ Meta,
+ StrMeta,
+ Table,
+ TableMeta,
+)
from fastcs.logging import logger
from fastcs.util import ONCE
@@ -63,12 +77,95 @@ def __call__(self, getter: Getter[DType_T]) -> NotPolled[DType_T]:
class AttrR(Attribute[DType_T]):
"""A read-only ``Attribute``"""
+ # One overload per datatype, so that metadata a datatype has no use for is
+ # a type error rather than a field silently ignored: ``AttrR(str,
+ # precision=3)`` does not type check. The last overload is the
+ # inferred-datatype case, where the datatype is only known from the
+ # getter/setter annotation, so the metadata is checked at runtime.
+ #
+ # Overload resolution takes the first datatype a call matches, and ``bool``
+ # matches ``int`` while ``int`` matches ``float``. So ``AttrR(bool,
+ # units=...)`` resolves to the ``int`` overload rather than failing here -
+ # the constructor's runtime check is what rejects it. A call whose metadata
+ # is valid always picks its own datatype's overload.
+ @overload
+ def __init__(
+ self: AttrR[bool],
+ datatype: type[bool],
+ getter: Getter[bool] | Schedule[bool] | None = None,
+ initial_value: bool | None = None,
+ **meta: Unpack[BoolMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrR[int],
+ datatype: type[int],
+ getter: Getter[int] | Schedule[int] | None = None,
+ initial_value: int | None = None,
+ **meta: Unpack[IntMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrR[float],
+ datatype: type[float],
+ getter: Getter[float] | Schedule[float] | None = None,
+ initial_value: float | None = None,
+ **meta: Unpack[FloatMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrR[str],
+ datatype: type[str],
+ getter: Getter[str] | Schedule[str] | None = None,
+ initial_value: str | None = None,
+ **meta: Unpack[StrMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrR[Enum_T],
+ datatype: type[Enum_T],
+ getter: Getter[Enum_T] | Schedule[Enum_T] | None = None,
+ initial_value: Enum_T | None = None,
+ **meta: Unpack[EnumMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrR[Table],
+ datatype: type[Table],
+ getter: Getter[Table] | Schedule[Table] | None = None,
+ initial_value: Table | None = None,
+ **meta: Unpack[TableMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrR[Array_T],
+ datatype: type[Array_T],
+ getter: Getter[Array_T] | Schedule[Array_T] | None = None,
+ initial_value: Array_T | None = None,
+ **meta: Unpack[Array1DMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrR[Inferred_T],
+ datatype: None = None,
+ getter: Getter[Inferred_T] | Schedule[Inferred_T] | None = None,
+ initial_value: Inferred_T | None = None,
+ **meta: Unpack[Meta],
+ ) -> None: ...
+
def __init__(
self,
- datatype: DataType[DType_T] | None = None,
- getter: Getter[DType_T] | Schedule[DType_T] | None = None,
- initial_value: DType_T | None = None,
- **kwargs: Any,
+ datatype: Any = None,
+ getter: Any = None,
+ initial_value: Any = None,
+ **meta: Any,
) -> None:
match getter:
case Polled() | NotPolled():
@@ -90,12 +187,12 @@ def __init__(
# Pass the datatype on rather than validating it here: in an ``AttrRW`` the
# setter may still supply it, and ``Attribute`` makes the final check.
- super().__init__(datatype, **kwargs)
+ super().__init__(datatype, **meta)
self._value: DType_T = (
- self._datatype.initial_value if initial_value is None else initial_value
+ self.default_value() if initial_value is None else initial_value
)
- self._getter = resolved_getter
+ self._getter: Getter[DType_T] | None = resolved_getter
self._poll_period: float | None = poll_period
"""Period in seconds between calls to poll(), or ONCE, or None (on-demand)"""
self._readback_callbacks: (
@@ -148,7 +245,7 @@ async def update(self, value: DType_T | Update[DType_T]) -> None:
_previous_value = self._value
try:
- self._value = self._datatype.validate(value)
+ self._value = self.validate(value)
except ValueError:
logger.error("Failed to validate value", value=repr(value), attribute=self)
raise
@@ -163,7 +260,7 @@ async def update(self, value: DType_T | Update[DType_T]) -> None:
callbacks_to_call: list[AttrReadbackCallback[DType_T]] = [
cb
for cb, always in self._readback_callbacks
- if always or not self.datatype.equal(self._value, _previous_value)
+ if always or not self.equal(self._value, _previous_value)
]
try:
await asyncio.gather(*[cb(self._value) for cb in callbacks_to_call])
diff --git a/src/fastcs/attributes/attr_rw.py b/src/fastcs/attributes/attr_rw.py
index 4214254e2..90222c781 100644
--- a/src/fastcs/attributes/attr_rw.py
+++ b/src/fastcs/attributes/attr_rw.py
@@ -1,35 +1,142 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, Unpack, overload
from fastcs.attributes.attr_r import AttrR, Getter, Schedule
from fastcs.attributes.attr_w import AttrW, Setter
from fastcs.attributes.attribute import AttributeAccessMode
from fastcs.attributes.update import Update
-from fastcs.datatypes import DataType, DType_T
+from fastcs.datatypes import (
+ Array1DMeta,
+ Array_T,
+ BoolMeta,
+ DType_T,
+ Enum_T,
+ EnumMeta,
+ FloatMeta,
+ Inferred_T,
+ IntMeta,
+ Meta,
+ StrMeta,
+ Table,
+ TableMeta,
+)
from fastcs.logging import logger
class AttrRW(AttrR[DType_T], AttrW[DType_T]):
"""A read-write ``Attribute``."""
+ # One overload per datatype, so that metadata a datatype has no use for is
+ # a type error rather than a field silently ignored: ``AttrRW(str,
+ # precision=3)`` does not type check. The last overload is the
+ # inferred-datatype case, where the datatype is only known from the
+ # getter/setter annotation, so the metadata is checked at runtime.
+ #
+ # Overload resolution takes the first datatype a call matches, and ``bool``
+ # matches ``int`` while ``int`` matches ``float``. So ``AttrRW(bool,
+ # units=...)`` resolves to the ``int`` overload rather than failing here -
+ # the constructor's runtime check is what rejects it. A call whose metadata
+ # is valid always picks its own datatype's overload.
+ @overload
+ def __init__(
+ self: AttrRW[bool],
+ datatype: type[bool],
+ getter: Getter[bool] | Schedule[bool] | None = None,
+ setter: Setter[bool] | None = None,
+ initial_value: bool | None = None,
+ **meta: Unpack[BoolMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrRW[int],
+ datatype: type[int],
+ getter: Getter[int] | Schedule[int] | None = None,
+ setter: Setter[int] | None = None,
+ initial_value: int | None = None,
+ **meta: Unpack[IntMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrRW[float],
+ datatype: type[float],
+ getter: Getter[float] | Schedule[float] | None = None,
+ setter: Setter[float] | None = None,
+ initial_value: float | None = None,
+ **meta: Unpack[FloatMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrRW[str],
+ datatype: type[str],
+ getter: Getter[str] | Schedule[str] | None = None,
+ setter: Setter[str] | None = None,
+ initial_value: str | None = None,
+ **meta: Unpack[StrMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrRW[Enum_T],
+ datatype: type[Enum_T],
+ getter: Getter[Enum_T] | Schedule[Enum_T] | None = None,
+ setter: Setter[Enum_T] | None = None,
+ initial_value: Enum_T | None = None,
+ **meta: Unpack[EnumMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrRW[Table],
+ datatype: type[Table],
+ getter: Getter[Table] | Schedule[Table] | None = None,
+ setter: Setter[Table] | None = None,
+ initial_value: Table | None = None,
+ **meta: Unpack[TableMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrRW[Array_T],
+ datatype: type[Array_T],
+ getter: Getter[Array_T] | Schedule[Array_T] | None = None,
+ setter: Setter[Array_T] | None = None,
+ initial_value: Array_T | None = None,
+ **meta: Unpack[Array1DMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrRW[Inferred_T],
+ datatype: None = None,
+ getter: Getter[Inferred_T] | Schedule[Inferred_T] | None = None,
+ setter: Setter[Inferred_T] | None = None,
+ initial_value: Inferred_T | None = None,
+ **meta: Unpack[Meta],
+ ) -> None: ...
+
def __init__(
self,
- datatype: DataType[DType_T] | None = None,
- getter: Getter[DType_T] | Schedule[DType_T] | None = None,
- setter: Setter[DType_T] | None = None,
- initial_value: DType_T | None = None,
- **kwargs: Any,
+ datatype: Any = None,
+ getter: Any = None,
+ setter: Any = None,
+ initial_value: Any = None,
+ **meta: Any,
):
# There is no datatype handling to do here. ``AttrR`` infers it from the
# getter and ``AttrW`` from the setter; the MRO runs both in turn, so
# whichever can resolve it does, and ``Attribute`` makes the final check.
+ # ``setter`` travels through ``AttrR`` to ``AttrW`` the same way, which
+ # the public overloads - describing what a caller may pass - do not show.
super().__init__(
datatype,
getter=getter,
- setter=setter,
+ setter=setter, # pyright: ignore[reportCallIssue]
initial_value=initial_value,
- **kwargs,
+ **meta,
)
@property
diff --git a/src/fastcs/attributes/attr_w.py b/src/fastcs/attributes/attr_w.py
index 696d66dc7..ca7e06c85 100644
--- a/src/fastcs/attributes/attr_w.py
+++ b/src/fastcs/attributes/attr_w.py
@@ -2,12 +2,26 @@
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
-from typing import Any
+from typing import Any, Unpack, overload
from fastcs.attributes._infer_datatype import infer_datatype_from_setter
from fastcs.attributes.attribute import Attribute, AttributeAccessMode
from fastcs.attributes.update import Update
-from fastcs.datatypes import DataType, DType_T
+from fastcs.datatypes import (
+ Array1DMeta,
+ Array_T,
+ BoolMeta,
+ DType_T,
+ Enum_T,
+ EnumMeta,
+ FloatMeta,
+ Inferred_T,
+ IntMeta,
+ Meta,
+ StrMeta,
+ Table,
+ TableMeta,
+)
from fastcs.logging import logger
Setter = Callable[[DType_T], Awaitable[None | DType_T | Update[DType_T]]]
@@ -19,19 +33,94 @@
class AttrW(Attribute[DType_T]):
"""A write-only ``Attribute``."""
+ # One overload per datatype, so that metadata a datatype has no use for is
+ # a type error rather than a field silently ignored: ``AttrW(str,
+ # precision=3)`` does not type check. The last overload is the
+ # inferred-datatype case, where the datatype is only known from the
+ # getter/setter annotation, so the metadata is checked at runtime.
+ #
+ # Overload resolution takes the first datatype a call matches, and ``bool``
+ # matches ``int`` while ``int`` matches ``float``. So ``AttrW(bool,
+ # units=...)`` resolves to the ``int`` overload rather than failing here -
+ # the constructor's runtime check is what rejects it. A call whose metadata
+ # is valid always picks its own datatype's overload.
+ @overload
+ def __init__(
+ self: AttrW[bool],
+ datatype: type[bool],
+ setter: Setter[bool] | None = None,
+ **meta: Unpack[BoolMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrW[int],
+ datatype: type[int],
+ setter: Setter[int] | None = None,
+ **meta: Unpack[IntMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrW[float],
+ datatype: type[float],
+ setter: Setter[float] | None = None,
+ **meta: Unpack[FloatMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrW[str],
+ datatype: type[str],
+ setter: Setter[str] | None = None,
+ **meta: Unpack[StrMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrW[Enum_T],
+ datatype: type[Enum_T],
+ setter: Setter[Enum_T] | None = None,
+ **meta: Unpack[EnumMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrW[Table],
+ datatype: type[Table],
+ setter: Setter[Table] | None = None,
+ **meta: Unpack[TableMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrW[Array_T],
+ datatype: type[Array_T],
+ setter: Setter[Array_T] | None = None,
+ **meta: Unpack[Array1DMeta],
+ ) -> None: ...
+
+ @overload
+ def __init__(
+ self: AttrW[Inferred_T],
+ datatype: None = None,
+ setter: Setter[Inferred_T] | None = None,
+ **meta: Unpack[Meta],
+ ) -> None: ...
+
def __init__(
self,
- datatype: DataType[DType_T] | None = None,
- setter: Setter[DType_T] | None = None,
- **kwargs: Any,
+ datatype: Any = None,
+ setter: Any = None,
+ **meta: Any,
) -> None:
if datatype is None and setter is not None:
datatype = infer_datatype_from_setter(setter)
- super().__init__(datatype, **kwargs)
+ super().__init__(datatype, **meta)
- self._setter = setter
- self._setpoint: DType_T = self._datatype.initial_value
+ self._setter: Setter[DType_T] | None = setter
+ self._setpoint: DType_T = self.default_value()
self._setpoint_known = False
"""Whether the setpoint reflects a real value rather than the datatype default
@@ -69,7 +158,7 @@ async def update_setpoint(self, value: DType_T) -> None:
This does no IO - it is the setpoint-side counterpart of ``AttrR.update``.
"""
- self._setpoint = self._datatype.validate(value)
+ self._setpoint = self.validate(value)
self._setpoint_known = True
if self._setpoint_callbacks:
diff --git a/src/fastcs/attributes/attribute.py b/src/fastcs/attributes/attribute.py
index f98e0ebab..3b54d8b30 100644
--- a/src/fastcs/attributes/attribute.py
+++ b/src/fastcs/attributes/attribute.py
@@ -1,8 +1,17 @@
from abc import ABC, abstractmethod
from collections.abc import Callable
-from typing import Generic, Literal
-
-from fastcs.datatypes import DataType, DType, DType_T
+from typing import Any, Generic, Literal, cast
+
+from fastcs.datatypes import (
+ DType_T,
+ Meta,
+ Table,
+ default_value,
+ resolve_datatype,
+ validate_meta,
+ validate_value,
+ values_equal,
+)
from fastcs.tracer import Tracer
AttributeAccessMode = Literal["r", "w", "rw"]
@@ -12,13 +21,17 @@ class Attribute(Generic[DType_T], Tracer, ABC):
"""Base FastCS attribute.
Instances of this class added to a ``Controller`` will be used by the FastCS class.
+
+ An attribute's datatype is a python type - ``float``, an `enum.Enum`
+ subclass, ``Array1D[np.int32]`` - and everything else that describes it
+ (precision, units, limits, shape) is metadata, held as a `Meta` typed dict
+ on ``attr.meta``.
"""
def __init__(
self,
- datatype: DataType[DType_T] | None = None,
- group: str | None = None,
- description: str | None = None,
+ datatype: Any = None,
+ **meta: Any,
) -> None:
super().__init__()
@@ -31,33 +44,39 @@ def __init__(
"getter's return annotation or the setter's value annotation"
)
- assert issubclass(datatype.dtype, DType), (
- f"Attr type must be one of {DType}, received type {datatype.dtype}"
- )
- self._datatype: DataType[DType_T] = datatype
- self._group = group
+ dtype, element_type = resolve_datatype(datatype)
+ self._meta: Meta = _resolve_meta(datatype, element_type, meta)
+ self._dtype: type[DType_T] = dtype # pyright: ignore[reportAttributeAccessIssue]
+
+ validate_meta(dtype, self._meta)
+
self.enabled = True
- self.description = description
- # A callback to use when setting the datatype to a different value, for example
- # changing the units on an int.
- self._update_datatype_callbacks: list[Callable[[DataType[DType_T]], None]] = []
+ # A callback to use when setting the metadata to a different value, for
+ # example changing the units on an int.
+ self._update_meta_callbacks: list[Callable[[Meta], None]] = []
# Path and name to be filled in by Controller it is bound to
self._name = ""
self._path = []
@property
- def datatype(self) -> DataType[DType_T]:
- return self._datatype
+ def dtype(self) -> type[DType_T]:
+ """The python type this attribute holds."""
+ return self._dtype
@property
- def dtype(self) -> type[DType_T]:
- return self._datatype.dtype
+ def meta(self) -> Meta:
+ """Everything known about this attribute beyond its python type."""
+ return self._meta
+
+ @property
+ def description(self) -> str | None:
+ return self._meta.get("description")
@property
def group(self) -> str | None:
- return self._group
+ return self._meta.get("group")
@property
def name(self) -> str:
@@ -77,19 +96,47 @@ def access_mode(self) -> AttributeAccessMode:
"""The access mode of this attribute."""
...
- def add_update_datatype_callback(
- self, callback: Callable[[DataType[DType_T]], None]
- ) -> None:
- self._update_datatype_callbacks.append(callback)
+ def validate(self, value: Any) -> DType_T:
+ """Coerce a value to this attribute's datatype and check its metadata.
- def update_datatype(self, datatype: DataType[DType_T]) -> None:
- if not isinstance(self._datatype, type(datatype)):
- raise ValueError(
- f"Attribute datatype must be of type {type(self._datatype)}"
- )
- self._datatype = datatype
- for callback in self._update_datatype_callbacks:
- callback(datatype)
+ Args:
+ value: The value to validate
+
+ Returns:
+ The validated value
+
+ Raises:
+ ValueError: If the value cannot be coerced, or breaks the metadata
+
+ """
+ return validate_value(self._dtype, self._meta, value)
+
+ def equal(self, value1: DType_T, value2: DType_T) -> bool:
+ """Whether two values of this attribute's datatype are equal."""
+ return values_equal(self._dtype, value1, value2)
+
+ def default_value(self) -> DType_T:
+ """The value this attribute holds before anything has set one."""
+ return default_value(self._dtype, self._meta)
+
+ def add_update_meta_callback(self, callback: Callable[[Meta], None]) -> None:
+ self._update_meta_callbacks.append(callback)
+
+ def update_meta(self, meta: Meta) -> None:
+ """Replace this attribute's metadata, notifying anything serving it.
+
+ Args:
+ meta: The new metadata, which must be valid for the datatype
+
+ Raises:
+ TypeError: If a field is not meaningful for the datatype
+
+ """
+ validate_meta(self._dtype, meta, self.full_name or "attribute")
+
+ self._meta = meta
+ for callback in self._update_meta_callbacks:
+ callback(meta)
def set_name(self, name: str):
if self._name:
@@ -110,6 +157,38 @@ def set_path(self, path: list[str]):
def __repr__(self):
name = self.__class__.__name__
full_name = self.full_name or None
- datatype = self._datatype.__class__.__name__
- return f"{name}(name={full_name}, datatype={datatype})"
+ return f"{name}(name={full_name}, dtype={self._dtype.__name__})"
+
+
+def _resolve_meta(
+ datatype: Any,
+ element_type: Any,
+ meta: dict[str, Any],
+) -> Meta:
+ """Fold what the datatype spelling implied into the metadata given."""
+ resolved: dict[str, Any] = {k: v for k, v in meta.items() if v is not None}
+
+ spelled_as_table = isinstance(datatype, type) and issubclass(datatype, Table)
+ if spelled_as_table and "structured_dtype" not in resolved:
+ raise TypeError(
+ "A Table attribute needs its columns - pass "
+ "structured_dtype=[('name', np.int32), ...]"
+ )
+ if not spelled_as_table and "structured_dtype" in resolved:
+ raise TypeError(
+ "structured_dtype is only valid for a Table attribute; declare the "
+ "datatype as Table to use it"
+ )
+
+ if element_type is not None:
+ # ``Array1D[np.int32]`` says the element type; an explicit array_dtype
+ # would be a second, possibly contradictory, source for it.
+ if "array_dtype" in resolved:
+ raise TypeError(
+ "The element type is already given by the datatype subscript; "
+ "drop the array_dtype argument"
+ )
+ resolved["array_dtype"] = element_type
+
+ return cast(Meta, resolved)
diff --git a/src/fastcs/controllers/base_controller.py b/src/fastcs/controllers/base_controller.py
index 725b02e53..05f207c2e 100755
--- a/src/fastcs/controllers/base_controller.py
+++ b/src/fastcs/controllers/base_controller.py
@@ -283,12 +283,12 @@ def add_attribute(self, name, attr: Attribute):
f"hinted attribute '{name}' does not match defined access mode. "
f"Expected '{hint.attr_type.__name__}' got '{type(attr).__name__}'."
)
- if hint.dtype is not None and hint.dtype != attr.datatype.dtype:
+ if hint.dtype is not None and hint.dtype != attr.dtype:
raise RuntimeError(
f"Controller '{self.__class__.__name__}' introspection of "
f"hinted attribute '{name}' does not match defined datatype. "
f"Expected '{hint.dtype.__name__}', "
- f"got '{attr.datatype.dtype.__name__}'."
+ f"got '{attr.dtype.__name__}'."
)
attr.set_name(name)
diff --git a/src/fastcs/datatypes/__init__.py b/src/fastcs/datatypes/__init__.py
index fc108c9d5..8cbe51bde 100644
--- a/src/fastcs/datatypes/__init__.py
+++ b/src/fastcs/datatypes/__init__.py
@@ -1,11 +1,28 @@
-from ._util import numpy_to_fastcs_datatype as numpy_to_fastcs_datatype
-from .bool import Bool as Bool
-from .datatype import DataType as DataType
-from .datatype import DType as DType
-from .datatype import DType_T as DType_T
-from .enum import Enum as Enum
-from .float import Float as Float
-from .int import Int as Int
-from .string import String as String
-from .table import Table as Table
-from .waveform import Waveform as Waveform
+from ._util import numpy_to_python_type as numpy_to_python_type
+from .limits import Limits as Limits
+from .limits import NumericLimits as NumericLimits
+from .meta import DEFAULT_ARRAY_SHAPE as DEFAULT_ARRAY_SHAPE
+from .meta import DEFAULT_PRECISION as DEFAULT_PRECISION
+from .meta import Array1DMeta as Array1DMeta
+from .meta import BoolMeta as BoolMeta
+from .meta import CommonMeta as CommonMeta
+from .meta import EnumMeta as EnumMeta
+from .meta import FloatMeta as FloatMeta
+from .meta import IntMeta as IntMeta
+from .meta import Meta as Meta
+from .meta import StrMeta as StrMeta
+from .meta import TableMeta as TableMeta
+from .types import Array1D as Array1D
+from .types import Array_T as Array_T
+from .types import DType as DType
+from .types import DType_T as DType_T
+from .types import Enum_T as Enum_T
+from .types import Inferred_T as Inferred_T
+from .types import Table as Table
+from .types import is_array_datatype as is_array_datatype
+from .types import resolve_datatype as resolve_datatype
+from .validation import array_dtype_of as array_dtype_of
+from .validation import default_value as default_value
+from .validation import validate_meta as validate_meta
+from .validation import validate_value as validate_value
+from .validation import values_equal as values_equal
diff --git a/src/fastcs/datatypes/_numeric.py b/src/fastcs/datatypes/_numeric.py
deleted file mode 100644
index 8b8f44da8..000000000
--- a/src/fastcs/datatypes/_numeric.py
+++ /dev/null
@@ -1,38 +0,0 @@
-from dataclasses import dataclass
-from typing import Any, TypeVar
-
-from fastcs.datatypes.datatype import DataType
-
-Numeric_T = TypeVar("Numeric_T", int, float)
-"""A numeric type supported by a corresponding FastCS Attribute DataType"""
-
-
-@dataclass(frozen=True)
-class _Numeric(DataType[Numeric_T]):
- """Base class for numeric FastCS DataType classes"""
-
- units: str | None = None
- """The units of the numeric value"""
- min: Numeric_T | None = None
- """The minimum allowed value - values below this will raise an exception"""
- max: Numeric_T | None = None
- """The maximum allowed value - values above this will raise an exception"""
- min_alarm: Numeric_T | None = None
- """The minimum alarm limit - values below this will be set with an alarm state"""
- max_alarm: Numeric_T | None = None
- """The maximum alarm limit - values above this will be set with an alarm state"""
-
- def validate(self, value: Any) -> Numeric_T:
- _value = super().validate(value)
-
- if self.min is not None and _value < self.min:
- raise ValueError(f"Value {_value} is less than minimum {self.min}")
-
- if self.max is not None and _value > self.max:
- raise ValueError(f"Value {_value} is greater than maximum {self.max}")
-
- return _value
-
- @property
- def initial_value(self) -> Numeric_T:
- return self.dtype(0)
diff --git a/src/fastcs/datatypes/_util.py b/src/fastcs/datatypes/_util.py
index b590f7ff8..bd55ac958 100644
--- a/src/fastcs/datatypes/_util.py
+++ b/src/fastcs/datatypes/_util.py
@@ -1,21 +1,18 @@
import numpy as np
-from fastcs.datatypes.bool import Bool
-from fastcs.datatypes.datatype import DataType
-from fastcs.datatypes.float import Float
-from fastcs.datatypes.int import Int
-from fastcs.datatypes.string import String
+from fastcs.datatypes.types import DType
-def numpy_to_fastcs_datatype(np_type) -> DataType:
- """Converts numpy types to fastcs types for widget creation.
- Only types important for widget creation are explicitly converted
+def numpy_to_python_type(np_type) -> type[DType]:
+ """Converts numpy types to python types for widget creation.
+
+ Only types important for widget creation are explicitly converted.
"""
if np.issubdtype(np_type, np.integer):
- return Int()
+ return int
elif np.issubdtype(np_type, np.floating):
- return Float()
+ return float
elif np.issubdtype(np_type, np.bool_):
- return Bool()
+ return bool
else:
- return String()
+ return str
diff --git a/src/fastcs/datatypes/bool.py b/src/fastcs/datatypes/bool.py
deleted file mode 100644
index 7b99ae2a9..000000000
--- a/src/fastcs/datatypes/bool.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from dataclasses import dataclass
-
-from fastcs.datatypes.datatype import DataType
-
-
-@dataclass(frozen=True)
-class Bool(DataType[bool]):
- """`DataType` mapping to builtin ``bool``."""
-
- @property
- def dtype(self) -> type[bool]:
- return bool
-
- @property
- def initial_value(self) -> bool:
- return False
diff --git a/src/fastcs/datatypes/datatype.py b/src/fastcs/datatypes/datatype.py
deleted file mode 100644
index 09953bd7d..000000000
--- a/src/fastcs/datatypes/datatype.py
+++ /dev/null
@@ -1,94 +0,0 @@
-import enum
-from abc import abstractmethod
-from collections.abc import Sequence
-from dataclasses import dataclass
-from typing import Any, Generic, TypeVar
-
-import numpy as np
-
-DType = (
- int # Int
- | float # Float
- | bool # Bool
- | str # String
- | enum.Enum # Enum
- | np.ndarray # Waveform / Table
-)
-"""A builtin (or numpy) type supported by a corresponding FastCS Attribute DataType"""
-
-DType_T = TypeVar("DType_T", bound=DType)
-"""A TypeVar of `DType` for use in generic classes and functions"""
-
-
-@dataclass(frozen=True)
-class DataType(Generic[DType_T]):
- """Generic datatype mapping to a python type, with additional metadata."""
-
- @property
- @abstractmethod
- def dtype(self) -> type[DType_T]: # Using property due to lack of Generic ClassVars
- """Underlying python type"""
- raise NotImplementedError()
-
- @property
- @abstractmethod
- def initial_value(self) -> DType_T:
- """Fallback initial value if not specified in `Attribute`"""
- raise NotImplementedError()
-
- def validate(self, value: Any) -> DType_T:
- """Validate a value against the datatype.
-
- The base implementation is to try the cast and raise a useful error if it fails.
-
- Child classes can implement logic before calling ``super.validate(value)`` to
- modify the value passed in and help the cast succeed or after to perform further
- validation of the coerced type.
-
- Args:
- value: The value to validate
-
- Returns:
- The validated value
-
- Raises:
- ValueError: If the value cannot be coerced
-
- """
- if isinstance(value, self.dtype):
- return value
-
- try:
- return self.dtype(value)
- except (ValueError, TypeError) as e:
- raise ValueError(f"Failed to cast {value} to type {self.dtype}") from e
-
- @staticmethod
- def equal(value1: DType_T, value2: DType_T) -> bool:
- """Compare two values for equality
-
- Child classes can override this if the underlying type does not implement
- ``__eq__`` or to define custom logic.
-
- Args:
- value1: The first value to compare
- value2: The second value to compare
-
- Returns:
- `True` if the values are equal
-
- """
- return value1 == value2
-
- @classmethod
- def all_equal(cls, values: Sequence[DType_T]) -> bool:
- """Compare a sequence of values for equality
-
- Args:
- values: Values to compare
-
- Returns:
- `True` if all values are equal, else `False`
-
- """
- return all(cls.equal(values[0], value) for value in values[1:])
diff --git a/src/fastcs/datatypes/enum.py b/src/fastcs/datatypes/enum.py
deleted file mode 100644
index e490f5c76..000000000
--- a/src/fastcs/datatypes/enum.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import enum
-from dataclasses import dataclass
-from functools import cached_property
-from typing import Generic, TypeVar
-
-from fastcs.datatypes.datatype import DataType
-
-Enum_T = TypeVar("Enum_T", bound=enum.Enum)
-"""A builtin Enum type"""
-
-
-@dataclass(frozen=True)
-class Enum(Generic[Enum_T], DataType[Enum_T]):
- enum_cls: type[Enum_T]
-
- def __post_init__(self):
- if not issubclass(self.enum_cls, enum.Enum):
- raise ValueError("Enum class has to take an Enum.")
-
- def index_of(self, value: Enum_T) -> int:
- return self.members.index(value)
-
- @cached_property
- def members(self) -> list[Enum_T]:
- return list(self.enum_cls)
-
- @cached_property
- def names(self) -> list[str]:
- return [member.name for member in self.members]
-
- @property
- def dtype(self) -> type[Enum_T]:
- return self.enum_cls
-
- @property
- def initial_value(self) -> Enum_T:
- return self.members[0]
diff --git a/src/fastcs/datatypes/float.py b/src/fastcs/datatypes/float.py
deleted file mode 100644
index 6e24f384f..000000000
--- a/src/fastcs/datatypes/float.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from dataclasses import dataclass
-from typing import Any
-
-from fastcs.datatypes._numeric import _Numeric
-
-
-@dataclass(frozen=True)
-class Float(_Numeric[float]):
- """`DataType` mapping to builtin ``float``."""
-
- prec: int = 2
- """Number of decimal places to represent value"""
-
- @property
- def dtype(self) -> type[float]:
- return float
-
- def validate(self, value: Any) -> float:
- _value = super().validate(value)
-
- if self.prec is not None:
- _value = round(_value, self.prec)
-
- return _value
diff --git a/src/fastcs/datatypes/int.py b/src/fastcs/datatypes/int.py
deleted file mode 100644
index 31858e97e..000000000
--- a/src/fastcs/datatypes/int.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from dataclasses import dataclass
-
-from fastcs.datatypes._numeric import _Numeric
-
-
-@dataclass(frozen=True)
-class Int(_Numeric[int]):
- """`DataType` mapping to builtin ``int``."""
-
- @property
- def dtype(self) -> type[int]:
- return int
diff --git a/src/fastcs/datatypes/limits.py b/src/fastcs/datatypes/limits.py
new file mode 100644
index 000000000..635384ad5
--- /dev/null
+++ b/src/fastcs/datatypes/limits.py
@@ -0,0 +1,83 @@
+"""Numeric limits, aligned with the bluesky event-model (ADR 0017)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Generic, TypeVar
+
+Numeric_T = TypeVar("Numeric_T", int, float)
+"""A numeric type that can carry limits"""
+
+
+@dataclass(frozen=True)
+class Limits(Generic[Numeric_T]):
+ """A pair of bounds on a numeric value.
+
+ Either end may be ``None``, meaning unbounded in that direction.
+ """
+
+ low: Numeric_T | None = None
+ """The lower bound, or ``None`` for unbounded"""
+ high: Numeric_T | None = None
+ """The upper bound, or ``None`` for unbounded"""
+
+ def contains(self, other: Limits[Numeric_T]) -> bool:
+ """Whether ``other`` lies within this range.
+
+ An unbounded end of ``self`` contains any value; an unbounded end of
+ ``other`` is only contained by an unbounded end of ``self``.
+ """
+ if self.low is not None and (other.low is None or other.low < self.low):
+ return False
+ if self.high is not None and (other.high is None or other.high > self.high):
+ return False
+
+ return True
+
+
+UNBOUNDED: Limits = Limits()
+"""Limits with neither end set"""
+
+
+@dataclass(frozen=True)
+class NumericLimits(Generic[Numeric_T]):
+ """The four categories of limit on a numeric attribute.
+
+ All four are optional and are resolved on construction, so that after
+ construction every category holds a `Limits` - unbounded if nothing
+ determined it. The rules (ADR 0017) are:
+
+ - supply none and all four are unbounded;
+ - a ``display`` range with no ``control`` range gives ``control`` the
+ display range - what a device may be driven to defaults to what it is
+ shown as spanning;
+ - an ``alarm`` range with no ``warning`` range gives ``warning`` the alarm
+ range;
+ - supplying both asserts that ``warning`` lies within ``alarm``, since a
+ warning outside the alarm range could never be the milder condition.
+
+ >>> limits = NumericLimits(display=Limits(0.0, 10.0))
+ >>> limits.control
+ Limits(low=0.0, high=10.0)
+ """
+
+ control: Limits[Numeric_T] = UNBOUNDED
+ """The range the attribute may be driven to"""
+ display: Limits[Numeric_T] = UNBOUNDED
+ """The range the attribute is displayed over"""
+ alarm: Limits[Numeric_T] = UNBOUNDED
+ """The range outside which the attribute is in alarm"""
+ warning: Limits[Numeric_T] = UNBOUNDED
+ """The range outside which the attribute is in a warning state"""
+
+ def __post_init__(self) -> None:
+ if self.control == UNBOUNDED and self.display != UNBOUNDED:
+ object.__setattr__(self, "control", self.display)
+
+ if self.warning == UNBOUNDED and self.alarm != UNBOUNDED:
+ object.__setattr__(self, "warning", self.alarm)
+ elif self.alarm != UNBOUNDED and not self.alarm.contains(self.warning):
+ raise ValueError(
+ f"Warning limits {self.warning} are not within alarm limits "
+ f"{self.alarm}"
+ )
diff --git a/src/fastcs/datatypes/meta.py b/src/fastcs/datatypes/meta.py
new file mode 100644
index 000000000..4dd8a2f7a
--- /dev/null
+++ b/src/fastcs/datatypes/meta.py
@@ -0,0 +1,115 @@
+"""Per-datatype metadata, as typed dicts (ADR 0014).
+
+Each python type an `Attribute` can hold has a ``*Meta`` typed dict saying what
+metadata is meaningful for it - ``precision`` for a ``float``, ``length`` for a
+``str``, ``shape`` for an array. The ``Attr*`` constructors unpack the right one
+for the datatype they were given, so ``AttrRW(str, precision=3)`` is a static
+type error rather than a field silently ignored at runtime.
+
+`Meta` is the superset of every field, all optional. It is what a declarative
+extras object (such as the demo's ``SCPIParam``) takes, since an
+``Annotated[...]`` extra cannot tie its metadata to the attribute's datatype
+statically - the ``ControllerFiller`` validates those at fill time instead.
+"""
+
+from __future__ import annotations
+
+from typing import Any, TypedDict
+
+from numpy.typing import DTypeLike
+
+from fastcs.datatypes.limits import NumericLimits
+
+DEFAULT_PRECISION = 2
+"""Decimal places a ``float`` attribute is rounded to when unspecified"""
+
+DEFAULT_ARRAY_SHAPE: tuple[int, ...] = (2000,)
+"""Maximum shape of an array attribute when unspecified"""
+
+
+class CommonMeta(TypedDict, total=False):
+ """Metadata meaningful for an attribute of any datatype."""
+
+ description: str
+ """Human readable description of what the attribute is"""
+ group: str
+ """Name of the group to display the attribute under"""
+
+
+class BoolMeta(CommonMeta, total=False):
+ """Metadata for a ``bool`` attribute."""
+
+
+class IntMeta(CommonMeta, total=False):
+ """Metadata for an ``int`` attribute."""
+
+ units: str
+ """The units of the value"""
+ limits: NumericLimits[int]
+ """The control, display, alarm and warning ranges of the value"""
+
+
+class FloatMeta(CommonMeta, total=False):
+ """Metadata for a ``float`` attribute."""
+
+ units: str
+ """The units of the value"""
+ limits: NumericLimits[float]
+ """The control, display, alarm and warning ranges of the value"""
+ precision: int
+ """Number of decimal places to round to and display"""
+
+
+class StrMeta(CommonMeta, total=False):
+ """Metadata for a ``str`` attribute."""
+
+ length: int
+ """Maximum length of the string. Must be >= 1"""
+
+
+class EnumMeta(CommonMeta, total=False):
+ """Metadata for an `enum.Enum` attribute.
+
+ Display only - the choices come from the enum class itself.
+ """
+
+
+class Array1DMeta(CommonMeta, total=False):
+ """Metadata for an `Array1D` attribute."""
+
+ array_dtype: DTypeLike
+ """Numpy element type, if not given by the datatype subscript"""
+ shape: tuple[int, ...]
+ """Maximum shape of the array"""
+
+
+class TableMeta(CommonMeta, total=False):
+ """Metadata for a `Table` attribute."""
+
+ structured_dtype: list[tuple[str, DTypeLike]]
+ """The columns of the table, as a numpy structured dtype"""
+
+
+class Meta(CommonMeta, total=False):
+ """Every metadata field, all optional.
+
+ The spelling for metadata that cannot be tied to a datatype statically -
+ a declarative extras object collecting whatever the protocol layer was
+ told, validated against the datatype when the attribute is built.
+
+ Spelled out rather than inheriting every ``*Meta``, because ``IntMeta`` and
+ ``FloatMeta`` disagree on the type of ``limits``.
+ """
+
+ units: str
+ limits: NumericLimits[int] | NumericLimits[float]
+ precision: int
+ length: int
+ array_dtype: DTypeLike
+ shape: tuple[int, ...]
+ structured_dtype: list[tuple[str, DTypeLike]]
+
+
+def meta_fields(meta_cls: Any) -> frozenset[str]:
+ """The field names of a ``*Meta`` typed dict, including inherited ones."""
+ return frozenset(meta_cls.__optional_keys__) | frozenset(meta_cls.__required_keys__)
diff --git a/src/fastcs/datatypes/string.py b/src/fastcs/datatypes/string.py
deleted file mode 100644
index e4deb15bb..000000000
--- a/src/fastcs/datatypes/string.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from dataclasses import dataclass
-from typing import Any
-
-from fastcs.datatypes.datatype import DataType
-
-
-@dataclass(frozen=True)
-class String(DataType[str]):
- """`DataType` mapping to builtin ``str``."""
-
- length: int | None = None
- """Maximum length of string to display in transports. Must be >=1 or None."""
-
- def __post_init__(self):
- if self.length is not None and self.length < 1:
- raise ValueError("String length must be >= 1")
-
- @property
- def dtype(self) -> type[str]:
- return str
-
- @property
- def initial_value(self) -> str:
- return ""
-
- def validate(self, value: Any) -> str:
- """Truncate string to maximum length
-
- Returns:
- The string, truncated to the maximum length if set
-
- """
- return super().validate(value)[: self.length]
diff --git a/src/fastcs/datatypes/table.py b/src/fastcs/datatypes/table.py
deleted file mode 100644
index f8f3a6d60..000000000
--- a/src/fastcs/datatypes/table.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from dataclasses import dataclass
-from typing import Any
-
-import numpy as np
-from numpy.typing import DTypeLike
-
-from fastcs.datatypes.datatype import DataType
-
-
-@dataclass(frozen=True)
-class Table(DataType[np.ndarray]):
- structured_dtype: list[tuple[str, DTypeLike]]
- """The structured dtype for numpy array
-
- See docs for more information:
- https://numpy.org/devdocs/user/basics.rec.html#structured-datatype-creation
- """
-
- @property
- def dtype(self) -> type[np.ndarray]:
- return np.ndarray
-
- @property
- def initial_value(self) -> np.ndarray:
- return np.array([], dtype=self.structured_dtype)
-
- def validate(self, value: Any) -> np.ndarray:
- _value = super().validate(value)
-
- if self.structured_dtype != _value.dtype:
- raise ValueError(
- f"Value dtype {_value.dtype.descr} is not the same as the structured "
- f"dtype {self.structured_dtype}"
- )
-
- return _value
-
- @staticmethod
- def equal(value1: np.ndarray, value2: np.ndarray) -> bool:
- return np.array_equal(value1, value2)
diff --git a/src/fastcs/datatypes/types.py b/src/fastcs/datatypes/types.py
new file mode 100644
index 000000000..a24675cc5
--- /dev/null
+++ b/src/fastcs/datatypes/types.py
@@ -0,0 +1,139 @@
+"""The python types a FastCS `Attribute` can hold, and how they are spelled.
+
+There is no ``DataType`` object: an attribute's datatype *is* a python type,
+and everything that used to hang off a ``DataType`` instance - precision,
+units, limits, array shape - now travels separately as a ``*Meta`` typed dict
+(see :py:mod:`fastcs.datatypes.meta`).
+"""
+
+from __future__ import annotations
+
+import enum
+from typing import Any, TypeAlias, TypeVar, get_args, get_origin
+
+import numpy as np
+from numpy.typing import DTypeLike
+
+DType = (
+ int # int
+ | float # float
+ | bool # bool
+ | str # str
+ | enum.Enum # any Enum subclass
+ | np.ndarray # Array1D / Table
+)
+"""A python type that a FastCS `Attribute` can hold"""
+
+DType_T = TypeVar("DType_T", bound=DType)
+"""A TypeVar of `DType` for use in generic classes and functions"""
+
+NumpyScalar_T = TypeVar("NumpyScalar_T", bound=np.generic, covariant=True)
+"""The element type of a numpy array"""
+
+Array1D: TypeAlias = np.ndarray[tuple[int], np.dtype[NumpyScalar_T]]
+"""A one dimensional numpy array, subscripted with its element type.
+
+``Array1D[np.int32]`` is both the type hint for an array attribute and the
+datatype passed to its constructor - the element type is read straight off the
+subscript, so it does not have to be repeated in the metadata::
+
+ AttrR(Array1D[np.int32], shape=(10,))
+
+Arrays of higher rank have no ophyd-async-compatible spelling; write them as
+``np.ndarray`` with an explicit ``array_dtype``::
+
+ AttrR(np.ndarray, array_dtype=np.int32, shape=(10, 10))
+"""
+
+
+class Table(np.ndarray):
+ """A structured ("record") numpy array, one field per column.
+
+ Both the type hint and the datatype for a table attribute; the columns are
+ given as the ``structured_dtype`` metadata::
+
+ AttrR(Table, structured_dtype=[("index", np.int32), ("value", np.float64)])
+
+ See https://numpy.org/devdocs/user/basics.rec.html for structured dtypes.
+ """
+
+
+_BUILTIN_DTYPES: tuple[type, ...] = (bool, int, float, str)
+"""The builtin types an attribute may hold, matched exactly rather than by
+subclass - ``bool`` is a subclass of ``int``, and the two are not
+interchangeable to a transport."""
+
+
+def is_array_datatype(dtype: type[DType]) -> bool:
+ """Whether ``dtype`` is held as a numpy array - an `Array1D` or a `Table`."""
+ return issubclass(dtype, np.ndarray)
+
+
+def resolve_datatype(datatype: Any) -> tuple[type[DType], DTypeLike | None]:
+ """Resolve a datatype as written into the python type an attribute holds.
+
+ Args:
+ datatype: A datatype spelling - a builtin type, an `enum.Enum`
+ subclass, `Table`, ``np.ndarray``, or a subscripted `Array1D`
+
+ Returns:
+ The python type, and the numpy element type carried by the spelling if
+ it had one (``Array1D[np.int32]`` carries ``np.int32``; a bare
+ ``np.ndarray`` carries nothing and needs an ``array_dtype``)
+
+ Raises:
+ TypeError: If ``datatype`` is not a supported spelling
+
+ """
+ # ``Array1D[np.int32]`` is a subscripted generic alias rather than a class.
+ if (origin := get_origin(datatype)) is not None:
+ if not (isinstance(origin, type) and issubclass(origin, np.ndarray)):
+ raise TypeError(f"Unsupported datatype {datatype!r}")
+
+ return np.ndarray, _element_type_of(datatype)
+
+ if not isinstance(datatype, type):
+ raise TypeError(
+ f"Datatype must be a type, got {datatype!r}. Metadata such as "
+ "precision or units is passed as keyword arguments, not as part "
+ "of the datatype."
+ )
+
+ if datatype in _BUILTIN_DTYPES or issubclass(datatype, enum.Enum):
+ return datatype, None
+
+ if issubclass(datatype, np.ndarray):
+ # ``Table`` and ``Array1D`` are both held as plain ``np.ndarray``; what
+ # separates them is whether the metadata gives a structured dtype.
+ return np.ndarray, None
+
+ raise TypeError(f"Unsupported datatype {datatype!r}")
+
+
+def _element_type_of(alias: Any) -> DTypeLike | None:
+ """The numpy element type of a subscripted ``np.ndarray`` alias, if given."""
+ args = get_args(alias)
+ if len(args) != 2:
+ return None
+
+ # ``np.ndarray[tuple[int], np.dtype[np.int32]]`` - the element type is the
+ # argument of the inner ``np.dtype``.
+ dtype_args = get_args(args[1])
+
+ return dtype_args[0] if dtype_args else None
+
+
+Enum_T = TypeVar("Enum_T", bound=enum.Enum)
+"""A TypeVar of any `enum.Enum` subclass an attribute can hold"""
+
+Array_T = TypeVar("Array_T", bound=np.ndarray)
+"""A TypeVar of any numpy array an attribute can hold"""
+
+
+Inferred_T = TypeVar("Inferred_T", bound=DType)
+"""A TypeVar of `DType` for the constructor overload that infers the datatype
+
+Distinct from `DType_T` because the overload binds it from the getter or setter
+in the same signature that annotates ``self``, and a class-scoped TypeVar cannot
+be used there.
+"""
diff --git a/src/fastcs/datatypes/validation.py b/src/fastcs/datatypes/validation.py
new file mode 100644
index 000000000..49cad726a
--- /dev/null
+++ b/src/fastcs/datatypes/validation.py
@@ -0,0 +1,220 @@
+"""Validating and comparing attribute values against a datatype and its metadata.
+
+This is what the ``DataType`` classes used to do in ``validate``/``equal``/
+``initial_value``; with the datatype reduced to a python type, the behaviour
+that depended on the metadata is dispatched here instead.
+"""
+
+from __future__ import annotations
+
+import enum
+from typing import Any, cast
+
+import numpy as np
+
+from fastcs.datatypes.limits import NumericLimits
+from fastcs.datatypes.meta import (
+ DEFAULT_ARRAY_SHAPE,
+ DEFAULT_PRECISION,
+ Array1DMeta,
+ BoolMeta,
+ EnumMeta,
+ FloatMeta,
+ IntMeta,
+ Meta,
+ StrMeta,
+ TableMeta,
+ meta_fields,
+)
+from fastcs.datatypes.types import DType, DType_T
+
+_META_FOR_DTYPE: dict[type, Any] = {
+ bool: BoolMeta,
+ int: IntMeta,
+ float: FloatMeta,
+ str: StrMeta,
+}
+
+
+def meta_class_for(dtype: type[DType], meta: Meta) -> Any:
+ """The ``*Meta`` typed dict that applies to ``dtype``."""
+ if dtype in _META_FOR_DTYPE:
+ return _META_FOR_DTYPE[dtype]
+ if issubclass(dtype, enum.Enum):
+ return EnumMeta
+ if issubclass(dtype, np.ndarray):
+ return TableMeta if "structured_dtype" in meta else Array1DMeta
+
+ raise TypeError(f"Unsupported datatype {dtype!r}")
+
+
+def validate_meta(dtype: type[DType], meta: Meta, name: str = "attribute") -> None:
+ """Check that every field of ``meta`` is meaningful for ``dtype``.
+
+ The runtime counterpart of the ``Unpack[*Meta]`` overloads on the
+ constructors, for metadata that arrived without a static check - from a
+ ``ControllerFiller`` extras object, say.
+
+ Args:
+ dtype: The python type the attribute holds
+ meta: The metadata to check
+ name: The attribute's name, to name it in the error
+
+ Raises:
+ TypeError: If a field is not meaningful for the datatype
+
+ """
+ allowed = meta_fields(meta_class_for(dtype, meta))
+ for field in meta:
+ if field not in allowed:
+ raise TypeError(
+ f"'{field}' is not valid metadata for {dtype.__name__} "
+ f"{name} - valid fields are {', '.join(sorted(allowed))}"
+ )
+
+ length = meta.get("length")
+ if length is not None and length < 1:
+ raise ValueError(f"String length must be >= 1, got {length} for {name}")
+
+
+def array_dtype_of(meta: Meta, element_type: Any = None) -> Any:
+ """The numpy element type of an array attribute.
+
+ Args:
+ meta: The attribute's metadata
+ element_type: The element type carried by the datatype spelling, if any
+
+ Returns:
+ The numpy element type
+
+ Raises:
+ TypeError: If neither source gives one
+
+ """
+ array_dtype = meta.get("array_dtype", element_type)
+ if array_dtype is None:
+ raise TypeError(
+ "An array attribute needs an element type - subscript the datatype "
+ "as Array1D[np.int32], or pass array_dtype=np.int32"
+ )
+
+ return array_dtype
+
+
+def default_value(dtype: type[DType_T], meta: Meta) -> DType_T:
+ """The value an attribute holds before anything has set one."""
+ if dtype is str:
+ return cast(DType_T, "")
+ if dtype is bool:
+ return cast(DType_T, False)
+ if dtype in (int, float):
+ return cast(DType_T, dtype(0))
+ if issubclass(dtype, enum.Enum):
+ return cast(DType_T, next(iter(dtype)))
+ if issubclass(dtype, np.ndarray):
+ if (structured_dtype := meta.get("structured_dtype")) is not None:
+ return cast(DType_T, np.array([], dtype=structured_dtype))
+
+ return cast(
+ DType_T,
+ np.zeros(
+ meta.get("shape", DEFAULT_ARRAY_SHAPE),
+ dtype=array_dtype_of(meta),
+ ),
+ )
+
+ raise TypeError(f"Unsupported datatype {dtype!r}")
+
+
+def values_equal(dtype: type[DType], value1: Any, value2: Any) -> bool:
+ """Whether two values of ``dtype`` are equal.
+
+ Numpy arrays need ``array_equal`` rather than ``==``, which is elementwise.
+ """
+ if issubclass(dtype, np.ndarray):
+ return bool(np.array_equal(value1, value2))
+
+ return bool(value1 == value2)
+
+
+def validate_value(dtype: type[DType_T], meta: Meta, value: Any) -> DType_T:
+ """Coerce a value to ``dtype`` and check it against ``meta``.
+
+ Args:
+ dtype: The python type the attribute holds
+ meta: The attribute's metadata
+ value: The value to validate
+
+ Returns:
+ The validated value
+
+ Raises:
+ ValueError: If the value cannot be coerced, or breaks the metadata
+
+ """
+ if issubclass(dtype, np.ndarray):
+ return cast(DType_T, _validate_array(meta, value))
+
+ coerced = _coerce(dtype, value)
+
+ if dtype is float:
+ precision = meta.get("precision", DEFAULT_PRECISION)
+ coerced = cast(DType_T, round(cast(float, coerced), precision))
+ elif dtype is str:
+ return cast(DType_T, cast(str, coerced)[: meta.get("length")])
+
+ if dtype in (int, float):
+ _check_limits(cast(int | float, coerced), meta.get("limits"))
+
+ return coerced
+
+
+def _coerce(dtype: type[DType_T], value: Any) -> DType_T:
+ if isinstance(value, dtype):
+ return value
+
+ try:
+ return dtype(value) # pyright: ignore[reportCallIssue]
+ except (ValueError, TypeError) as e:
+ raise ValueError(f"Failed to cast {value} to type {dtype}") from e
+
+
+def _check_limits(value: int | float, limits: NumericLimits | None) -> None:
+ if limits is None:
+ return
+
+ control = limits.control
+ if control.low is not None and value < control.low:
+ raise ValueError(f"Value {value} is less than minimum {control.low}")
+ if control.high is not None and value > control.high:
+ raise ValueError(f"Value {value} is greater than maximum {control.high}")
+
+
+def _validate_array(meta: Meta, value: Any) -> np.ndarray:
+ if (structured_dtype := meta.get("structured_dtype")) is not None:
+ array = np.asarray(value)
+ if structured_dtype != array.dtype:
+ raise ValueError(
+ f"Value dtype {array.dtype.descr} is not the same as the "
+ f"structured dtype {structured_dtype}"
+ )
+
+ return array
+
+ array_dtype = array_dtype_of(meta)
+ array = np.asarray(value).astype(array_dtype)
+ if array_dtype != array.dtype:
+ raise ValueError(
+ f"Value dtype {array.dtype} is not the same as the array dtype "
+ f"{array_dtype}"
+ )
+
+ shape = meta.get("shape", DEFAULT_ARRAY_SHAPE)
+ if len(shape) != len(array.shape) or any(
+ actual > maximum for actual, maximum in zip(array.shape, shape, strict=True)
+ ):
+ raise ValueError(
+ f"Value shape {array.shape} exceeeds the shape maximum shape {shape}"
+ )
+
+ return array
diff --git a/src/fastcs/datatypes/waveform.py b/src/fastcs/datatypes/waveform.py
deleted file mode 100644
index 8c09ce239..000000000
--- a/src/fastcs/datatypes/waveform.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from dataclasses import dataclass
-
-import numpy as np
-from numpy.typing import DTypeLike
-
-from fastcs.datatypes.datatype import DataType
-
-
-@dataclass(frozen=True)
-class Waveform(DataType[np.ndarray]):
- array_dtype: DTypeLike
- """Numpy array dtype"""
- shape: tuple[int, ...] = (2000,)
- """Numpy array shape"""
-
- @property
- def dtype(self) -> type[np.ndarray]:
- return np.ndarray
-
- @property
- def initial_value(self) -> np.ndarray:
- return np.zeros(self.shape, dtype=self.array_dtype)
-
- def validate(self, value: np.ndarray) -> np.ndarray:
- _value = super().validate(np.asarray(value).astype(self.array_dtype))
-
- if self.array_dtype != _value.dtype:
- raise ValueError(
- f"Value dtype {_value.dtype} is not the same as the array dtype "
- f"{self.array_dtype}"
- )
-
- if len(self.shape) != len(_value.shape) or any(
- shape1 > shape2
- for shape1, shape2 in zip(_value.shape, self.shape, strict=True)
- ):
- raise ValueError(
- f"Value shape {_value.shape} exceeeds the shape maximum shape "
- f"{self.shape}"
- )
-
- return _value
-
- @staticmethod
- def equal(value1: np.ndarray, value2: np.ndarray) -> bool:
- return np.array_equal(value1, value2)
diff --git a/src/fastcs/demo/eiger.py b/src/fastcs/demo/eiger.py
index 4d473b9af..ba89d1f13 100644
--- a/src/fastcs/demo/eiger.py
+++ b/src/fastcs/demo/eiger.py
@@ -17,37 +17,36 @@
from fastcs.attributes import AttrR, AttrRW, Polled
from fastcs.controllers import Controller
-from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String
+from fastcs.datatypes import DType
from fastcs.demo.simulation.eiger import API_PREFIX, Subsystem, ValueType
-_DATATYPES: dict[ValueType, type[DataType]] = {
- "float": Float,
- "int": Int,
- "string": String,
- "bool": Bool,
+_DATATYPES: dict[ValueType, type[DType]] = {
+ "float": float,
+ "int": int,
+ "string": str,
+ "bool": bool,
}
# Poll period (seconds) for read-only status params that change on the device.
UPDATE_PERIOD = 0.2
-def _datatype(param: str, data: dict[str, Any]) -> DataType:
+def _datatype(param: str, data: dict[str, Any]) -> type[DType]:
"""Build a datatype for a parameter from the metadata the device reports.
- A parameter that reports ``allowed_values`` is discrete, so it becomes an `Enum`
- over an enum class built from those values. The members are only knowable over the
- wire, which is exactly the case introspection exists for.
+ A parameter that reports ``allowed_values`` is discrete, so it becomes an enum
+ class built from those values. The members are only knowable over the wire,
+ which is exactly the case introspection exists for.
"""
allowed_values = data.get("allowed_values")
if allowed_values is None:
- return _DATATYPES[data["value_type"]]()
+ return _DATATYPES[data["value_type"]]
name = "".join(part.title() for part in param.split("_"))
# The functional API builds a class; type checkers only see the instance signature.
- enum_cls = cast(
+ return cast(
type[enum.Enum], enum.Enum(name, {value: value for value in allowed_values})
)
- return Enum(enum_cls)
@dataclass
@@ -112,7 +111,7 @@ class EigerDetector(Controller):
# Derived (soft): built on top of the introspected ``state`` param. Declaring
# ``state`` as a checked attribute is what lets us reference it in code and
# publish something computed from it - here, whether the detector is idle.
- idle = AttrR(Bool())
+ idle = AttrR(bool)
def __init__(
self,
diff --git a/src/fastcs/demo/temperature_attr.py b/src/fastcs/demo/temperature_attr.py
index 756559bc8..89a7caabc 100755
--- a/src/fastcs/demo/temperature_attr.py
+++ b/src/fastcs/demo/temperature_attr.py
@@ -13,7 +13,7 @@
Nothing sits between the protocol and the attribute: no IO class hierarchy, no
per-attribute ref object, no adapter. Because each method annotates its types, the
datatype is inferred from them, so most attributes do not restate it - only the ones
-that want metadata the annotation cannot carry, like ``Float(prec=3)``.
+that want metadata the annotation cannot carry, like ``precision=3``.
Because the attributes are wired in ``__init__`` rather than the class body, each one
can close over per-instance state - which is what lets a ramp's index be baked into
@@ -33,7 +33,7 @@
from fastcs.attributes import AttrR, AttrRW, Polled
from fastcs.connections import IPConnection, IPConnectionSettings
from fastcs.controllers import Controller, ControllerVector
-from fastcs.datatypes import DType_T, Float, Waveform
+from fastcs.datatypes import Array1D, DType_T
from fastcs.logging import logger
from fastcs.methods import command, scan
@@ -140,7 +140,7 @@ def __init__(self, settings: TemperatureControllerSettings) -> None:
)
self.power = AttrR(getter=Polled(self._protocol.get_power, period=0.2))
# Updated by the update_voltages scan below, so no IO of its own
- self.voltages = AttrR(Waveform(np.int32, shape=(4,)))
+ self.voltages = AttrR(Array1D[np.int32], shape=(4,))
self.ramps = ControllerVector(
{
@@ -210,10 +210,10 @@ def __init__(self, index: int, conn: IPConnection) -> None:
# Stated explicitly, to carry metadata the annotation cannot: `-> float`
# says nothing about display precision.
self.target = AttrR(
- Float(prec=3), getter=Polled(self._protocol.get_target, period=0.2)
+ float, precision=3, getter=Polled(self._protocol.get_target, period=0.2)
)
self.actual = AttrR(
- Float(prec=3), getter=Polled(self._protocol.get_actual, period=0.2)
+ float, precision=3, getter=Polled(self._protocol.get_actual, period=0.2)
)
# Updated by the parent controller's update_voltages scan
- self.voltage = AttrR(Float(prec=3))
+ self.voltage = AttrR(float, precision=3)
diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py
index 29a89d3a2..7240d151a 100644
--- a/src/fastcs/transports/epics/ca/ioc.py
+++ b/src/fastcs/transports/epics/ca/ioc.py
@@ -2,13 +2,14 @@
from collections import Counter
from typing import Any, Literal
+import numpy as np
from softioc import builder, softioc
from softioc.asyncio_dispatcher import AsyncioDispatcher
from softioc.pythonSoftIoc import RecordWrapper
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controllers import ControllerAPI
-from fastcs.datatypes import DType_T, Waveform
+from fastcs.datatypes import DEFAULT_ARRAY_SHAPE, DType_T
from fastcs.logging import logger
from fastcs.methods import Command
from fastcs.tracer import Tracer
@@ -126,11 +127,11 @@ def _create_and_link_attribute_pvs(
for attr_name, attribute in controller_api.attributes.items():
if (
- isinstance(attribute.datatype, Waveform)
- and len(attribute.datatype.shape) != 1
+ issubclass(attribute.dtype, np.ndarray)
+ and len(attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE)) != 1
):
logger.warning(
- "Only 1D Waveform attributes are supported in EPICS CA transport",
+ "Only 1D array attributes are supported in EPICS CA transport",
attribute=attribute,
)
continue
@@ -206,7 +207,7 @@ async def async_record_set(value: DType_T):
"PV set from attribute", topic=attribute, pv=pv, value=repr(value)
)
- record.set(cast_to_epics_type(attribute.datatype, value))
+ record.set(cast_to_epics_type(attribute, value))
record = _make_in_record(pv, attribute)
@@ -229,14 +230,14 @@ def _create_and_link_write_pv(
async def on_update(value):
logger.info("PV put: {pv} = {value}", pv=pv, value=repr(value))
- await attribute.set(cast_from_epics_type(attribute.datatype, value))
+ await attribute.set(cast_from_epics_type(attribute, value))
async def set_setpoint_without_process(value: DType_T):
tracer.log_event(
"PV setpoint set from attribute", topic=attribute, pv=pv, value=repr(value)
)
- record.set(cast_to_epics_type(attribute.datatype, value), process=False)
+ record.set(cast_to_epics_type(attribute, value), process=False)
record = _make_out_record(pv, attribute, on_update=on_update)
diff --git a/src/fastcs/transports/epics/ca/util.py b/src/fastcs/transports/epics/ca/util.py
index c6473afbf..374108a39 100644
--- a/src/fastcs/transports/epics/ca/util.py
+++ b/src/fastcs/transports/epics/ca/util.py
@@ -1,15 +1,22 @@
import enum
import re
from collections.abc import Callable
-from dataclasses import asdict
-from typing import Any
+from typing import Any, cast
+import numpy as np
from softioc import builder
from softioc.pythonSoftIoc import RecordWrapper
-from fastcs.attributes import AttrR, AttrRW, AttrW
+from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
from fastcs.controllers import ControllerAPI
-from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform
+from fastcs.datatypes import (
+ DEFAULT_ARRAY_SHAPE,
+ DEFAULT_PRECISION,
+ DType,
+ DType_T,
+ Meta,
+ NumericLimits,
+)
from fastcs.exceptions import FastCSError
from fastcs.transports.epics.util import validate_epics_pv_id
@@ -50,226 +57,234 @@ def validate_ca_id(controller_api: ControllerAPI) -> None:
MBB_MAX_CHOICES = len(_MBB_FIELD_PREFIXES)
-EPICS_ALLOWED_DATATYPES = (Bool, Enum, Float, Int, String, Waveform)
DEFAULT_STRING_WAVEFORM_LENGTH = 256
-DATATYPE_FIELD_TO_IN_RECORD_FIELD = {
- "prec": "PREC",
- "units": "EGU",
- "min_alarm": "LOPR",
- "max_alarm": "HOPR",
-}
-DATATYPE_FIELD_TO_OUT_RECORD_FIELD = {
- "prec": "PREC",
- "units": "EGU",
- "min": "DRVL",
- "max": "DRVH",
- "min_alarm": "LOPR",
- "max_alarm": "HOPR",
-}
+def is_epics_supported(dtype: type[DType]) -> bool:
+ """Whether EPICS CA can serve an attribute of this datatype."""
+ return (
+ dtype in (bool, int, float, str)
+ or issubclass(dtype, enum.Enum)
+ or issubclass(dtype, np.ndarray)
+ )
+
+
+def enum_names(dtype: type[enum.Enum]) -> list[str]:
+ """The names of an enum's members, in declaration order."""
+ return [member.name for member in dtype]
+
+
+def _display_limit_fields(meta: Meta) -> dict[str, Any]:
+ """The record fields for the range an attribute is displayed over."""
+ limits: NumericLimits | None = meta.get("limits")
+ display = limits.display if limits is not None else None
+
+ return {
+ "LOPR": display.low if display is not None else None,
+ "HOPR": display.high if display is not None else None,
+ }
+
+
+def _control_limit_fields(meta: Meta) -> dict[str, Any]:
+ """The record fields for the range an attribute may be driven to."""
+ limits: NumericLimits | None = meta.get("limits")
+ control = limits.control if limits is not None else None
+
+ return {
+ "DRVL": control.low if control is not None else None,
+ "DRVH": control.high if control is not None else None,
+ }
+
+
+def _string_length(meta: Meta) -> int:
+ return (meta.get("length") or DEFAULT_STRING_WAVEFORM_LENGTH) + 1
+
+
+def _array_length(meta: Meta) -> int:
+ return meta.get("shape", DEFAULT_ARRAY_SHAPE)[0]
def _make_in_record(pv: str, attribute: AttrR) -> RecordWrapper:
+ meta = attribute.meta
+ dtype = attribute.dtype
common_fields = {
"DESC": attribute.description,
- "initial_value": cast_to_epics_type(attribute.datatype, attribute.readback),
+ "initial_value": cast_to_epics_type(attribute, attribute.readback),
}
- match attribute.datatype:
- case Bool():
- record = builder.boolIn(pv, ZNAM="False", ONAM="True", **common_fields)
- case Int():
- record = builder.longIn(
- pv,
- LOPR=attribute.datatype.min_alarm,
- HOPR=attribute.datatype.max_alarm,
- EGU=attribute.datatype.units,
- **common_fields,
- )
- case Float():
- record = builder.aIn(
- pv,
- LOPR=attribute.datatype.min_alarm,
- HOPR=attribute.datatype.max_alarm,
- EGU=attribute.datatype.units,
- PREC=attribute.datatype.prec,
- **common_fields,
- )
- case String():
- record = builder.longStringIn(
- pv,
- length=(attribute.datatype.length + 1)
- if attribute.datatype.length
- else DEFAULT_STRING_WAVEFORM_LENGTH + 1,
- **common_fields,
- )
- case Enum():
- if len(attribute.datatype.members) > MBB_MAX_CHOICES:
- record = builder.longStringIn(
- pv,
- **common_fields,
- )
- else:
- common_fields.update(create_state_keys(attribute.datatype))
- record = builder.mbbIn(
- pv,
- **common_fields,
- )
- case Waveform():
- record = builder.WaveformIn(
- pv, length=attribute.datatype.shape[0], **common_fields
- )
- case _:
- raise FastCSError(
- f"EPICS unsupported datatype on {attribute}: {attribute.datatype}"
- )
-
- def datatype_updater(datatype: DataType):
- for name, value in asdict(datatype).items():
- if name in DATATYPE_FIELD_TO_IN_RECORD_FIELD:
- record.set_field(DATATYPE_FIELD_TO_IN_RECORD_FIELD[name], value)
-
- attribute.add_update_datatype_callback(datatype_updater)
+ if dtype is bool:
+ record = builder.boolIn(pv, ZNAM="False", ONAM="True", **common_fields)
+ elif dtype is int:
+ record = builder.longIn(
+ pv,
+ EGU=meta.get("units"),
+ **_display_limit_fields(meta),
+ **common_fields,
+ )
+ elif dtype is float:
+ record = builder.aIn(
+ pv,
+ EGU=meta.get("units"),
+ PREC=meta.get("precision", DEFAULT_PRECISION),
+ **_display_limit_fields(meta),
+ **common_fields,
+ )
+ elif dtype is str:
+ record = builder.longStringIn(pv, length=_string_length(meta), **common_fields)
+ elif issubclass(dtype, enum.Enum):
+ if len(enum_names(dtype)) > MBB_MAX_CHOICES:
+ record = builder.longStringIn(pv, **common_fields)
+ else:
+ common_fields.update(create_state_keys(dtype))
+ record = builder.mbbIn(pv, **common_fields)
+ elif issubclass(dtype, np.ndarray):
+ record = builder.WaveformIn(pv, length=_array_length(meta), **common_fields)
+ else:
+ raise FastCSError(f"EPICS unsupported datatype on {attribute}: {dtype}")
+
+ _mirror_meta_onto_record(attribute, record, _in_record_fields)
return record
def _make_out_record(pv: str, attribute: AttrW, on_update: Callable) -> RecordWrapper:
+ meta = attribute.meta
+ dtype = attribute.dtype
common_fields = {
"DESC": attribute.description,
"initial_value": cast_to_epics_type(
- attribute.datatype,
+ attribute,
attribute.readback
if isinstance(attribute, AttrRW)
- else attribute.datatype.initial_value,
+ else attribute.default_value(),
),
"on_update": on_update,
"always_update": True,
"blocking": True,
}
- match attribute.datatype:
- case Bool():
- record = builder.boolOut(pv, ZNAM="False", ONAM="True", **common_fields)
- case Int():
- record = builder.longOut(
- pv,
- LOPR=attribute.datatype.min_alarm,
- HOPR=attribute.datatype.max_alarm,
- EGU=attribute.datatype.units,
- DRVL=attribute.datatype.min,
- DRVH=attribute.datatype.max,
- **common_fields,
- )
- case Float():
- record = builder.aOut(
- pv,
- LOPR=attribute.datatype.min_alarm,
- HOPR=attribute.datatype.max_alarm,
- EGU=attribute.datatype.units,
- PREC=attribute.datatype.prec,
- DRVL=attribute.datatype.min,
- DRVH=attribute.datatype.max,
- **common_fields,
- )
- case String():
+ if dtype is bool:
+ record = builder.boolOut(pv, ZNAM="False", ONAM="True", **common_fields)
+ elif dtype is int:
+ record = builder.longOut(
+ pv,
+ EGU=meta.get("units"),
+ **_display_limit_fields(meta),
+ **_control_limit_fields(meta),
+ **common_fields,
+ )
+ elif dtype is float:
+ record = builder.aOut(
+ pv,
+ EGU=meta.get("units"),
+ PREC=meta.get("precision", DEFAULT_PRECISION),
+ **_display_limit_fields(meta),
+ **_control_limit_fields(meta),
+ **common_fields,
+ )
+ elif dtype is str:
+ record = builder.longStringOut(pv, length=_string_length(meta), **common_fields)
+ elif issubclass(dtype, enum.Enum):
+ names = enum_names(dtype)
+ if len(names) > MBB_MAX_CHOICES:
+
+ def _verify_in_names(_, value):
+ return value in names
+
record = builder.longStringOut(
- pv,
- length=(attribute.datatype.length + 1)
- if attribute.datatype.length
- else DEFAULT_STRING_WAVEFORM_LENGTH + 1,
- **common_fields,
- )
- case Enum():
- if len(attribute.datatype.members) > MBB_MAX_CHOICES:
- datatype: Enum = attribute.datatype
-
- def _verify_in_datatype(_, value):
- return value in datatype.names
-
- record = builder.longStringOut(
- pv,
- validate=_verify_in_datatype,
- **common_fields,
- )
-
- else:
- common_fields.update(create_state_keys(attribute.datatype))
- record = builder.mbbOut(
- pv,
- **common_fields,
- )
- case Waveform():
- record = builder.WaveformOut(
- pv,
- length=attribute.datatype.shape[0],
- **common_fields,
- )
- case _:
- raise FastCSError(
- f"EPICS unsupported datatype on {attribute}: {attribute.datatype}"
+ pv, validate=_verify_in_names, **common_fields
)
+ else:
+ common_fields.update(create_state_keys(dtype))
+ record = builder.mbbOut(pv, **common_fields)
+ elif issubclass(dtype, np.ndarray):
+ record = builder.WaveformOut(pv, length=_array_length(meta), **common_fields)
+ else:
+ raise FastCSError(f"EPICS unsupported datatype on {attribute}: {dtype}")
+
+ _mirror_meta_onto_record(attribute, record, _out_record_fields)
+ return record
- def datatype_updater(datatype: DataType):
- for name, value in asdict(datatype).items():
- if name in DATATYPE_FIELD_TO_OUT_RECORD_FIELD:
- record.set_field(DATATYPE_FIELD_TO_OUT_RECORD_FIELD[name], value)
- attribute.add_update_datatype_callback(datatype_updater)
- return record
+def _in_record_fields(meta: Meta) -> dict[str, Any]:
+ return {
+ "PREC": meta.get("precision"),
+ "EGU": meta.get("units"),
+ **_display_limit_fields(meta),
+ }
+
+
+def _out_record_fields(meta: Meta) -> dict[str, Any]:
+ return {**_in_record_fields(meta), **_control_limit_fields(meta)}
+
+def _mirror_meta_onto_record(
+ attribute: Attribute,
+ record: RecordWrapper,
+ fields_from_meta: Callable[[Meta], dict[str, Any]],
+) -> None:
+ """Push later metadata changes - new units, say - onto the record."""
-def create_state_keys(datatype: Enum):
+ def meta_updater(meta: Meta) -> None:
+ for field, value in fields_from_meta(meta).items():
+ if value is not None:
+ record.set_field(field, value)
+
+ attribute.add_update_meta_callback(meta_updater)
+
+
+def create_state_keys(dtype: type[enum.Enum]) -> dict[str, str]:
"""Creates a dictionary of state field keys to names"""
return dict(
zip(
MBB_STATE_FIELDS,
- datatype.names,
+ enum_names(dtype),
strict=False,
)
)
-def cast_from_epics_type(datatype: DataType[DType_T], value: object) -> DType_T:
- """Casts from an EPICS datatype to a FastCS datatype."""
- match datatype:
- case Bool():
- if value == 0:
- return False
- elif value == 1:
- return True
- else:
- raise ValueError(f"Invalid bool value from EPICS record {value}")
- case Enum():
- if len(datatype.members) <= MBB_MAX_CHOICES:
- assert isinstance(value, int), "Got non-integer value for Enum"
- return datatype.validate(datatype.members[value])
- else: # enum backed by string record
- assert isinstance(value, str), "Got non-string value for long Enum"
- # python typing can't narrow the nested generic enum_cls
- assert issubclass(datatype.enum_cls, enum.Enum), "Invalid Enum.enum_cls"
- enum_member = datatype.enum_cls[value]
- return datatype.validate(enum_member)
- case datatype if issubclass(type(datatype), EPICS_ALLOWED_DATATYPES):
- return datatype.validate(value) # type: ignore
- case _:
- raise ValueError(f"Unsupported datatype {datatype}")
-
-
-def cast_to_epics_type(datatype: DataType[DType_T], value: DType_T) -> Any:
- """Casts from an attribute's datatype to an EPICS datatype."""
- match datatype:
- case Enum():
- if len(datatype.members) <= MBB_MAX_CHOICES:
- return datatype.index_of(datatype.validate(value))
- else: # enum backed by string record
- return datatype.validate(value).name
- case String() as string:
- if string.length is not None:
- return value[: string.length]
- else:
- return value[:DEFAULT_STRING_WAVEFORM_LENGTH]
- case datatype if issubclass(type(datatype), EPICS_ALLOWED_DATATYPES):
- return value
- case _:
- raise ValueError(f"Unsupported datatype {datatype}")
+def cast_from_epics_type(attribute: Attribute[DType_T], value: object) -> DType_T:
+ """Casts from an EPICS value to an attribute's datatype."""
+ dtype = attribute.dtype
+
+ if dtype is bool:
+ if value == 0:
+ return False # pyright: ignore[reportReturnType]
+ elif value == 1:
+ return True # pyright: ignore[reportReturnType]
+ else:
+ raise ValueError(f"Invalid bool value from EPICS record {value}")
+
+ if issubclass(dtype, enum.Enum):
+ if len(enum_names(dtype)) <= MBB_MAX_CHOICES:
+ assert isinstance(value, int), "Got non-integer value for Enum"
+ return attribute.validate(list(dtype)[value])
+ # enum backed by string record
+ assert isinstance(value, str), "Got non-string value for long Enum"
+ return attribute.validate(dtype[value])
+
+ if is_epics_supported(dtype):
+ return attribute.validate(value)
+
+ raise ValueError(f"Unsupported datatype {dtype}")
+
+
+def cast_to_epics_type(attribute: Attribute[DType_T], value: DType_T) -> Any:
+ """Casts from an attribute's value to an EPICS value."""
+ dtype = attribute.dtype
+
+ if issubclass(dtype, enum.Enum):
+ member = cast(enum.Enum, attribute.validate(value))
+ if len(enum_names(dtype)) <= MBB_MAX_CHOICES:
+ return list(dtype).index(member)
+ # enum backed by string record
+ return member.name
+
+ if dtype is str:
+ length = attribute.meta.get("length") or DEFAULT_STRING_WAVEFORM_LENGTH
+ return str(value)[:length]
+
+ if is_epics_supported(dtype):
+ return value
+
+ raise ValueError(f"Unsupported datatype {dtype}")
diff --git a/src/fastcs/transports/epics/gui.py b/src/fastcs/transports/epics/gui.py
index 882a83a02..20f9f972e 100644
--- a/src/fastcs/transports/epics/gui.py
+++ b/src/fastcs/transports/epics/gui.py
@@ -1,3 +1,6 @@
+import enum
+
+import numpy as np
from pvi.device import (
LED,
ArrayTrace,
@@ -24,14 +27,7 @@
from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
from fastcs.controllers import ControllerAPI
-from fastcs.datatypes import (
- Bool,
- Enum,
- Float,
- Int,
- String,
- Waveform,
-)
+from fastcs.datatypes import DEFAULT_ARRAY_SHAPE, DEFAULT_PRECISION
from fastcs.logging import logger
from fastcs.methods import Command
from fastcs.transports.epics.util import pv_prefix_from_path
@@ -51,45 +47,50 @@ def _get_pv(self, attr_path: list[str], name: str):
return f"{attr_prefix}:{snake_to_pascal(name)}"
def _get_read_widget(self, attribute: Attribute) -> ReadWidgetUnion | None:
- match attribute.datatype:
- case Bool():
- return LED()
- case Int():
- return TextRead(precision=0)
- case Float(prec=precision):
- return TextRead(precision=precision)
- case String():
- return TextRead(format=TextFormat.string)
- case Enum():
- return TextRead(format=TextFormat.string)
- case Waveform() as waveform:
- if len(waveform.shape) > 1:
- logger.warning(
- "EPICS CA transport only supports 1D waveforms, "
- f"{attribute} is a {len(waveform.shape)}D waveform"
- )
- return None
+ dtype = attribute.dtype
+ if dtype is bool:
+ return LED()
+ if dtype is int:
+ return TextRead(precision=0)
+ if dtype is float:
+ return TextRead(
+ precision=attribute.meta.get("precision", DEFAULT_PRECISION)
+ )
+ if dtype is str:
+ return TextRead(format=TextFormat.string)
+ if issubclass(dtype, enum.Enum):
+ return TextRead(format=TextFormat.string)
+ if issubclass(dtype, np.ndarray):
+ shape = attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE)
+ if len(shape) > 1:
+ logger.warning(
+ "EPICS CA transport only supports 1D waveforms, "
+ f"{attribute} is a {len(shape)}D waveform"
+ )
+ return None
+
+ return ArrayTrace(axis="x")
- return ArrayTrace(axis="x")
- case datatype:
- raise TypeError(f"Unsupported type {type(datatype)}: {datatype}")
+ raise TypeError(f"Unsupported type {dtype}")
def _get_write_widget(self, attribute: Attribute) -> WriteWidgetUnion | None:
- match attribute.datatype:
- case Bool():
- return ToggleButton()
- case Int():
- return TextWrite(precision=0)
- case Float(prec=precision):
- return TextWrite(precision=precision)
- case String():
- return TextWrite(format=TextFormat.string)
- case Enum():
- return ComboBox(choices=attribute.datatype.names)
- case Waveform():
- return None
- case datatype:
- raise TypeError(f"Unsupported type {type(datatype)}: {datatype}")
+ dtype = attribute.dtype
+ if dtype is bool:
+ return ToggleButton()
+ if dtype is int:
+ return TextWrite(precision=0)
+ if dtype is float:
+ return TextWrite(
+ precision=attribute.meta.get("precision", DEFAULT_PRECISION)
+ )
+ if dtype is str:
+ return TextWrite(format=TextFormat.string)
+ if issubclass(dtype, enum.Enum):
+ return ComboBox(choices=[member.name for member in dtype])
+ if issubclass(dtype, np.ndarray):
+ return None
+
+ raise TypeError(f"Unsupported type {dtype}")
def _get_attribute_component(
self, attr_path: list[str], name: str, attribute: Attribute
diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py
index 5668ce0d4..ea78820ef 100644
--- a/src/fastcs/transports/epics/pva/_pv_handlers.py
+++ b/src/fastcs/transports/epics/pva/_pv_handlers.py
@@ -1,3 +1,5 @@
+import enum
+
import numpy as np
from p4p import Value
from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable
@@ -7,7 +9,6 @@
from p4p.server.asyncio import SharedPV
from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
-from fastcs.datatypes import Enum, Table
from fastcs.methods import CommandCallback
from fastcs.tracer import Tracer
@@ -30,11 +31,12 @@ def __init__(self, attr_w: AttrW | AttrRW):
async def put(self, pv: SharedPV, op: ServerOperation):
value = op.value()
- if isinstance(self._attr_w.datatype, Table):
+ structured_dtype = self._attr_w.meta.get("structured_dtype")
+ if structured_dtype is not None:
assert isinstance(value, list)
raw_value = np.array(
[tuple(labelled_row.values()) for labelled_row in value],
- dtype=self._attr_w.datatype.structured_dtype,
+ dtype=structured_dtype,
)
elif isinstance(value, Value):
raw_value = value.todict()["value"]
@@ -48,7 +50,7 @@ async def put(self, pv: SharedPV, op: ServerOperation):
tracer.log_event("PV put", topic=self._attr_w, pv=pv, value=cast_value)
- if isinstance(self._attr_w.datatype, Enum):
+ if issubclass(self._attr_w.dtype, enum.Enum):
pv.post(cast_to_p4p_value(self._attr_w, cast_value))
else:
pv.post(value)
@@ -137,7 +139,7 @@ async def set_readback(value):
def make_shared_write_pv(attribute: AttrW) -> SharedPV:
shared_pv = SharedPV(
handler=WritePvHandler(attribute),
- initial=cast_to_p4p_value(attribute, attribute.datatype.initial_value),
+ initial=cast_to_p4p_value(attribute, attribute.default_value()),
**_make_shared_pv_arguments(attribute),
)
diff --git a/src/fastcs/transports/epics/pva/gui.py b/src/fastcs/transports/epics/pva/gui.py
index 0ae6e4de1..5d25660d0 100644
--- a/src/fastcs/transports/epics/pva/gui.py
+++ b/src/fastcs/transports/epics/pva/gui.py
@@ -1,3 +1,4 @@
+import numpy as np
from pvi.device import (
CheckBox,
ImageColorMap,
@@ -9,7 +10,10 @@
)
from fastcs.attributes import Attribute, AttrR, AttrW
-from fastcs.datatypes import Bool, Table, Waveform, numpy_to_fastcs_datatype
+from fastcs.datatypes import (
+ DEFAULT_ARRAY_SHAPE,
+ numpy_to_python_type,
+)
from fastcs.transports.epics.gui import EpicsGUI
@@ -22,39 +26,42 @@ def _get_pv(self, attr_path: list[str], name: str):
return f"pva://{super()._get_pv(attr_path, name)}"
def _get_read_widget(self, attribute: Attribute) -> ReadWidgetUnion | None:
- match attribute.datatype:
- case Table():
- fastcs_datatypes = [
- numpy_to_fastcs_datatype(datatype)
- for _, datatype in attribute.datatype.structured_dtype
- ]
-
- base_get_read_widget = super()._get_read_widget
- widgets = [
- base_get_read_widget(AttrR(datatype))
- for datatype in fastcs_datatypes
- ]
-
- return TableRead(widgets=widgets) # type: ignore
- case Waveform(shape=(height, width)):
+ structured_dtype = attribute.meta.get("structured_dtype")
+ if structured_dtype is not None:
+ column_types = [
+ numpy_to_python_type(column_dtype)
+ for _, column_dtype in structured_dtype
+ ]
+
+ base_get_read_widget = super()._get_read_widget
+ widgets = [
+ base_get_read_widget(AttrR(column_type)) for column_type in column_types
+ ]
+
+ return TableRead(widgets=widgets) # type: ignore
+
+ if issubclass(attribute.dtype, np.ndarray):
+ shape = attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE)
+ if len(shape) == 2:
+ height, width = shape
return ImageRead(
height=height, width=width, color_map=ImageColorMap.GRAY
)
- case _:
- return super()._get_read_widget(attribute)
+
+ return super()._get_read_widget(attribute)
def _get_write_widget(self, attribute: Attribute) -> WriteWidgetUnion | None:
- match attribute.datatype:
- case Table():
- widgets = []
- for _, datatype in attribute.datatype.structured_dtype:
- fastcs_datatype = numpy_to_fastcs_datatype(datatype)
- if isinstance(fastcs_datatype, Bool):
- # Replace with compact version for Table row
- widget = CheckBox()
- else:
- widget = super()._get_write_widget(AttrW(fastcs_datatype))
- widgets.append(widget)
- return TableWrite(widgets=widgets)
- case _:
- return super()._get_write_widget(attribute)
+ structured_dtype = attribute.meta.get("structured_dtype")
+ if structured_dtype is not None:
+ widgets = []
+ for _, column_dtype in structured_dtype:
+ column_type = numpy_to_python_type(column_dtype)
+ if column_type is bool:
+ # Replace with compact version for Table row
+ widget = CheckBox()
+ else:
+ widget = super()._get_write_widget(AttrW(column_type))
+ widgets.append(widget)
+ return TableWrite(widgets=widgets)
+
+ return super()._get_write_widget(attribute)
diff --git a/src/fastcs/transports/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py
index 75e979996..ea8accc0d 100644
--- a/src/fastcs/transports/epics/pva/types.py
+++ b/src/fastcs/transports/epics/pva/types.py
@@ -1,3 +1,4 @@
+import enum
import math
import time
@@ -7,10 +8,14 @@
from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable
from fastcs.attributes import Attribute, AttrR, AttrW
-from fastcs.datatypes import Bool, DType, Enum, Float, Int, String, Table, Waveform
-from fastcs.datatypes.datatype import DType_T
-
-P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform, Table)
+from fastcs.datatypes import (
+ DEFAULT_ARRAY_SHAPE,
+ DEFAULT_PRECISION,
+ DType,
+ DType_T,
+ Meta,
+ NumericLimits,
+)
# https://epics-base.github.io/pvxs/nt.html#alarm-t
RECORD_ALARM_STATUS = 3
@@ -49,64 +54,78 @@ def _table_with_numpy_dtypes_to_p4p_dtypes(numpy_dtypes: list[tuple[str, DTypeLi
return p4p_dtypes
+def is_p4p_supported(dtype: type[DType]) -> bool:
+ """Whether the PVA transport can serve an attribute of this datatype."""
+ return (
+ dtype in (bool, int, float, str)
+ or issubclass(dtype, enum.Enum)
+ or issubclass(dtype, np.ndarray)
+ )
+
+
def make_p4p_type(
attribute: Attribute,
) -> NTScalar | NTEnum | NTNDArray | NTTable:
- """Creates a p4p type for a given `Attribute` `DataType`."""
+ """Creates a p4p type for a given `Attribute` datatype."""
display = isinstance(attribute, AttrR)
control = isinstance(attribute, AttrW)
- match attribute.datatype:
- case Int():
- return NTScalar.buildType("i", display=display, control=control)
- case Float():
- return NTScalar.buildType("d", display=display, control=control, form=True)
- case String():
- return NTScalar.buildType("s", display=display, control=control)
- case Bool():
- return NTScalar.buildType("?", display=display, control=control)
- case Enum():
- return NTEnum()
- case Waveform():
- # TODO: https://github.com/DiamondLightSource/FastCS/issues/123
- # * Make 1D scalar array for 1D shapes.
- # This will require converting from np.int32 to "ai"
- # if len(shape) == 1:
- # return NTScalarArray(convert np.datatype32 to string "ad")
- # * Add an option for allowing shape to change, if so we will
- # use an NDArray here even if shape is 1D
-
- return NTNDArray()
- case Table(structured_dtype):
+ dtype = attribute.dtype
+
+ if dtype is bool:
+ return NTScalar.buildType("?", display=display, control=control)
+ if dtype is int:
+ return NTScalar.buildType("i", display=display, control=control)
+ if dtype is float:
+ return NTScalar.buildType("d", display=display, control=control, form=True)
+ if dtype is str:
+ return NTScalar.buildType("s", display=display, control=control)
+ if issubclass(dtype, enum.Enum):
+ return NTEnum()
+ if issubclass(dtype, np.ndarray):
+ if (structured_dtype := attribute.meta.get("structured_dtype")) is not None:
# TODO: `NTEnum/NTNDArray/NTTable.wrap` don't accept extra fields until
# https://github.com/epics-base/p4p/issues/166
return NTTable(
columns=_table_with_numpy_dtypes_to_p4p_dtypes(structured_dtype)
)
- case _:
- raise RuntimeError(f"DataType `{attribute.datatype}` unsupported in P4P.")
+
+ # TODO: https://github.com/DiamondLightSource/FastCS/issues/123
+ # * Make 1D scalar array for 1D shapes.
+ # This will require converting from np.int32 to "ai"
+ # if len(shape) == 1:
+ # return NTScalarArray(convert np.datatype32 to string "ad")
+ # * Add an option for allowing shape to change, if so we will
+ # use an NDArray here even if shape is 1D
+
+ return NTNDArray()
+
+ raise RuntimeError(f"Datatype `{dtype}` unsupported in P4P.")
def cast_from_p4p_value(attribute: Attribute[DType_T], value: object) -> DType_T:
"""Converts from a p4p value to a FastCS `Attribute` value."""
- match attribute.datatype:
- case Enum():
- assert hasattr(value, "index"), "Got non-enum p4p.Value for Enum DataType"
- index: int = value.index # pyright: ignore[reportAttributeAccessIssue]
- return attribute.datatype.validate(attribute.datatype.members[index])
- case Waveform(shape=shape):
- # p4p sends a flattened array
- assert value.shape == (math.prod(shape),)
- return attribute.datatype.validate(value.reshape(attribute.datatype.shape))
- case Table(structured_dtype):
+ dtype = attribute.dtype
+
+ if issubclass(dtype, enum.Enum):
+ assert hasattr(value, "index"), "Got non-enum p4p.Value for Enum datatype"
+ index: int = value.index # pyright: ignore[reportAttributeAccessIssue]
+ return attribute.validate(list(dtype)[index])
+
+ if issubclass(dtype, np.ndarray):
+ if (structured_dtype := attribute.meta.get("structured_dtype")) is not None:
assert isinstance(value, np.ndarray)
- return attribute.datatype.validate(np.array(value, dtype=structured_dtype))
- case attribute.datatype if issubclass(
- type(attribute.datatype), P4P_ALLOWED_DATATYPES
- ):
- return attribute.datatype.validate(value) # type: ignore
- case _:
- raise ValueError(f"Unsupported datatype {attribute.datatype}")
+ return attribute.validate(np.array(value, dtype=structured_dtype))
+
+ shape = attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE)
+ # p4p sends a flattened array
+ assert value.shape == (math.prod(shape),) # pyright: ignore[reportAttributeAccessIssue]
+ return attribute.validate(value.reshape(shape)) # pyright: ignore[reportAttributeAccessIssue]
+
+ if is_p4p_supported(dtype):
+ return attribute.validate(value)
+
+ raise ValueError(f"Unsupported datatype {dtype}")
def p4p_alarm_states(
@@ -140,26 +159,33 @@ def p4p_timestamp_now() -> dict:
def p4p_display(attribute: Attribute) -> dict:
"""Gets the p4p display structure for a given attribute."""
display = {}
+ meta = attribute.meta
if attribute.description is not None:
display["description"] = attribute.description
- if isinstance(attribute.datatype, (Float | Int)):
- if attribute.datatype.max is not None:
- display["limitHigh"] = attribute.datatype.max
- if attribute.datatype.min is not None:
- display["limitLow"] = attribute.datatype.min
- if attribute.datatype.units is not None:
- display["units"] = attribute.datatype.units
- if isinstance(attribute.datatype, Float):
- if attribute.datatype.prec is not None:
- display["precision"] = attribute.datatype.prec
+ if attribute.dtype in (int, float):
+ limits: NumericLimits | None = meta.get("limits")
+ if limits is not None:
+ if limits.control.high is not None:
+ display["limitHigh"] = limits.control.high
+ if limits.control.low is not None:
+ display["limitLow"] = limits.control.low
+ if (units := meta.get("units")) is not None:
+ display["units"] = units
+ if attribute.dtype is float:
+ display["precision"] = meta.get("precision", DEFAULT_PRECISION)
if display:
return {"display": display}
return {}
-def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) -> dict:
- low = None if datatype.min_alarm is None else value < datatype.min_alarm # type: ignore
- high = None if datatype.max_alarm is None else value > datatype.max_alarm # type: ignore
+def _p4p_check_numeric_for_alarm_states(meta: Meta, value: DType) -> dict:
+ limits: NumericLimits | None = meta.get("limits")
+ alarm = limits.alarm if limits is not None else None
+ alarm_low = alarm.low if alarm is not None else None
+ alarm_high = alarm.high if alarm is not None else None
+
+ low = None if alarm_low is None else value < alarm_low # type: ignore
+ high = None if alarm_high is None else value > alarm_high # type: ignore
severity = (
MAJOR_ALARM_SEVERITY
if high not in (None, False) or low not in (None, False)
@@ -169,12 +195,12 @@ def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) ->
if low:
status, message = (
RECORD_ALARM_STATUS,
- f"Below minimum alarm limit: {datatype.min_alarm}",
+ f"Below minimum alarm limit: {alarm_low}",
)
if high:
status, message = (
RECORD_ALARM_STATUS,
- f"Above maximum alarm limit: {datatype.max_alarm}",
+ f"Above maximum alarm limit: {alarm_high}",
)
return p4p_alarm_states(severity, status, message)
@@ -182,34 +208,32 @@ def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) ->
def cast_to_p4p_value(attribute: Attribute[DType_T], value: DType_T) -> object:
"""Converts a FastCS ``Attribute`` value to a p4p value"""
- match attribute.datatype:
- case Enum():
- return {
- "index": attribute.datatype.index_of(value),
- "choices": attribute.datatype.names,
- }
- case Waveform():
- return attribute.datatype.validate(value)
- case Table():
- return attribute.datatype.validate(value)
-
- case datatype if issubclass(type(datatype), P4P_ALLOWED_DATATYPES):
- record_fields: dict = {"value": datatype.validate(value)}
- if isinstance(attribute, AttrR):
- record_fields.update(p4p_display(attribute))
-
- if isinstance(datatype, (Float | Int)):
- record_fields.update(
- _p4p_check_numeric_for_alarm_states(
- datatype,
- value,
- )
- )
- else:
- record_fields.update(p4p_alarm_states())
-
- record_fields.update(p4p_timestamp_now())
-
- return Value(make_p4p_type(attribute), record_fields)
- case _:
- raise ValueError(f"Unsupported datatype {attribute.datatype}")
+ dtype = attribute.dtype
+
+ if issubclass(dtype, enum.Enum):
+ members = list(dtype)
+ return {
+ "index": members.index(value), # pyright: ignore[reportArgumentType]
+ "choices": [member.name for member in members],
+ }
+
+ if issubclass(dtype, np.ndarray):
+ return attribute.validate(value)
+
+ if is_p4p_supported(dtype):
+ record_fields: dict = {"value": attribute.validate(value)}
+ if isinstance(attribute, AttrR):
+ record_fields.update(p4p_display(attribute))
+
+ if dtype in (int, float):
+ record_fields.update(
+ _p4p_check_numeric_for_alarm_states(attribute.meta, value)
+ )
+ else:
+ record_fields.update(p4p_alarm_states())
+
+ record_fields.update(p4p_timestamp_now())
+
+ return Value(make_p4p_type(attribute), record_fields)
+
+ raise ValueError(f"Unsupported datatype {dtype}")
diff --git a/src/fastcs/transports/graphql/graphql.py b/src/fastcs/transports/graphql/graphql.py
index 0c74ad27d..0cf0e8b98 100644
--- a/src/fastcs/transports/graphql/graphql.py
+++ b/src/fastcs/transports/graphql/graphql.py
@@ -9,7 +9,7 @@
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controllers import ControllerAPI
-from fastcs.datatypes.datatype import DType_T
+from fastcs.datatypes import DType_T
from fastcs.exceptions import FastCSError
from fastcs.logging import intercept_std_logger
@@ -149,8 +149,8 @@ async def _dynamic_f(value):
# Add type annotations for validation, schema, conversions
_dynamic_f.__name__ = attr_name
- _dynamic_f.__annotations__["value"] = attribute.datatype.dtype
- _dynamic_f.__annotations__["return"] = attribute.datatype.dtype
+ _dynamic_f.__annotations__["value"] = attribute.dtype
+ _dynamic_f.__annotations__["return"] = attribute.dtype
return _dynamic_f
@@ -164,7 +164,7 @@ async def _dynamic_f() -> DType_T:
return attribute.readback
_dynamic_f.__name__ = attr_name
- _dynamic_f.__annotations__["return"] = attribute.datatype.dtype
+ _dynamic_f.__annotations__["return"] = attribute.dtype
return _dynamic_f
diff --git a/src/fastcs/transports/rest/rest.py b/src/fastcs/transports/rest/rest.py
index a8ca359d8..d014bae75 100644
--- a/src/fastcs/transports/rest/rest.py
+++ b/src/fastcs/transports/rest/rest.py
@@ -7,7 +7,7 @@
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controllers import ControllerAPI
-from fastcs.datatypes.datatype import DType_T
+from fastcs.datatypes import DType_T
from fastcs.logging import intercept_std_logger
from fastcs.methods import CommandCallback
@@ -56,8 +56,8 @@ def _put_request_body(attribute: AttrW[DType_T]):
Creates a pydantic model for each datatype which defines the schema
of the PUT request body
"""
- converted_datatype = convert_datatype(attribute.datatype)
- type_name = str(attribute.datatype.dtype.__name__).title()
+ converted_datatype = convert_datatype(attribute.dtype)
+ type_name = str(attribute.dtype.__name__).title()
# key=(type, ...) to declare a field without default value
return create_model(
f"Put{type_name}Value",
@@ -69,7 +69,7 @@ def _wrap_attr_put(
attribute: AttrW[DType_T],
) -> Callable[[DType_T], Coroutine[Any, Any, None]]:
async def attr_put(request):
- await attribute.set(cast_from_rest_type(attribute.datatype, request.value))
+ await attribute.set(cast_from_rest_type(attribute, request.value))
# Fast api uses type annotations for validation, schema, conversions
attr_put.__annotations__["request"] = _put_request_body(attribute)
@@ -82,7 +82,7 @@ def _get_response_body(attribute: AttrR[DType_T]):
Creates a pydantic model for each datatype which defines the schema
of the GET request body
"""
- converted_datatype = convert_datatype(attribute.datatype)
+ converted_datatype = convert_datatype(attribute.dtype)
type_name = str(converted_datatype.__name__).title()
# key=(type, ...) to declare a field without default value
return create_model(
@@ -96,7 +96,7 @@ def _wrap_attr_get(
) -> Callable[[], Coroutine[Any, Any, dict[str, object]]]:
async def attr_get() -> dict[str, object]:
value = attribute.readback
- return {"value": cast_to_rest_type(attribute.datatype, value)}
+ return {"value": cast_to_rest_type(attribute, value)}
return attr_get
diff --git a/src/fastcs/transports/rest/util.py b/src/fastcs/transports/rest/util.py
index c869a0d9f..2e8406ca8 100644
--- a/src/fastcs/transports/rest/util.py
+++ b/src/fastcs/transports/rest/util.py
@@ -2,9 +2,8 @@
import numpy as np
-from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform
-
-REST_ALLOWED_DATATYPES = (Bool, DataType, Enum, Float, Int, String)
+from fastcs.attributes import Attribute
+from fastcs.datatypes import DType, DType_T, array_dtype_of
_REST_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
@@ -20,32 +19,25 @@ def validate_rest_id(id: str) -> None:
)
-def convert_datatype(datatype: DataType[DType_T]) -> type[DType_T]:
+def convert_datatype(dtype: type[DType]) -> type:
"""Converts a datatype to a rest serialisable type."""
- match datatype:
- case Waveform():
- return list
- case _:
- return datatype.dtype
+ if issubclass(dtype, np.ndarray):
+ return list
+
+ return dtype
-def cast_to_rest_type(datatype: DataType[DType_T], value: DType_T) -> object:
+def cast_to_rest_type(attribute: Attribute[DType_T], value: DType_T) -> object:
"""Casts from an attribute value to a rest value."""
- match datatype:
- case Waveform():
- return value.tolist()
- case datatype if issubclass(type(datatype), REST_ALLOWED_DATATYPES):
- return datatype.validate(value)
- case _:
- raise ValueError(f"Unsupported datatype {datatype}")
+ if issubclass(attribute.dtype, np.ndarray):
+ return value.tolist() # pyright: ignore[reportAttributeAccessIssue]
+ return attribute.validate(value)
-def cast_from_rest_type(datatype: DataType[DType_T], value: object) -> DType_T:
+
+def cast_from_rest_type(attribute: Attribute[DType_T], value: object) -> DType_T:
"""Casts from a rest value to an attribute datatype."""
- match datatype:
- case Waveform():
- return datatype.validate(np.array(value, dtype=datatype.array_dtype))
- case datatype if issubclass(type(datatype), REST_ALLOWED_DATATYPES):
- return datatype.validate(value) # type: ignore
- case _:
- raise ValueError(f"Unsupported datatype {datatype}")
+ if issubclass(attribute.dtype, np.ndarray):
+ return attribute.validate(np.array(value, dtype=array_dtype_of(attribute.meta)))
+
+ return attribute.validate(value)
diff --git a/src/fastcs/transports/tango/dsr.py b/src/fastcs/transports/tango/dsr.py
index 553fe3d92..bfbe22f2f 100644
--- a/src/fastcs/transports/tango/dsr.py
+++ b/src/fastcs/transports/tango/dsr.py
@@ -30,7 +30,7 @@ def _wrap_updater_fget(
) -> Callable[[Any], Any]:
async def fget(tango_device: Device):
tango_device.info_stream(f"called fget method: {attr_name}")
- return cast_to_tango_type(attribute.datatype, attribute.readback)
+ return cast_to_tango_type(attribute, attribute.readback)
return fget
@@ -54,7 +54,7 @@ def _wrap_updater_fset(
) -> Callable[[Any, Any], Any]:
async def fset(tango_device: Device, value):
tango_device.info_stream(f"called fset method: {attr_name}")
- coro = attribute.set(cast_from_tango_type(attribute.datatype, value))
+ coro = attribute.set(cast_from_tango_type(attribute, value))
await _run_threadsafe_blocking(coro, loop)
return fset
@@ -84,7 +84,7 @@ def _collect_dev_attributes(
),
access=AttrWriteType.READ_WRITE,
**get_server_metadata_from_attribute(attribute),
- **get_server_metadata_from_datatype(attribute.datatype),
+ **get_server_metadata_from_datatype(attribute),
)
case AttrR():
collection[d_attr_name] = server.attribute(
@@ -92,7 +92,7 @@ def _collect_dev_attributes(
access=AttrWriteType.READ,
fget=_wrap_updater_fget(attr_name, attribute, controller_api),
**get_server_metadata_from_attribute(attribute),
- **get_server_metadata_from_datatype(attribute.datatype),
+ **get_server_metadata_from_datatype(attribute),
)
case AttrW():
collection[d_attr_name] = server.attribute(
@@ -102,7 +102,7 @@ def _collect_dev_attributes(
attr_name, attribute, controller_api, loop
),
**get_server_metadata_from_attribute(attribute),
- **get_server_metadata_from_datatype(attribute.datatype),
+ **get_server_metadata_from_datatype(attribute),
)
return collection
diff --git a/src/fastcs/transports/tango/util.py b/src/fastcs/transports/tango/util.py
index 9a82f264c..f1bd24348 100644
--- a/src/fastcs/transports/tango/util.py
+++ b/src/fastcs/transports/tango/util.py
@@ -1,24 +1,20 @@
+import enum
import re
-from dataclasses import asdict
-from typing import Any
+from typing import Any, cast
+import numpy as np
from tango import AttrDataFormat
from fastcs.attributes import Attribute
from fastcs.datatypes import (
- Bool,
- DataType,
+ DEFAULT_ARRAY_SHAPE,
+ DEFAULT_PRECISION,
DType,
DType_T,
- Enum,
- Float,
- Int,
- String,
- Waveform,
+ NumericLimits,
+ array_dtype_of,
)
-TANGO_ALLOWED_DATATYPES = (Bool, DataType, Enum, Float, Int, String, Waveform)
-
_TANGO_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
@@ -55,15 +51,6 @@ def tango_dev_name(id: str, dsr_instance: str) -> str:
return f"{id}/{tango_dev_class_name(id)}/{dsr_instance}"
-DATATYPE_FIELD_TO_SERVER_FIELD = {
- "units": "unit",
- "min": "min_value",
- "max": "max_value",
- "min_alarm": "min_alarm",
- "max_alarm": "min_alarm",
-}
-
-
def get_server_metadata_from_attribute(
attribute: Attribute[DType],
) -> dict[str, Any]:
@@ -73,33 +60,45 @@ def get_server_metadata_from_attribute(
return arguments
-def get_server_metadata_from_datatype(datatype: DataType[DType]) -> dict[str, str]:
- """Gets the metadata for a Tango field from a FastCS datatype."""
- arguments = {
- DATATYPE_FIELD_TO_SERVER_FIELD[field]: value
- for field, value in asdict(datatype).items()
- if field in DATATYPE_FIELD_TO_SERVER_FIELD
+def _limit_arguments(limits: NumericLimits | None) -> dict[str, Any]:
+ if limits is None:
+ return {}
+
+ return {
+ "min_value": limits.control.low,
+ "max_value": limits.control.high,
+ "min_alarm": limits.alarm.low,
+ "max_alarm": limits.alarm.high,
+ "min_warning": limits.warning.low,
+ "max_warning": limits.warning.high,
}
- dtype = datatype.dtype
-
- match datatype:
- case Waveform():
- dtype = datatype.array_dtype
- match len(datatype.shape):
- case 1:
- arguments["max_dim_x"] = datatype.shape[0]
- arguments["dformat"] = AttrDataFormat.SPECTRUM
- case 2:
- arguments["max_dim_x"], arguments["max_dim_y"] = datatype.shape
- arguments["dformat"] = AttrDataFormat.IMAGE
- case _:
- raise TypeError(
- f"Unsupported shape {datatype.shape}, Tango supports up "
- "to 2D arrays"
- )
- case Float():
- arguments["format"] = f"%.{datatype.prec}"
+
+def get_server_metadata_from_datatype(attribute: Attribute[DType]) -> dict[str, Any]:
+ """Gets the metadata for a Tango field from an attribute's datatype."""
+ meta = attribute.meta
+ dtype: Any = attribute.dtype
+
+ arguments: dict[str, Any] = {"unit": meta.get("units")}
+ arguments.update(_limit_arguments(meta.get("limits")))
+
+ if issubclass(attribute.dtype, np.ndarray):
+ dtype = array_dtype_of(meta)
+ shape = meta.get("shape", DEFAULT_ARRAY_SHAPE)
+ match len(shape):
+ case 1:
+ arguments["max_dim_x"] = shape[0]
+ arguments["dformat"] = AttrDataFormat.SPECTRUM
+ case 2:
+ arguments["max_dim_x"] = shape[0]
+ arguments["max_dim_y"] = shape[1]
+ arguments["dformat"] = AttrDataFormat.IMAGE
+ case _:
+ raise TypeError(
+ f"Unsupported shape {shape}, Tango supports up to 2D arrays"
+ )
+ elif attribute.dtype is float:
+ arguments["format"] = f"%.{meta.get('precision', DEFAULT_PRECISION)}"
arguments["dtype"] = dtype
for argument, value in arguments.items():
@@ -109,24 +108,19 @@ def get_server_metadata_from_datatype(datatype: DataType[DType]) -> dict[str, st
return arguments
-def cast_to_tango_type(datatype: DataType[DType_T], value: DType_T) -> object:
+def cast_to_tango_type(attribute: Attribute[DType_T], value: DType_T) -> object:
"""Casts a value from FastCS to tango datatype."""
- match datatype:
- case Enum():
- return datatype.index_of(datatype.validate(value))
- case datatype if issubclass(type(datatype), TANGO_ALLOWED_DATATYPES):
- return datatype.validate(value)
- case _:
- raise ValueError(f"Unsupported datatype {datatype}")
+ if issubclass(attribute.dtype, enum.Enum):
+ member = cast(enum.Enum, attribute.validate(value))
+ return list(attribute.dtype).index(member)
+
+ return attribute.validate(value)
-def cast_from_tango_type(datatype: DataType[DType_T], value: object) -> DType_T:
+def cast_from_tango_type(attribute: Attribute[DType_T], value: object) -> DType_T:
"""Casts a value from tango to FastCS datatype."""
- match datatype:
- case Enum():
- assert isinstance(value, int), "Got non-integer value for Enum"
- return datatype.validate(datatype.members[value])
- case datatype if issubclass(type(datatype), TANGO_ALLOWED_DATATYPES):
- return datatype.validate(value) # type: ignore
- case _:
- raise ValueError(f"Unsupported datatype {datatype}")
+ if issubclass(attribute.dtype, enum.Enum):
+ assert isinstance(value, int), "Got non-integer value for Enum"
+ return attribute.validate(list(attribute.dtype)[value])
+
+ return attribute.validate(value)
diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py
index d429abd7a..8299bff91 100644
--- a/tests/assertable_controller.py
+++ b/tests/assertable_controller.py
@@ -6,14 +6,13 @@
from fastcs.attributes import AttrR
from fastcs.controllers import Controller, ControllerAPI
-from fastcs.datatypes import Int
from fastcs.methods import command, scan
class TestSubController(Controller):
def __init__(self) -> None:
super().__init__()
- self.read_int = AttrR(Int())
+ self.read_int = AttrR(int)
class MyTestController(Controller):
diff --git a/tests/benchmarking/controller.py b/tests/benchmarking/controller.py
index fc2d187e9..19932655b 100644
--- a/tests/benchmarking/controller.py
+++ b/tests/benchmarking/controller.py
@@ -3,7 +3,6 @@
from fastcs import FastCS
from fastcs.attributes import AttrR, AttrW
from fastcs.controllers import Controller
-from fastcs.datatypes import Bool, Int
from fastcs.transports.epics.ca.transport import EpicsCATransport
from fastcs.transports.rest.options import RestServerOptions
from fastcs.transports.rest.transport import RestTransport
@@ -11,8 +10,8 @@
class MyTestController(Controller):
- read_int: AttrR = AttrR(Int(), initial_value=0)
- write_bool: AttrW = AttrW(Bool())
+ read_int: AttrR = AttrR(int, initial_value=0)
+ write_bool: AttrW = AttrW(bool)
def run():
diff --git a/tests/conftest.py b/tests/conftest.py
index 9c6563dfe..937526e78 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -19,7 +19,6 @@
from softioc import builder
from fastcs.attributes import AttrR, AttrRW, AttrW
-from fastcs.datatypes import Bool, Float, Int, String
from fastcs.logging import configure_logging, logger
from fastcs.logging._logging import LogLevel
from fastcs.transports.tango.dsr import FASTCS_TANGO_SERVER_NAME, register_dev
@@ -42,12 +41,12 @@ def clear_softioc_records():
class BackendTestController(MyTestController):
- read_int: AttrR = AttrR(Int())
- read_write_int: AttrRW = AttrRW(Int())
- read_write_float: AttrRW = AttrRW(Float())
- read_bool: AttrR = AttrR(Bool())
- write_bool: AttrW = AttrW(Bool())
- read_string: AttrRW = AttrRW(String())
+ read_int: AttrR = AttrR(int)
+ read_write_int: AttrRW = AttrRW(int)
+ read_write_float: AttrRW = AttrRW(float)
+ read_bool: AttrR = AttrR(bool)
+ write_bool: AttrW = AttrW(bool)
+ read_string: AttrRW = AttrRW(str)
@pytest.fixture
diff --git a/tests/demo/test_eiger.py b/tests/demo/test_eiger.py
index df35f758d..2b518c8bb 100644
--- a/tests/demo/test_eiger.py
+++ b/tests/demo/test_eiger.py
@@ -6,7 +6,6 @@
import pytest_asyncio
from fastcs.attributes import AttrR, AttrRW
-from fastcs.datatypes import Enum
from fastcs.demo.eiger import UPDATE_PERIOD, EigerDetector
from fastcs.demo.simulation.eiger import EigerParameter, create_eiger_sim_app
from fastcs.util import ONCE
@@ -39,13 +38,17 @@ async def sim(_eiger) -> SimState:
@pytest.mark.asyncio
async def test_hinted_attributes_are_introspected(detector: EigerDetector):
assert isinstance(detector.count_time, AttrRW)
- assert detector.count_time.datatype.dtype is float
+ assert detector.count_time.dtype is float
assert isinstance(detector.state, AttrR)
# ``state`` reports ``allowed_values``, so it is introspected as an enum whose
# members come from the device rather than as a bare string.
- assert isinstance(detector.state.datatype, Enum)
- assert detector.state.datatype.names == ["idle", "ready", "acquire"]
+ assert issubclass(detector.state.dtype, enum.Enum)
+ assert [member.name for member in detector.state.dtype] == [
+ "idle",
+ "ready",
+ "acquire",
+ ]
@pytest.mark.asyncio
diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py
index ce83fe43f..83d36e851 100644
--- a/tests/example_p4p_ioc.py
+++ b/tests/example_p4p_ioc.py
@@ -5,7 +5,7 @@
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controllers import Controller, ControllerVector
-from fastcs.datatypes import Bool, Enum, Float, Int, Table, Waveform
+from fastcs.datatypes import Array1D, Limits, NumericLimits, Table
from fastcs.launch import FastCS
from fastcs.methods import command, scan
from fastcs.transports.epics.pva import EpicsPVATransport
@@ -21,17 +21,23 @@ class FEnum(enum.Enum):
class ParentController(Controller):
description = "some controller"
- a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000))
- b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5))
+ a: AttrRW = AttrRW(
+ int,
+ limits=NumericLimits(control=Limits(high=400_000), alarm=Limits(high=40_000)),
+ )
+ b: AttrW = AttrW(
+ float, limits=NumericLimits(control=Limits(low=-1), alarm=Limits(low=-0.5))
+ )
table: AttrRW = AttrRW(
- Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]),
+ Table,
+ structured_dtype=[("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)],
)
class ChildController(Controller):
fail_on_next_e = True
- c: AttrW = AttrW(Int())
+ c: AttrW = AttrW(int)
def __init__(self, description: str | None = None):
super().__init__(description=description)
@@ -41,7 +47,7 @@ def __init__(self, description: str | None = None):
# returns what it accepted, which becomes both the readback and the
# setpoint; the getter seeds the setpoint when the controller connects.
self._clamped = 5
- self.clamped = AttrRW(Int(), getter=self.get_clamped, setter=self.set_clamped)
+ self.clamped = AttrRW(int, getter=self.get_clamped, setter=self.set_clamped)
async def get_clamped(self) -> int:
return self._clamped
@@ -57,15 +63,15 @@ async def d(self):
print("D: FINISHED")
await self.j.update(self.j.readback + 1)
- e: AttrR = AttrR(Bool())
+ e: AttrR = AttrR(bool)
@scan(1)
async def flip_flop(self):
await self.e.update(not self.e.readback)
- f: AttrRW = AttrRW(Enum(FEnum))
- g: AttrRW = AttrRW(Waveform(np.int64, shape=(3,)))
- h: AttrRW = AttrRW(Waveform(np.float64, shape=(3, 3)))
+ f: AttrRW = AttrRW(FEnum)
+ g: AttrRW = AttrRW(Array1D[np.int64], shape=(3,))
+ h: AttrRW = AttrRW(Array1D[np.float64], shape=(3, 3))
@command()
async def i(self):
@@ -79,7 +85,7 @@ async def i(self):
print("I: FINISHED")
await self.j.update(self.j.readback + 1)
- j: AttrR = AttrR(Int())
+ j: AttrR = AttrR(int)
def run(id="P4P_TEST_DEVICE"):
@@ -88,7 +94,7 @@ def run(id="P4P_TEST_DEVICE"):
controller.set_path([id])
class ChildVector(ControllerVector):
- vector_attribute: AttrR = AttrR(Int())
+ vector_attribute: AttrR = AttrR(int)
def __init__(self, children, description=None):
super().__init__(children, description)
diff --git a/tests/example_softioc.py b/tests/example_softioc.py
index d58011d71..88da09e1e 100644
--- a/tests/example_softioc.py
+++ b/tests/example_softioc.py
@@ -3,7 +3,6 @@
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.control_system import FastCS
from fastcs.controllers import Controller, ControllerVector
-from fastcs.datatypes import Int
from fastcs.methods import command
from fastcs.transports.epics.ca.transport import (
EpicsCAOptions,
@@ -13,13 +12,13 @@
class ParentController(Controller):
- a: AttrR = AttrR(Int())
- b: AttrRW = AttrRW(Int())
+ a: AttrR = AttrR(int)
+ b: AttrRW = AttrRW(int)
def __init__(self, description: str | None = None) -> None:
super().__init__(description)
self._clamped = 5
- self.clamped = AttrRW(Int(), getter=self.get_clamped, setter=self.set_clamped)
+ self.clamped = AttrRW(int, getter=self.get_clamped, setter=self.set_clamped)
async def get_clamped(self) -> int:
return self._clamped
@@ -30,7 +29,7 @@ async def set_clamped(self, value: int) -> int:
class ChildController(Controller):
- c: AttrW = AttrW(Int())
+ c: AttrW = AttrW(int)
@command()
async def d(self):
diff --git a/tests/test_attribute_logging.py b/tests/test_attribute_logging.py
index 4f54d5d3b..851c1056b 100644
--- a/tests/test_attribute_logging.py
+++ b/tests/test_attribute_logging.py
@@ -1,13 +1,12 @@
import pytest
from fastcs.attributes import AttrR
-from fastcs.datatypes import Int
@pytest.mark.asyncio
async def test_attr_r_update_trace_logs_when_tracing_enabled(loguru_caplog):
"""log_event emits 'Attribute set' and 'Value validated' when tracing is on."""
- attr = AttrR(Int())
+ attr = AttrR(int)
attr.enable_tracing()
await attr.update(42)
@@ -19,7 +18,7 @@ async def test_attr_r_update_trace_logs_when_tracing_enabled(loguru_caplog):
@pytest.mark.asyncio
async def test_attr_r_update_no_trace_logs_when_tracing_disabled(loguru_caplog):
- attr = AttrR(Int())
+ attr = AttrR(int)
await attr.update(42)
@@ -30,7 +29,7 @@ async def test_attr_r_update_no_trace_logs_when_tracing_disabled(loguru_caplog):
@pytest.mark.asyncio
async def test_attr_r_update_logs_validation_error(loguru_caplog):
- attr = AttrR(Int())
+ attr = AttrR(int)
with pytest.raises(ValueError):
await attr.update("not_an_int") # type: ignore[arg-type]
@@ -40,7 +39,7 @@ async def test_attr_r_update_logs_validation_error(loguru_caplog):
@pytest.mark.asyncio
async def test_attr_r_update_logs_callback_failure(loguru_caplog):
- attr = AttrR(Int())
+ attr = AttrR(int)
async def failing_callback(_value: int):
raise RuntimeError("callback failed")
diff --git a/tests/test_attributes.py b/tests/test_attributes.py
index e78d5c59e..b9ef89f3e 100644
--- a/tests/test_attributes.py
+++ b/tests/test_attributes.py
@@ -1,34 +1,34 @@
import asyncio
from functools import partial
+import numpy as np
import pytest
from pytest_mock import MockerFixture
from fastcs.attributes import AttrR, AttrRW, AttrW, NotPolled, Polled, Update
from fastcs.controllers import Controller
-from fastcs.datatypes import Float, Int, String
+from fastcs.datatypes import Array1D, Limits, Meta, NumericLimits, Table
from fastcs.util import ONCE
def test_attribute_access_mode():
"""Test that attributes have the correct access_mode property."""
- attr_r = AttrR(String())
+ attr_r = AttrR(str)
assert attr_r.access_mode == "r"
- attr_w = AttrW(String())
+ attr_w = AttrW(str)
assert attr_w.access_mode == "w"
- attr_rw = AttrRW(String())
+ attr_rw = AttrRW(str)
assert attr_rw.access_mode == "rw"
def test_attr_r():
- attr = AttrR(String(), group="test group")
+ attr = AttrR(str, group="test group")
assert not attr.has_getter()
assert attr.poll_period is None
- assert isinstance(attr.datatype, String)
- assert attr.dtype == str
+ assert attr.dtype is str
assert attr.group == "test group"
assert attr.name == ""
assert attr.path == []
@@ -52,7 +52,7 @@ async def get_value() -> float:
return 1.5
attr = AttrR(getter=get_value)
- assert isinstance(attr.datatype, Float)
+ assert attr.dtype is float
def test_datatype_inferred_from_setter_annotation():
@@ -60,7 +60,7 @@ async def set_value(value: int) -> None:
pass
attr = AttrW(setter=set_value)
- assert isinstance(attr.datatype, Int)
+ assert attr.dtype is int
def test_datatype_required_when_not_inferable():
@@ -78,7 +78,7 @@ def test_datatype_required_when_not_inferable():
@pytest.mark.asyncio
async def test_attr_update():
- attr = AttrRW(Int())
+ attr = AttrRW(int)
await attr.update(42)
assert attr.readback == 42
@@ -99,7 +99,7 @@ async def test_poll():
async def do_update():
return 5
- attr = AttrR(Int(), getter=do_update)
+ attr = AttrR(int, getter=do_update)
assert attr.has_getter()
value = await attr.poll()
@@ -112,7 +112,7 @@ async def test_poll_unwraps_update_wrapper():
async def do_update():
return Update(9, timestamp=123.0)
- attr = AttrR(Int(), getter=do_update)
+ attr = AttrR(int, getter=do_update)
value = await attr.poll()
assert value == 9
assert attr.readback == 9
@@ -120,7 +120,7 @@ async def do_update():
@pytest.mark.asyncio
async def test_poll_with_no_getter_raises():
- attr = AttrR(Int())
+ attr = AttrR(int)
with pytest.raises(RuntimeError, match="has no getter"):
await attr.poll()
@@ -131,7 +131,7 @@ async def test_poll_exception_propagates():
async def do_update():
raise ValueError("do_update failed")
- attr = AttrR(Int(), getter=do_update)
+ attr = AttrR(int, getter=do_update)
with pytest.raises(ValueError, match="do_update failed"):
await attr.poll()
@@ -142,25 +142,25 @@ async def do_update():
return 1
# A bare getter is read once, when the controller connects.
- attr = AttrR(Int(), getter=do_update)
+ attr = AttrR(int, getter=do_update)
assert attr.poll_period == ONCE
# Wrapping it in Polled schedules it instead.
- attr_explicit = AttrR(Int(), getter=Polled(do_update, period=0.5))
+ attr_explicit = AttrR(int, getter=Polled(do_update, period=0.5))
assert attr_explicit.poll_period == 0.5
# NotPolled is never scheduled - on-demand poll() only.
- attr_on_demand = AttrR(Int(), getter=NotPolled(do_update))
+ attr_on_demand = AttrR(int, getter=NotPolled(do_update))
assert attr_on_demand.poll_period is None
assert attr_on_demand.has_getter()
- attr_no_getter = AttrR(Int())
+ attr_no_getter = AttrR(int)
assert attr_no_getter.poll_period is None
@pytest.mark.asyncio
async def test_wait_for_predicate(mocker: MockerFixture):
- attr = AttrR(Int(), initial_value=0)
+ attr = AttrR(int, initial_value=0)
async def update(attr: AttrR):
while True:
@@ -188,7 +188,7 @@ def predicate(v: int) -> bool:
@pytest.mark.asyncio
async def test_wait_for_value(mocker: MockerFixture):
- attr = AttrR(Int(), initial_value=0)
+ attr = AttrR(int, initial_value=0)
async def update(attr: AttrR):
await asyncio.sleep(0.5)
@@ -222,7 +222,7 @@ async def send(value, key):
device[key] = value
return value # accepted value echoes straight back to the readback
- attr_r = AttrR(String())
+ attr_r = AttrR(str)
attr_r.add_readback_callback(partial(update_ui, key="state"), always=False)
await attr_r.update(device["state"])
assert ui["state"] == "Idle"
@@ -232,7 +232,7 @@ async def send(value, key):
# Identical update does not trigger callback as always=False
assert ui["update_count"] == 1
- attr_rw = AttrRW(Int(), setter=partial(send, key="number"))
+ attr_rw = AttrRW(int, setter=partial(send, key="number"))
attr_rw.add_readback_callback(partial(update_ui, key="number"))
await attr_rw.set(2)
assert device["number"] == 2
@@ -242,7 +242,7 @@ async def send(value, key):
@pytest.mark.asyncio
async def test_soft_attribute_self_wires():
"""With no getter/setter, AttrRW.set() pushes straight to readback."""
- attr = AttrRW(Int())
+ attr = AttrRW(int)
assert not attr.has_getter()
assert not attr.has_setter()
@@ -259,7 +259,7 @@ async def setter(value):
accepted["value"] = value
return value + 1 # device clamps/accepts a different value
- attr = AttrRW(Int(), setter=setter)
+ attr = AttrRW(int, setter=setter)
await attr.set(10)
assert accepted["value"] == 10
@@ -272,7 +272,7 @@ async def test_setter_with_no_return_leaves_readback_untouched():
async def setter(value):
return None
- attr = AttrRW(Int(), setter=setter)
+ attr = AttrRW(int, setter=setter)
await attr.set(5)
assert attr.setpoint == 5
@@ -284,7 +284,7 @@ async def test_attrw_setter_return_value_updates_setpoint_cache():
async def setter(value):
return value + 1
- attr = AttrW(Int(), setter=setter)
+ attr = AttrW(int, setter=setter)
await attr.set(5)
assert attr.setpoint == 6
@@ -295,7 +295,7 @@ async def test_set_setter_exception_is_caught_and_logged(mocker: MockerFixture):
async def do_set(value):
raise ValueError("do_set failed")
- attr = AttrW(Int(), setter=do_set)
+ attr = AttrW(int, setter=do_set)
mock_logger = mocker.patch("fastcs.attributes.attr_w.logger")
# exception is caught, not raised, and the setpoint is still cached
@@ -386,7 +386,7 @@ class DemoParameterController(Controller):
async def initialise(self):
self._connection = DummyConnection()
await self._connection.connect()
- dtype_mapping = {"int": Int, "float": Float}
+ dtype_mapping = {"int": int, "float": float}
example_introspection_response = await self._connection.get(
"config/introspect_api"
)
@@ -396,9 +396,12 @@ async def initialise(self):
ro = parameter_response["read_only"]
name = parameter_response["name"]
uri = f"{parameter_response['subsystem']}/{name}"
- datatype = dtype_mapping[parameter_response["dtype"]](
- min=parameter_response.get("min", None),
- max=parameter_response.get("max", None),
+ datatype = dtype_mapping[parameter_response["dtype"]]
+ limits = NumericLimits(
+ control=Limits(
+ low=parameter_response.get("min", None),
+ high=parameter_response.get("max", None),
+ )
)
async def getter(uri=uri) -> int | float:
@@ -409,6 +412,7 @@ async def getter(uri=uri) -> int | float:
datatype,
getter=getter,
initial_value=parameter_response.get("value", None),
+ limits=limits,
)
else:
@@ -421,6 +425,7 @@ async def setter(value, uri=uri):
getter=getter,
setter=setter,
initial_value=parameter_response.get("value", None),
+ limits=limits,
)
self.add_attribute(name, attr)
@@ -439,3 +444,91 @@ async def setter(value, uri=uri):
await c.int_parameter.set(20)
assert c.int_parameter.readback == 20
+
+
+def test_metadata_is_held_on_the_attribute():
+ attr = AttrRW(float, precision=3, units="degC", description="the temperature")
+
+ assert attr.dtype is float
+ assert attr.meta == {
+ "precision": 3,
+ "units": "degC",
+ "description": "the temperature",
+ }
+ assert attr.description == "the temperature"
+
+
+def test_metadata_the_datatype_has_no_use_for_is_rejected():
+ # Also a static error - the constructor overloads unpack StrMeta for a str
+ # attribute - but the runtime check is what catches metadata arriving from a
+ # source the type checker never saw.
+ with pytest.raises(TypeError, match="'precision' is not valid metadata for str"):
+ AttrR(str, precision=3) # pyright: ignore[reportCallIssue, reportArgumentType]
+
+
+def test_metadata_is_validated_when_replaced():
+ attr = AttrR(int, units="counts")
+
+ attr.update_meta(Meta(units="mm"))
+ assert attr.meta == {"units": "mm"}
+
+ with pytest.raises(TypeError, match="'length' is not valid metadata for int"):
+ attr.update_meta(Meta(length=4))
+
+
+def test_update_meta_notifies_callbacks():
+ attr = AttrR(int)
+ seen: list[Meta] = []
+ attr.add_update_meta_callback(seen.append)
+
+ attr.update_meta(Meta(units="mm"))
+
+ assert seen == [{"units": "mm"}]
+
+
+def test_array_element_type_comes_from_the_datatype():
+ attr = AttrR(Array1D[np.int32], shape=(4,))
+
+ assert attr.dtype is np.ndarray
+ assert attr.meta.get("array_dtype") is np.int32
+ assert np.array_equal(attr.readback, np.zeros(4, dtype=np.int32))
+
+
+def test_array_element_type_is_not_given_twice():
+ with pytest.raises(TypeError, match="already given by the datatype subscript"):
+ AttrR(Array1D[np.int32], array_dtype=np.int64)
+
+
+def test_a_table_needs_its_columns():
+ with pytest.raises(TypeError, match="Table attribute needs its columns"):
+ AttrR(Table)
+
+
+def test_structured_dtype_needs_a_table_datatype():
+ with pytest.raises(TypeError, match="only valid for a Table attribute"):
+ # Statically an error too: structured_dtype belongs to TableMeta.
+ AttrR( # pyright: ignore[reportCallIssue]
+ Array1D[np.int32], # pyright: ignore[reportArgumentType]
+ structured_dtype=[("a", np.int32)],
+ )
+
+
+@pytest.mark.asyncio
+async def test_control_limits_reject_out_of_range_values():
+ attr = AttrRW(int, limits=NumericLimits(control=Limits(0, 10)))
+
+ await attr.set(5)
+ assert attr.readback == 5
+
+ with pytest.raises(ValueError, match="greater than maximum 10"):
+ await attr.update(15)
+
+
+@pytest.mark.asyncio
+async def test_display_limits_do_not_reject_values():
+ """Only the control range constrains a write; the rest are served, not enforced."""
+ attr = AttrR(float, limits=NumericLimits(alarm=Limits(0.0, 10.0)))
+
+ await attr.update(15.0)
+
+ assert attr.readback == 15.0
diff --git a/tests/test_control_system.py b/tests/test_control_system.py
index 66b78b192..77b48e20a 100644
--- a/tests/test_control_system.py
+++ b/tests/test_control_system.py
@@ -5,7 +5,6 @@
from fastcs.attributes import AttrR, NotPolled, Polled
from fastcs.control_system import FastCS
from fastcs.controllers import Controller
-from fastcs.datatypes import Int
from fastcs.methods import Command, command
from fastcs.util import ONCE
@@ -75,9 +74,9 @@ async def get_never():
class MyController(Controller):
def __init__(self):
super().__init__()
- self.update_once = AttrR(Int(), getter=Polled(get_once, period=ONCE))
- self.update_quickly = AttrR(Int(), getter=Polled(get_quickly, period=0.1))
- self.update_never = AttrR(Int(), getter=NotPolled(get_never))
+ self.update_once = AttrR(int, getter=Polled(get_once, period=ONCE))
+ self.update_quickly = AttrR(int, getter=Polled(get_quickly, period=0.1))
+ self.update_never = AttrR(int, getter=NotPolled(get_never))
controller = MyController()
loop = asyncio.get_event_loop()
diff --git a/tests/test_controllers.py b/tests/test_controllers.py
index 5d5dfb4ed..c6d2a9ee9 100644
--- a/tests/test_controllers.py
+++ b/tests/test_controllers.py
@@ -5,7 +5,6 @@
from fastcs.attributes import AttrR, AttrRW
from fastcs.controllers import Controller, ControllerVector
-from fastcs.datatypes import Enum, Float, Int
from fastcs.methods import Command, Scan, command, scan
@@ -33,22 +32,22 @@ class SomeSubController(Controller):
def __init__(self):
super().__init__()
- sub_attribute = AttrR(Int())
+ sub_attribute = AttrR(int)
- root_attribute = AttrR(Int())
+ root_attribute = AttrR(int)
class SomeController(Controller):
annotated_attr_not_defined_in_init: AttrR[int]
- equal_attr = AttrR(Int())
- annotated_and_equal_attr: AttrR[int] = AttrR(Int())
+ equal_attr = AttrR(int)
+ annotated_and_equal_attr: AttrR[int] = AttrR(int)
def __init__(self, sub_controller: Controller):
super().__init__()
- self.attr_on_object = AttrR(Int())
+ self.attr_on_object = AttrR(int)
- self.attributes["_attributes_attr"] = AttrR(Int())
+ self.attributes["_attributes_attr"] = AttrR(int)
self.attributes["_attributes_attr_equal"] = self.equal_attr
self.sub_controller = sub_controller
@@ -85,13 +84,13 @@ async def noop() -> None:
@pytest.mark.parametrize(
"member_name, member_value, expected_error",
[
- ("attr", AttrR(Float()), r"Cannot add attribute"),
+ ("attr", AttrR(float), r"Cannot add attribute"),
("attr", Controller(), r"Cannot add sub controller"),
("attr", Command(noop), r"Cannot add command"),
- ("sub_controller", AttrR(Int()), r"Cannot add attribute"),
+ ("sub_controller", AttrR(int), r"Cannot add attribute"),
("sub_controller", Controller(), r"Cannot add sub controller"),
("sub_controller", Command(noop), r"Cannot add command"),
- ("cmd", AttrR(Int()), r"Cannot add attribute"),
+ ("cmd", AttrR(int), r"Cannot add attribute"),
("cmd", Controller(), r"Cannot add sub controller"),
("cmd", Command(noop), r"Cannot add command"),
],
@@ -100,7 +99,7 @@ def test_conflicting_attributes_and_controllers_and_commands(
member_name, member_value, expected_error
):
class ConflictingController(Controller):
- attr = AttrR(Int())
+ attr = AttrR(int)
cmd = Command(noop)
def __init__(self):
@@ -163,10 +162,10 @@ class HintedController(Controller):
controller = HintedController()
with pytest.raises(RuntimeError, match="does not match defined datatype"):
- controller.add_attribute("read_write_int", AttrRW(Float()))
+ controller.add_attribute("read_write_int", AttrRW(float))
with pytest.raises(RuntimeError, match="does not match defined access mode"):
- controller.add_attribute("read_write_int", AttrR(Int()))
+ controller.add_attribute("read_write_int", AttrR(int))
with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"):
controller.read_write_int = 5 # type: ignore
@@ -175,7 +174,7 @@ class HintedController(Controller):
with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"):
controller._validate_type_hints()
- controller.add_attribute("read_write_int", AttrRW(Int()))
+ controller.add_attribute("read_write_int", AttrRW(int))
def test_enum_attribute_hint_validation():
@@ -191,9 +190,9 @@ class HintedController(Controller):
controller = HintedController()
with pytest.raises(RuntimeError, match="does not match defined datatype"):
- controller.add_attribute("enum", AttrRW(Enum(BadEnum)))
+ controller.add_attribute("enum", AttrRW(BadEnum))
- controller.add_attribute("enum", AttrRW(Enum(GoodEnum)))
+ controller.add_attribute("enum", AttrRW(GoodEnum))
@pytest.mark.asyncio
@@ -233,12 +232,12 @@ class HintedController(Controller):
def test_controller_api():
class MyTestController(Controller):
- attr1: AttrRW[int] = AttrRW(Int())
+ attr1: AttrRW[int] = AttrRW(int)
def __init__(self):
super().__init__(description="Controller for testing")
- self.attr2 = AttrRW(Int())
+ self.attr2 = AttrRW(int)
@command()
async def do_nothing(self):
diff --git a/tests/test_datatypes.py b/tests/test_datatypes.py
index b0b26d562..f04bfe955 100644
--- a/tests/test_datatypes.py
+++ b/tests/test_datatypes.py
@@ -1,115 +1,196 @@
-from enum import IntEnum
+from enum import Enum, IntEnum
import numpy as np
import pytest
-from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, Table, Waveform
-from fastcs.datatypes._util import numpy_to_fastcs_datatype
-
+from fastcs.datatypes import (
+ Array1D,
+ Limits,
+ Meta,
+ NumericLimits,
+ Table,
+ default_value,
+ numpy_to_python_type,
+ resolve_datatype,
+ validate_meta,
+ validate_value,
+ values_equal,
+)
-def test_base_validate():
- class TestInt(DataType[int]):
- @property
- def dtype(self) -> type[int]:
- return int
+def test_coerces_to_the_datatype():
class MyIntEnum(IntEnum):
A = 0
B = 1
- test_int = TestInt()
-
- assert test_int.validate("0") == 0
- assert test_int.validate(MyIntEnum.B) == 1
+ assert validate_value(int, Meta(), "0") == 0
+ assert validate_value(int, Meta(), MyIntEnum.B) == 1
with pytest.raises(ValueError, match="Failed to cast"):
- test_int.validate("foo")
+ validate_value(int, Meta(), "foo")
@pytest.mark.parametrize(
- ["datatype", "init_args", "value"],
+ ["dtype", "meta", "value"],
[
- (Int, {"min": 1}, 0),
- (Int, {"max": -1}, 0),
- (Float, {"min": 1}, 0.0),
- (Float, {"max": -1}, 0.0),
- (Enum, {"enum_cls": int}, 0),
- (Waveform, {"array_dtype": "uint64", "shape": (1, 1)}, np.ndarray([1])),
+ (int, Meta(limits=NumericLimits(control=Limits(low=1))), 0),
+ (int, Meta(limits=NumericLimits(control=Limits(high=-1))), 0),
+ (float, Meta(limits=NumericLimits(control=Limits(low=1))), 0.0),
+ (float, Meta(limits=NumericLimits(control=Limits(high=-1))), 0.0),
+ (
+ np.ndarray,
+ Meta(array_dtype="uint64", shape=(1, 1)),
+ np.ndarray([1]),
+ ),
],
)
-def test_validate(datatype, init_args, value):
+def test_rejects_values_outside_the_metadata(dtype, meta, value):
with pytest.raises(ValueError):
- datatype(**init_args).validate(value)
+ validate_value(dtype, meta, value)
+
+
+def test_control_limits_default_to_the_display_range():
+ limits = NumericLimits(display=Limits(0.0, 10.0))
+
+ assert limits.control == Limits(0.0, 10.0)
+ with pytest.raises(ValueError, match="less than minimum"):
+ validate_value(float, Meta(limits=limits), -1.0)
+
+
+def test_warning_limits_default_to_the_alarm_range():
+ assert NumericLimits(alarm=Limits(0, 10)).warning == Limits(0, 10)
+
+
+def test_warning_limits_must_lie_within_the_alarm_range():
+ with pytest.raises(ValueError, match="not within alarm limits"):
+ NumericLimits(alarm=Limits(0, 10), warning=Limits(-1, 11))
@pytest.mark.parametrize(
- "numpy_type, fastcs_datatype",
+ "numpy_type, python_type",
[
- (np.float16, Float()),
- (np.float32, Float()),
- (np.int16, Int()),
- (np.int32, Int()),
- (np.bool, Bool()),
- (np.dtype("S1000"), String()),
- (np.dtype("U25"), String()),
- (np.dtype(">i4"), Int()),
- (np.dtype("d"), Float()),
+ (np.float16, float),
+ (np.float32, float),
+ (np.int16, int),
+ (np.int32, int),
+ (np.bool, bool),
+ (np.dtype("S1000"), str),
+ (np.dtype("U25"), str),
+ (np.dtype(">i4"), int),
+ (np.dtype("d"), float),
],
)
-def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype):
- assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type)
+def test_numpy_to_python_type(numpy_type, python_type):
+ assert numpy_to_python_type(numpy_type) is python_type
+
+
+_TABLE_META = Meta(
+ structured_dtype=[("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))]
+)
@pytest.mark.parametrize(
- "fastcs_datatype, value1, value2, expected",
+ "dtype, value1, value2, expected",
[
- (Int(), 1, 1, True),
- (Int(), 1, 2, False),
- (Float(), 1.0, 1.0, True),
- (Float(), 1.0, 2.0, False),
- (Bool(), True, True, True),
- (Bool(), True, False, False),
- (String(), "foo", "foo", True),
- (String(), "foo", "bar", False),
- (Waveform(np.int16), np.array([1]), np.array([1]), True),
- (Waveform(np.int16), np.array([1]), np.array([2]), False),
+ (int, 1, 1, True),
+ (int, 1, 2, False),
+ (float, 1.0, 1.0, True),
+ (float, 1.0, 2.0, False),
+ (bool, True, True, True),
+ (bool, True, False, False),
+ (str, "foo", "foo", True),
+ (str, "foo", "bar", False),
+ (np.ndarray, np.array([1]), np.array([1]), True),
+ (np.ndarray, np.array([1]), np.array([2]), False),
(
- Table([("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))]),
+ np.ndarray,
np.array([1, True, "foo"]),
np.array([1, True, "foo"]),
True,
),
(
- Table([("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))]),
+ np.ndarray,
np.array([1, True, "foo"]),
np.array([2, False, "bar"]),
False,
),
],
)
-def test_dataset_equal(fastcs_datatype: DataType, value1, value2, expected):
- assert fastcs_datatype.equal(value1, value2) is expected
+def test_values_equal(dtype, value1, value2, expected):
+ assert values_equal(dtype, value1, value2) is expected
+
+
+def test_string_length():
+ assert validate_value(str, Meta(length=10), "12345678901") == "1234567890"
+ assert validate_value(str, Meta(), "12345678901") == "12345678901"
+
+ with pytest.raises(ValueError, match="String length must be >= 1"):
+ validate_meta(str, Meta(length=0))
+
+
+def test_float_is_rounded_to_its_precision():
+ assert validate_value(float, Meta(precision=3), 1.23456) == 1.235
+ assert validate_value(float, Meta(), 1.23456) == 1.23
+
+
+@pytest.mark.parametrize(
+ "spelling, dtype, element_type",
+ [
+ (int, int, None),
+ (float, float, None),
+ (bool, bool, None),
+ (str, str, None),
+ (Array1D[np.int32], np.ndarray, np.int32),
+ (np.ndarray, np.ndarray, None),
+ (Table, np.ndarray, None),
+ ],
+)
+def test_resolve_datatype(spelling, dtype, element_type):
+ assert resolve_datatype(spelling) == (dtype, element_type)
+
+
+def test_resolve_datatype_takes_an_enum_class():
+ class Colour(Enum):
+ RED = "red"
+
+ assert resolve_datatype(Colour) == (Colour, None)
+
+
+@pytest.mark.parametrize("spelling", ["float", 3, list[int]])
+def test_resolve_datatype_rejects_unsupported_spellings(spelling):
+ with pytest.raises(TypeError):
+ resolve_datatype(spelling)
@pytest.mark.parametrize(
- "fastcs_datatype, values, expected",
+ "dtype, meta, expected",
[
- (Int(), [1, 1], True),
- (Int(), [1, 2], False),
- (Float(), [1.0, 1.0], True),
- (Float(), [1.0, 2.0], False),
- (Bool(), [True, True], True),
- (Bool(), [True, False], False),
+ (int, Meta(), 0),
+ (float, Meta(), 0.0),
+ (bool, Meta(), False),
+ (str, Meta(), ""),
],
)
-def test_dataset_all_equal(fastcs_datatype: DataType, values, expected):
- assert fastcs_datatype.all_equal(values) is expected
+def test_default_value(dtype, meta, expected):
+ assert default_value(dtype, meta) == expected
-def test_string_length():
- assert String(length=10).validate("12345678901") == "1234567890"
+def test_default_value_of_an_array():
+ assert np.array_equal(
+ default_value(np.ndarray, Meta(array_dtype=np.int32, shape=(3,))),
+ np.zeros(3, dtype=np.int32),
+ )
- assert String().validate("12345678901") == "12345678901"
- with pytest.raises(ValueError):
- String(length=0)
+def test_default_value_of_a_table():
+ assert default_value(np.ndarray, _TABLE_META).size == 0
+
+
+def test_validate_meta_rejects_fields_the_datatype_has_no_use_for():
+ with pytest.raises(TypeError, match="'precision' is not valid metadata for str"):
+ validate_meta(str, Meta(precision=3), "device_id")
+
+
+def test_an_array_needs_an_element_type():
+ with pytest.raises(TypeError, match="needs an element type"):
+ default_value(np.ndarray, Meta(shape=(3,)))
diff --git a/tests/test_launch.py b/tests/test_launch.py
index 17e935f4c..df7bdae58 100644
--- a/tests/test_launch.py
+++ b/tests/test_launch.py
@@ -13,7 +13,6 @@
from fastcs.attributes import AttrR
from fastcs.control_system import FastCS
from fastcs.controllers import Controller
-from fastcs.datatypes import Int
from fastcs.exceptions import LaunchError
from fastcs.launch import (
_build_options_model,
@@ -45,7 +44,7 @@ def __init__(self, arg):
class IsHinted(Controller):
- read = AttrR(Int())
+ read = AttrR(int)
def __init__(self, arg: SomeConfig) -> None:
super().__init__()
diff --git a/tests/test_multi_controller.py b/tests/test_multi_controller.py
index bde9b2f38..0ae223c17 100644
--- a/tests/test_multi_controller.py
+++ b/tests/test_multi_controller.py
@@ -12,7 +12,6 @@
from fastcs.attributes import AttrR
from fastcs.control_system import FastCS
from fastcs.controllers import Controller
-from fastcs.datatypes import Int
from fastcs.transports.epics import EpicsDocsOptions, EpicsGUIOptions
from fastcs.transports.epics.ca.transport import EpicsCATransport
from fastcs.transports.epics.emission import INDEX_STEM
@@ -26,11 +25,11 @@ class _IdController(Controller):
class _OneAttrController(Controller):
- foo = AttrR(Int())
+ foo = AttrR(int)
class _OtherAttrController(Controller):
- bar = AttrR(Int())
+ bar = AttrR(int)
def test_controller_api_path_uses_id():
@@ -303,7 +302,7 @@ class names, so ``DEV-1`` and ``DEV_1`` would silently override each other in
class _LifecycleController(Controller):
"""Records lifecycle hook calls for end-to-end assertions."""
- foo = AttrR(Int())
+ foo = AttrR(int)
def __init__(self):
super().__init__()
@@ -325,7 +324,7 @@ async def disconnect(self):
class _OtherLifecycleController(_LifecycleController):
- bar = AttrR(Int())
+ bar = AttrR(int)
@pytest.mark.asyncio
diff --git a/tests/transports/epics/ca/test_ca_util.py b/tests/transports/epics/ca/test_ca_util.py
index 463a17dcd..6f56d2123 100644
--- a/tests/transports/epics/ca/test_ca_util.py
+++ b/tests/transports/epics/ca/test_ca_util.py
@@ -1,9 +1,11 @@
import enum
+from typing import Any, cast
import pytest
+from fastcs.attributes import AttrR
from fastcs.controllers import ControllerAPI
-from fastcs.datatypes import Bool, Enum, Float, Int, String
+from fastcs.datatypes import Meta
from fastcs.transports.epics.ca.util import (
cast_from_epics_type,
cast_to_epics_type,
@@ -11,6 +13,18 @@
)
+def attr(datatype, **meta) -> AttrR:
+ """An attribute to cast values for, standing in for a real controller's."""
+ return AttrR(datatype, **meta)
+
+
+class UnsupportedAttribute:
+ """Stands in for an attribute of a datatype no transport knows about."""
+
+ dtype = object
+ meta: Meta = {}
+
+
class ShortEnum(enum.Enum):
NOT = 0
TOO = 1
@@ -65,74 +79,74 @@ class ShortMixedEnum(enum.Enum):
@pytest.mark.parametrize(
- "datatype,input,output",
+ "attribute,input,output",
[
- (Enum(ShortEnum), ShortEnum.TOO, 1),
+ (attr(ShortEnum), ShortEnum.TOO, 1),
# in CA, enums with too many values become epics strings
- (Enum(LongMixedEnum), LongMixedEnum.BE, "BE"), # string value
- (Enum(LongMixedEnum), LongMixedEnum.EPICS, "EPICS"), # None value
- (Enum(LongMixedEnum), LongMixedEnum.MBB, "MBB"), # int value
- (Int(), 4, 4),
- (Float(), 1.0, 1.0),
- (Bool(), True, True),
- (String(), "a" * 257, "a" * 256),
- (String(length=3), "1234", "123"),
+ (attr(LongMixedEnum), LongMixedEnum.BE, "BE"), # string value
+ (attr(LongMixedEnum), LongMixedEnum.EPICS, "EPICS"), # None value
+ (attr(LongMixedEnum), LongMixedEnum.MBB, "MBB"), # int value
+ (attr(int), 4, 4),
+ (attr(float), 1.0, 1.0),
+ (attr(bool), True, True),
+ (attr(str), "a" * 257, "a" * 256),
+ (attr(str, length=3), "1234", "123"),
# shorter enums can be represented by integers from 0-15
- (Enum(ShortMixedEnum), ShortMixedEnum.STRING_MEMBER, 0),
- (Enum(ShortMixedEnum), ShortMixedEnum.INT_MEMBER, 1),
- (Enum(ShortMixedEnum), ShortMixedEnum.NONE_MEMBER, 2),
+ (attr(ShortMixedEnum), ShortMixedEnum.STRING_MEMBER, 0),
+ (attr(ShortMixedEnum), ShortMixedEnum.INT_MEMBER, 1),
+ (attr(ShortMixedEnum), ShortMixedEnum.NONE_MEMBER, 2),
],
)
-def test_casting_to_epics(datatype, input, output):
- assert cast_to_epics_type(datatype, input) == output
+def test_casting_to_epics(attribute, input, output):
+ assert cast_to_epics_type(attribute, input) == output
@pytest.mark.parametrize(
- "datatype, input",
+ "attribute, input",
[
- # TODO cover Waveform and Table cases
- (Enum(ShortEnum), LongEnum.TOO), # wrong enum.Enum class
+ # TODO cover Array1D and Table cases
+ (attr(ShortEnum), LongEnum.TOO), # wrong enum.Enum class
],
)
-def test_cast_to_epics_validations(datatype, input):
+def test_cast_to_epics_validations(attribute, input):
with pytest.raises(ValueError):
- cast_to_epics_type(datatype, input)
+ cast_to_epics_type(attribute, input)
@pytest.mark.parametrize(
- "datatype,from_epics,result",
+ "attribute,from_epics,result",
[
# long enums backed by strings
- (Enum(LongMixedEnum), "BE", LongMixedEnum.BE), # string value
- (Enum(LongMixedEnum), "EPICS", LongMixedEnum.EPICS), # None value
- (Enum(LongMixedEnum), "MBB", LongMixedEnum.MBB), # int value
- (Int(), 4, 4),
- (Float(), 1.0, 1.0),
- (Bool(), True, True),
- (String(), "hey", "hey"),
- (Enum(ShortEnum), 2, ShortEnum.MANY),
+ (attr(LongMixedEnum), "BE", LongMixedEnum.BE), # string value
+ (attr(LongMixedEnum), "EPICS", LongMixedEnum.EPICS), # None value
+ (attr(LongMixedEnum), "MBB", LongMixedEnum.MBB), # int value
+ (attr(int), 4, 4),
+ (attr(float), 1.0, 1.0),
+ (attr(bool), True, True),
+ (attr(str), "hey", "hey"),
+ (attr(ShortEnum), 2, ShortEnum.MANY),
# short enums backed by mbbi/mbbo
- (Enum(ShortMixedEnum), 0, ShortMixedEnum.STRING_MEMBER),
- (Enum(ShortMixedEnum), 1, ShortMixedEnum.INT_MEMBER),
- (Enum(ShortMixedEnum), 2, ShortMixedEnum.NONE_MEMBER),
- (Bool(), 1, True),
- (Bool(), 0, False),
+ (attr(ShortMixedEnum), 0, ShortMixedEnum.STRING_MEMBER),
+ (attr(ShortMixedEnum), 1, ShortMixedEnum.INT_MEMBER),
+ (attr(ShortMixedEnum), 2, ShortMixedEnum.NONE_MEMBER),
+ (attr(bool), 1, True),
+ (attr(bool), 0, False),
],
)
-def test_cast_from_epics_type(datatype, from_epics, result):
- assert cast_from_epics_type(datatype, from_epics) == result
+def test_cast_from_epics_type(attribute, from_epics, result):
+ assert cast_from_epics_type(attribute, from_epics) == result
@pytest.mark.parametrize(
- "datatype, input",
+ "attribute, input",
[
- (object(), 0),
- (Bool(), 3),
+ (UnsupportedAttribute(), 0),
+ (attr(bool), 3),
],
)
-def test_cast_from_epics_validations(datatype, input):
+def test_cast_from_epics_validations(attribute, input):
with pytest.raises(ValueError):
- cast_from_epics_type(datatype, input)
+ cast_from_epics_type(cast(Any, attribute), input)
@pytest.mark.parametrize("id", ["DEVICE", "my-id", "name_1", "ABC-123_xyz"])
diff --git a/tests/transports/epics/ca/test_gui.py b/tests/transports/epics/ca/test_gui.py
index 46e000e15..b8586c469 100644
--- a/tests/transports/epics/ca/test_gui.py
+++ b/tests/transports/epics/ca/test_gui.py
@@ -22,7 +22,7 @@
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controllers import Controller, ControllerAPI
-from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform
+from fastcs.datatypes import Array1D
from fastcs.transports.epics.emission import INDEX_STEM, emit_gui_files
from fastcs.transports.epics.gui import EpicsGUI
from fastcs.transports.epics.options import EpicsGUIOptions
@@ -37,50 +37,50 @@ def test_get_pv():
@pytest.mark.parametrize(
- "datatype, widget",
+ "attribute, widget",
[
- (Bool(), LED()),
- (Int(), TextRead()),
- (Float(), TextRead()),
- (String(), TextRead(format=TextFormat.string)),
- (Enum(ColourEnum), TextRead(format=TextFormat.string)),
- (Waveform(array_dtype=np.int32), ArrayTrace(axis="x")),
+ (AttrR(bool), LED()),
+ (AttrR(int), TextRead()),
+ (AttrR(float), TextRead()),
+ (AttrR(str), TextRead(format=TextFormat.string)),
+ (AttrR(ColourEnum), TextRead(format=TextFormat.string)),
+ (AttrR(Array1D[np.int32]), ArrayTrace(axis="x")),
],
)
-def test_get_attribute_component_r(datatype, widget):
+def test_get_attribute_component_r(attribute, widget):
gui = EpicsGUI(ControllerAPI())
- assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(datatype)) == SignalR(
+ assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) == SignalR(
name="Attr", read_pv="DEVICE:Attr", read_widget=widget
)
@pytest.mark.parametrize(
- "datatype",
+ "attribute",
[
- (Waveform(array_dtype=np.int32, shape=(10, 10))),
+ AttrR(np.ndarray, array_dtype=np.int32, shape=(10, 10)),
],
)
-def test_get_attribute_component_r_signal_none(datatype):
+def test_get_attribute_component_r_signal_none(attribute):
gui = EpicsGUI(ControllerAPI())
- assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(datatype)) is None
+ assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) is None
@pytest.mark.parametrize(
- "datatype, widget",
+ "attribute, widget",
[
- (Bool(), ToggleButton()),
- (Int(), TextWrite()),
- (Float(), TextWrite()),
- (String(), TextWrite(format=TextFormat.string)),
- (Enum(ColourEnum), ComboBox(choices=["RED", "GREEN", "BLUE"])),
+ (AttrW(bool), ToggleButton()),
+ (AttrW(int), TextWrite()),
+ (AttrW(float), TextWrite()),
+ (AttrW(str), TextWrite(format=TextFormat.string)),
+ (AttrW(ColourEnum), ComboBox(choices=["RED", "GREEN", "BLUE"])),
],
)
-def test_get_attribute_component_w(datatype, widget):
+def test_get_attribute_component_w(attribute, widget):
gui = EpicsGUI(ControllerAPI())
- assert gui._get_attribute_component(["DEVICE"], "Attr", AttrW(datatype)) == SignalW(
+ assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) == SignalW(
name="Attr", write_pv="DEVICE:Attr", write_widget=widget
)
@@ -90,16 +90,14 @@ def test_get_attribute_component_none(mocker):
mocker.patch.object(gui, "_get_read_widget", return_value=None)
mocker.patch.object(gui, "_get_write_widget", return_value=None)
- assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(Int())) is None
- assert gui._get_attribute_component(["DEVICE"], "Attr", AttrW(Int())) is None
- assert gui._get_attribute_component(["DEVICE"], "Attr", AttrRW(Int())) is None
+ assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(int)) is None
+ assert gui._get_attribute_component(["DEVICE"], "Attr", AttrW(int)) is None
+ assert gui._get_attribute_component(["DEVICE"], "Attr", AttrRW(int)) is None
def test_get_write_widget_none():
gui = EpicsGUI(ControllerAPI())
- assert (
- gui._get_write_widget(attribute=AttrR(Waveform(array_dtype=np.int32))) is None
- )
+ assert gui._get_write_widget(attribute=AttrR(Array1D[np.int32])) is None
def test_get_components(controller):
@@ -195,11 +193,11 @@ def test_get_command_component():
class _A(Controller):
- foo = AttrR(Int())
+ foo = AttrR(int)
class _B(Controller):
- bar = AttrR(Int())
+ bar = AttrR(int)
def _api_with_id(cls, name):
diff --git a/tests/transports/epics/ca/test_initial_value.py b/tests/transports/epics/ca/test_initial_value.py
index b78090dd0..9563e7e55 100644
--- a/tests/transports/epics/ca/test_initial_value.py
+++ b/tests/transports/epics/ca/test_initial_value.py
@@ -7,7 +7,7 @@
import fastcs.transports.epics.ca.ioc as ca_ioc
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controllers import Controller
-from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform
+from fastcs.datatypes import Array1D
from fastcs.launch import FastCS
from fastcs.transports.epics.ca.transport import EpicsCATransport
@@ -19,30 +19,32 @@ class InitialEnum(enum.Enum):
class InitialValuesController(Controller):
- int = AttrRW(Int(), initial_value=4)
- float = AttrRW(Float(), initial_value=3.1)
- bool = AttrRW(Bool(), initial_value=True)
- enum = AttrRW(Enum(InitialEnum), initial_value=InitialEnum.B)
- str = AttrRW(String(), initial_value="initial")
- waveform = AttrRW(
- Waveform(np.int64, shape=(10,)),
+ int_rw = AttrRW(int, initial_value=4)
+ float_rw = AttrRW(float, initial_value=3.1)
+ bool_rw = AttrRW(bool, initial_value=True)
+ enum_rw = AttrRW(InitialEnum, initial_value=InitialEnum.B)
+ str_rw = AttrRW(str, initial_value="initial")
+ waveform_rw = AttrRW(
+ Array1D[np.int64],
initial_value=np.array(range(10), dtype=np.int64),
+ shape=(10,),
)
- int_r = AttrR(Int(), initial_value=5)
- float_r = AttrR(Float(), initial_value=4.1)
- bool_r = AttrR(Bool(), initial_value=False)
- enum_r = AttrR(Enum(InitialEnum), initial_value=InitialEnum.C)
- str_r = AttrR(String(), initial_value="initial_r")
+ int_r = AttrR(int, initial_value=5)
+ float_r = AttrR(float, initial_value=4.1)
+ bool_r = AttrR(bool, initial_value=False)
+ enum_r = AttrR(InitialEnum, initial_value=InitialEnum.C)
+ str_r = AttrR(str, initial_value="initial_r")
waveform_r = AttrR(
- Waveform(np.int64, shape=(10,)),
+ Array1D[np.int64],
initial_value=np.array(range(10, 20), dtype=np.int64),
+ shape=(10,),
)
- int_w = AttrW(Int())
- float_w = AttrW(Float())
- bool_w = AttrW(Bool())
- enum_w = AttrW(Enum(InitialEnum))
- str_w = AttrW(String())
- waveform_w = AttrW(Waveform(np.int64, shape=(10,)))
+ int_w = AttrW(int)
+ float_w = AttrW(float)
+ bool_w = AttrW(bool)
+ enum_w = AttrW(InitialEnum)
+ str_w = AttrW(str)
+ waveform_w = AttrW(Array1D[np.int64], shape=(10,))
@pytest.mark.forked
@@ -73,27 +75,27 @@ async def test_initial_values_set_in_ca(mocker):
for wrapper in record_spy.spy_return_list + record_spy_out.spy_return_list
}
for name, value in {
- "SOFTIOC_INITIAL_DEVICE:Bool": 1,
+ "SOFTIOC_INITIAL_DEVICE:BoolRw": 1,
"SOFTIOC_INITIAL_DEVICE:BoolR": 0,
"SOFTIOC_INITIAL_DEVICE:BoolW": 0,
- "SOFTIOC_INITIAL_DEVICE:Bool_RBV": 1,
- "SOFTIOC_INITIAL_DEVICE:Enum": 1,
+ "SOFTIOC_INITIAL_DEVICE:BoolRw_RBV": 1,
+ "SOFTIOC_INITIAL_DEVICE:EnumRw": 1,
"SOFTIOC_INITIAL_DEVICE:EnumR": 2,
"SOFTIOC_INITIAL_DEVICE:EnumW": 0,
- "SOFTIOC_INITIAL_DEVICE:Enum_RBV": 1,
- "SOFTIOC_INITIAL_DEVICE:Float": 3.1,
+ "SOFTIOC_INITIAL_DEVICE:EnumRw_RBV": 1,
+ "SOFTIOC_INITIAL_DEVICE:FloatRw": 3.1,
"SOFTIOC_INITIAL_DEVICE:FloatR": 4.1,
"SOFTIOC_INITIAL_DEVICE:FloatW": 0.0,
- "SOFTIOC_INITIAL_DEVICE:Float_RBV": 3.1,
- "SOFTIOC_INITIAL_DEVICE:Int": 4,
+ "SOFTIOC_INITIAL_DEVICE:FloatRw_RBV": 3.1,
+ "SOFTIOC_INITIAL_DEVICE:IntRw": 4,
"SOFTIOC_INITIAL_DEVICE:IntR": 5,
"SOFTIOC_INITIAL_DEVICE:IntW": 0,
- "SOFTIOC_INITIAL_DEVICE:Int_RBV": 4,
- "SOFTIOC_INITIAL_DEVICE:Str": "initial",
+ "SOFTIOC_INITIAL_DEVICE:IntRw_RBV": 4,
+ "SOFTIOC_INITIAL_DEVICE:StrRw": "initial",
"SOFTIOC_INITIAL_DEVICE:StrR": "initial_r",
"SOFTIOC_INITIAL_DEVICE:StrW": "",
- "SOFTIOC_INITIAL_DEVICE:Str_RBV": "initial",
- "SOFTIOC_INITIAL_DEVICE:Waveform": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
+ "SOFTIOC_INITIAL_DEVICE:StrRw_RBV": "initial",
+ "SOFTIOC_INITIAL_DEVICE:WaveformRw": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
"SOFTIOC_INITIAL_DEVICE:WaveformR": [
10,
11,
@@ -107,7 +109,7 @@ async def test_initial_values_set_in_ca(mocker):
19,
],
"SOFTIOC_INITIAL_DEVICE:WaveformW": 10 * [0],
- "SOFTIOC_INITIAL_DEVICE:Waveform_RBV": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
+ "SOFTIOC_INITIAL_DEVICE:WaveformRw_RBV": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
}.items():
assert np.array_equal(value, initial_values[name])
except Exception as e:
diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py
index ece949116..6172d981c 100644
--- a/tests/transports/epics/ca/test_softioc.py
+++ b/tests/transports/epics/ca/test_softioc.py
@@ -14,7 +14,7 @@
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controllers import Controller, ControllerAPI
-from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform
+from fastcs.datatypes import Array1D, Limits, Meta, NumericLimits
from fastcs.exceptions import FastCSError
from fastcs.methods import Command
from fastcs.transports.epics.ca import EpicsCATransport
@@ -52,7 +52,7 @@ async def test_create_and_link_read_pv(mocker: MockerFixture):
)
record = make_record.return_value
- attribute = AttrR(Int())
+ attribute = AttrR(int)
attribute.add_readback_callback = mocker.MagicMock()
_create_and_link_read_pv("PREFIX", "PV", "attr", None, attribute)
@@ -151,17 +151,17 @@ async def test_ioc_raises_if_duplicate_aliases_provided(mocker: MockerFixture):
"attribute,record_type,kwargs",
(
(
- AttrR(String()),
+ AttrR(str),
"longStringIn",
{"length": 257, "DESC": None, "initial_value": ""},
),
(
- AttrR(String(length=10)),
+ AttrR(str, length=10),
"longStringIn",
{"length": 11, "DESC": None, "initial_value": ""},
),
(
- AttrR(Enum(ColourEnum)),
+ AttrR(ColourEnum),
"mbbIn",
{
"ZRST": "RED",
@@ -173,18 +173,16 @@ async def test_ioc_raises_if_duplicate_aliases_provided(mocker: MockerFixture):
),
(
AttrR(
- Enum(
- enum.IntEnum(
- "ONOFF_STATES",
- {"DISABLED": 0, "ENABLED": 1},
- )
+ enum.IntEnum(
+ "ONOFF_STATES",
+ {"DISABLED": 0, "ENABLED": 1},
)
),
"mbbIn",
{"ZRST": "DISABLED", "ONST": "ENABLED", "DESC": None, "initial_value": 0},
),
(
- AttrR(Waveform(np.int32, (10,))),
+ AttrR(Array1D[np.int32], shape=(10,)),
"WaveformIn",
{
"DESC": None,
@@ -212,11 +210,18 @@ def test_make_input_record(
)
+def _attribute_of_unsupported_datatype(mocker: MockerFixture):
+ attribute = mocker.MagicMock()
+ attribute.dtype = object
+ attribute.meta = {}
+ return attribute
+
+
def test_make_record_raises(mocker: MockerFixture):
mocker.patch("fastcs.transports.epics.ca.util.cast_to_epics_type")
- # Pass a mock as attribute to provoke the fallback case matching on datatype
+ # An attribute of a datatype EPICS cannot serve, to provoke the fallback
with pytest.raises(FastCSError):
- _make_in_record("PV", mocker.MagicMock())
+ _make_in_record("PV", _attribute_of_unsupported_datatype(mocker))
@pytest.mark.asyncio
@@ -227,7 +232,7 @@ async def test_create_and_link_write_pv(mocker: MockerFixture):
)
record = make_record.return_value
- attribute = AttrRW(Int())
+ attribute = AttrRW(int)
attribute.set = mocker.AsyncMock()
attribute.add_setpoint_callback = mocker.MagicMock()
@@ -279,7 +284,7 @@ class LongEnum(enum.Enum):
"attribute,record_type,kwargs",
(
(
- AttrW(Enum(enum.IntEnum("ONOFF_STATES", {"DISABLED": 0, "ENABLED": 1}))),
+ AttrW(enum.IntEnum("ONOFF_STATES", {"DISABLED": 0, "ENABLED": 1})),
"mbbOut",
{
"ZRST": "DISABLED",
@@ -289,12 +294,12 @@ class LongEnum(enum.Enum):
},
),
(
- AttrW(String()),
+ AttrW(str),
"longStringOut",
{"length": 257, "DESC": None, "initial_value": ""},
),
(
- AttrW(String(length=10)),
+ AttrW(str, length=10),
"longStringOut",
{"length": 11, "DESC": None, "initial_value": ""},
),
@@ -323,7 +328,7 @@ def test_make_output_record(
def test_long_enum_validator(mocker: MockerFixture):
builder = mocker.patch("fastcs.transports.epics.ca.util.builder")
update = mocker.MagicMock()
- attribute = AttrRW(Enum(LongEnum))
+ attribute = AttrRW(LongEnum)
pv = "PV"
record = _make_out_record(pv, attribute, on_update=update)
validator = builder.longStringOut.call_args.kwargs["validate"]
@@ -333,7 +338,7 @@ def test_long_enum_validator(mocker: MockerFixture):
def test_long_enum_in_creation(mocker: MockerFixture):
builder = mocker.patch("fastcs.transports.epics.ca.util.builder")
- attribute = AttrR(Enum(LongEnum))
+ attribute = AttrR(LongEnum)
pv = "PV"
_make_in_record(pv, attribute)
assert builder.longStringIn.call_args.kwargs["initial_value"] == "THIS"
@@ -341,20 +346,24 @@ def test_long_enum_in_creation(mocker: MockerFixture):
def test_get_output_record_raises(mocker: MockerFixture):
mocker.patch("fastcs.transports.epics.ca.util.cast_to_epics_type")
- # Pass a mock as attribute to provoke the fallback case matching on datatype
+ # An attribute of a datatype EPICS cannot serve, to provoke the fallback
with pytest.raises(FastCSError):
- _make_out_record("PV", mocker.MagicMock(), on_update=mocker.MagicMock())
+ _make_out_record(
+ "PV",
+ _attribute_of_unsupported_datatype(mocker),
+ on_update=mocker.MagicMock(),
+ )
class EpicsController(MyTestController):
- read_int = AttrR(Int())
- read_write_int = AttrRW(Int())
- read_write_float = AttrRW(Float())
- read_bool = AttrR(Bool())
- write_bool = AttrW(Bool())
- read_string = AttrRW(String())
- enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2})))
- one_d_waveform = AttrRW(Waveform(np.int32, (10,)))
+ read_int = AttrR(int)
+ read_write_int = AttrRW(int)
+ read_write_float = AttrRW(float)
+ read_bool = AttrR(bool)
+ write_bool = AttrW(bool)
+ read_string = AttrRW(str)
+ enum = AttrRW(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))
+ one_d_waveform = AttrRW(Array1D[np.int32], shape=(10,))
@pytest.fixture()
@@ -577,9 +586,9 @@ async def do_nothing(): ...
class ControllerLongNames(Controller):
- attr_r_with_reallyreallyreallyreallyreallyreallyreally_long_name = AttrR(Int())
- attr_rw_with_a_reallyreally_long_name_that_is_too_long_for_rbv = AttrRW(Int())
- attr_rw_short_name = AttrRW(Int())
+ attr_r_with_reallyreallyreallyreallyreallyreallyreally_long_name = AttrR(int)
+ attr_rw_with_a_reallyreally_long_name_that_is_too_long_for_rbv = AttrRW(int)
+ attr_rw_short_name = AttrRW(int)
command_with_reallyreallyreallyreallyreallyreallyreally_long_name = Command(
do_nothing
)
@@ -675,10 +684,10 @@ def test_non_1d_waveforms_discarded(mocker: MockerFixture):
api = ControllerAPI(
path=[DEVICE],
attributes={
- "waveform_0d": AttrR(Waveform(np.int32, shape=())),
- "waveform_1d": AttrR(Waveform(np.int32, shape=(10,))),
- "waveform_2d": AttrR(Waveform(np.int32, shape=(10, 2))),
- "waveform_3d": AttrR(Waveform(np.int32, shape=(10, 2, 3))),
+ "waveform_0d": AttrR(Array1D[np.int32], shape=()),
+ "waveform_1d": AttrR(Array1D[np.int32], shape=(10,)),
+ "waveform_2d": AttrR(Array1D[np.int32], shape=(10, 2)),
+ "waveform_3d": AttrR(Array1D[np.int32], shape=(10, 2, 3)),
},
)
@@ -692,12 +701,12 @@ def test_non_1d_waveforms_discarded(mocker: MockerFixture):
)
-def test_update_datatype(mocker: MockerFixture):
+def test_update_meta(mocker: MockerFixture):
builder = mocker.patch("fastcs.transports.epics.ca.util.builder")
pv_name = f"{DEVICE}:Attr"
- attr_r = AttrR(Int())
+ attr_r = AttrR(int)
record_r = _make_in_record(pv_name, attr_r)
builder.longIn.assert_called_once_with(
@@ -709,17 +718,17 @@ def test_update_datatype(mocker: MockerFixture):
initial_value=0,
)
record_r.set_field.assert_not_called()
- attr_r.update_datatype(Int(units="m", min_alarm=-3))
+ attr_r.update_meta(Meta(units="m", limits=NumericLimits(display=Limits(low=-3))))
record_r.set_field.assert_any_call("EGU", "m")
record_r.set_field.assert_any_call("LOPR", -3)
with pytest.raises(
- ValueError,
- match="Attribute datatype must be of type ",
+ TypeError,
+ match="'precision' is not valid metadata for int",
):
- attr_r.update_datatype(String()) # type: ignore
+ attr_r.update_meta(Meta(precision=3))
- attr_w = AttrW(Int())
+ attr_w = AttrW(int)
record_w = _make_out_record(pv_name, attr_w, on_update=mocker.ANY)
builder.longOut.assert_called_once_with(
@@ -736,16 +745,21 @@ def test_update_datatype(mocker: MockerFixture):
blocking=True,
)
record_w.set_field.assert_not_called()
- attr_w.update_datatype(Int(units="m", min_alarm=-1, min=-3))
+ attr_w.update_meta(
+ Meta(
+ units="m",
+ limits=NumericLimits(display=Limits(low=-1), control=Limits(low=-3)),
+ )
+ )
record_w.set_field.assert_any_call("EGU", "m")
record_w.set_field.assert_any_call("LOPR", -1)
record_w.set_field.assert_any_call("DRVL", -3)
with pytest.raises(
- ValueError,
- match="Attribute datatype must be of type ",
+ TypeError,
+ match="'precision' is not valid metadata for int",
):
- attr_w.update_datatype(String()) # type: ignore
+ attr_w.update_meta(Meta(precision=3))
def test_ca_context_contains_softioc_commands(mocker: MockerFixture):
diff --git a/tests/transports/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py
index 098c3cfc6..56fe9b897 100644
--- a/tests/transports/epics/pva/test_p4p.py
+++ b/tests/transports/epics/pva/test_p4p.py
@@ -16,7 +16,7 @@
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controllers import Controller, ControllerVector
-from fastcs.datatypes import Bool, Enum, Float, Int, String, Table, Waveform
+from fastcs.datatypes import Array1D, Limits, NumericLimits, Table
from fastcs.launch import FastCS
from fastcs.methods import command
from fastcs.transports.epics.pva.transport import EpicsPVATransport
@@ -224,8 +224,17 @@ def make_fastcs(pv_prefix: str, controller: Controller) -> FastCS:
def test_read_signal_set():
class SomeController(Controller):
- a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000))
- b: AttrR = AttrR(Float(min=-1, min_alarm=-0.5, prec=2))
+ a: AttrRW = AttrRW(
+ int,
+ limits=NumericLimits(
+ control=Limits(high=400_000), alarm=Limits(high=40_000)
+ ),
+ )
+ b: AttrR = AttrR(
+ float,
+ limits=NumericLimits(control=Limits(low=-1), alarm=Limits(low=-0.5)),
+ precision=2,
+ )
controller = SomeController()
pv_prefix = str(uuid4())
@@ -265,20 +274,29 @@ async def _wait_and_set_attr_r():
def test_pvi_grouping():
class ChildChildController(Controller):
- attr_e: AttrRW = AttrRW(Int())
- attr_f: AttrR = AttrR(String())
+ attr_e: AttrRW = AttrRW(int)
+ attr_f: AttrR = AttrR(str)
class ChildController(Controller):
- attr_c: AttrW = AttrW(Bool(), description="Some bool")
- attr_d: AttrW = AttrW(String())
+ attr_c: AttrW = AttrW(bool, description="Some bool")
+ attr_d: AttrW = AttrW(str)
class SomeController(Controller):
description = "some controller"
- attr_1: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000))
- attr_1: AttrRW = AttrRW(Float(min=-1, min_alarm=-0.5, prec=2))
- another_attr_0: AttrRW = AttrRW(Int())
- another_attr_1000: AttrRW = AttrRW(Int())
- a_third_attr: AttrW = AttrW(Int())
+ attr_1: AttrRW = AttrRW(
+ int,
+ limits=NumericLimits(
+ control=Limits(high=400_000), alarm=Limits(high=40_000)
+ ),
+ )
+ attr_1: AttrRW = AttrRW(
+ float,
+ limits=NumericLimits(control=Limits(low=-1), alarm=Limits(low=-0.5)),
+ precision=2,
+ )
+ another_attr_0: AttrRW = AttrRW(int)
+ another_attr_1000: AttrRW = AttrRW(int)
+ a_third_attr: AttrW = AttrW(int)
controller = SomeController()
@@ -415,9 +433,9 @@ class AnEnum(enum.Enum):
C = 3
class SomeController(Controller):
- some_waveform: AttrRW = AttrRW(Waveform(np.int64, shape=(10, 10)))
- some_table: AttrRW = AttrRW(Table(table_columns))
- some_enum: AttrRW = AttrRW(Enum(AnEnum))
+ some_waveform: AttrRW = AttrRW(Array1D[np.int64], shape=(10, 10))
+ some_table: AttrRW = AttrRW(Table, structured_dtype=table_columns)
+ some_enum: AttrRW = AttrRW(AnEnum)
controller = SomeController()
pv_prefix = str(uuid4())
@@ -525,12 +543,7 @@ async def _wait_and_put_pvs():
]
for expected_enum, actual_enum in zip(expected_enum_gets, enum_values, strict=True):
- assert (
- expected_enum
- == controller.some_enum.datatype.members[ # type: ignore
- actual_enum.todict()["value"]["index"]
- ]
- )
+ assert expected_enum == list(AnEnum)[actual_enum.todict()["value"]["index"]]
def test_command_method_put_twice(caplog):
@@ -680,7 +693,7 @@ async def test_setpoint_seeded_by_initial_poll_reaches_transport(
class SeedController(Controller):
def __init__(self):
super().__init__()
- self.a = AttrRW(Int(), getter=self.get_a)
+ self.a = AttrRW(int, getter=self.get_a)
async def get_a(self) -> int:
return 10
diff --git a/tests/transports/epics/pva/test_pva_gui.py b/tests/transports/epics/pva/test_pva_gui.py
index 4a753608e..6870110b0 100644
--- a/tests/transports/epics/pva/test_pva_gui.py
+++ b/tests/transports/epics/pva/test_pva_gui.py
@@ -4,6 +4,7 @@
LED,
ButtonPanel,
CheckBox,
+ ImageColorMap,
ImageRead,
SignalR,
SignalW,
@@ -17,22 +18,24 @@
from fastcs.attributes import AttrR, AttrW
from fastcs.controllers import ControllerAPI
-from fastcs.datatypes import Table, Waveform
-from fastcs.transports.epics.gui import EpicsGUI
+from fastcs.datatypes import Table
from fastcs.transports.epics.pva.gui import PvaEpicsGUI
@pytest.mark.parametrize(
- "datatype, widget",
+ "attribute, widget",
[
- (Waveform(array_dtype=np.int32), ImageRead()),
+ (
+ AttrR(np.ndarray, array_dtype=np.int32, shape=(10, 20)),
+ ImageRead(height=10, width=20, color_map=ImageColorMap.GRAY),
+ ),
],
)
-def test_pva_get_attribute_component_r(datatype, widget):
- gui = EpicsGUI(ControllerAPI())
+def test_pva_get_attribute_component_r(attribute, widget):
+ gui = PvaEpicsGUI(ControllerAPI())
- assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(datatype)) == SignalR(
- name="Attr", read_pv="DEVICE:Attr", read_widget=widget
+ assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) == SignalR(
+ name="Attr", read_pv="pva://DEVICE:Attr", read_widget=widget
)
@@ -51,13 +54,12 @@ def test_get_attribute_component_table_write():
["DEVICE"],
"Table",
AttrW(
- Table(
- structured_dtype=[
- ("FIELD1", np.uint32),
- ("FIELD2", np.bool),
- ("FIELD3", np.dtype("S1000")),
- ]
- )
+ Table,
+ structured_dtype=[
+ ("FIELD1", np.uint32),
+ ("FIELD2", np.bool),
+ ("FIELD3", np.dtype("S1000")),
+ ],
),
)
@@ -77,13 +79,12 @@ def test_get_attribute_component_table_read():
["DEVICE"],
"Table",
AttrR(
- Table(
- structured_dtype=[
- ("FIELD1", np.uint32),
- ("FIELD2", np.bool),
- ("FIELD3", np.dtype("S1000")),
- ]
- )
+ Table,
+ structured_dtype=[
+ ("FIELD1", np.uint32),
+ ("FIELD2", np.bool),
+ ("FIELD3", np.dtype("S1000")),
+ ],
),
)
diff --git a/tests/transports/epics/test_emission.py b/tests/transports/epics/test_emission.py
index b4e223650..e83d0ac3f 100644
--- a/tests/transports/epics/test_emission.py
+++ b/tests/transports/epics/test_emission.py
@@ -6,7 +6,6 @@
from fastcs.attributes import AttrR
from fastcs.controllers import Controller
-from fastcs.datatypes import Int
from fastcs.transports.epics.emission import (
DOCS_EXT,
INDEX_STEM,
@@ -24,11 +23,11 @@
class _Alpha(Controller):
- foo = AttrR(Int())
+ foo = AttrR(int)
class _Beta(Controller):
- bar = AttrR(Int())
+ bar = AttrR(int)
def _api_with_id(controller_class: type[Controller], name: str):
diff --git a/tests/transports/graphQL/test_graphql.py b/tests/transports/graphQL/test_graphql.py
index 193d081cd..ae618141c 100644
--- a/tests/transports/graphQL/test_graphql.py
+++ b/tests/transports/graphQL/test_graphql.py
@@ -12,17 +12,16 @@
)
from fastcs.attributes import AttrR, AttrRW, AttrW
-from fastcs.datatypes import Bool, Float, Int, String
from fastcs.transports.graphql.transport import GraphQLTransport
class GraphQLController(MyTestController):
- read_int = AttrR(Int())
- read_write_int = AttrRW(Int())
- read_write_float = AttrRW(Float())
- read_bool = AttrR(Bool())
- write_bool = AttrW(Bool())
- read_string = AttrRW(String())
+ read_int = AttrR(int)
+ read_write_int = AttrRW(int)
+ read_write_float = AttrRW(float)
+ read_bool = AttrR(bool)
+ write_bool = AttrW(bool)
+ read_string = AttrRW(str)
_GQL_ID = "device"
diff --git a/tests/transports/rest/test_rest.py b/tests/transports/rest/test_rest.py
index 2f458cb05..8bb7d97cc 100644
--- a/tests/transports/rest/test_rest.py
+++ b/tests/transports/rest/test_rest.py
@@ -9,20 +9,20 @@
from fastcs.attributes import AttrR, AttrRW, AttrW
from fastcs.controllers import ControllerAPI
-from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform
+from fastcs.datatypes import Array1D
from fastcs.transports.rest.transport import RestTransport
class RestController(MyTestController):
- read_int = AttrR(Int())
- read_write_int = AttrRW(Int())
- read_write_float = AttrRW(Float())
- read_bool = AttrR(Bool())
- write_bool = AttrW(Bool())
- read_string = AttrRW(String())
- enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2})))
- one_d_waveform = AttrRW(Waveform(np.int32, (10,)))
- two_d_waveform = AttrRW(Waveform(np.int32, (10, 10)))
+ read_int = AttrR(int)
+ read_write_int = AttrRW(int)
+ read_write_float = AttrRW(float)
+ read_bool = AttrR(bool)
+ write_bool = AttrW(bool)
+ read_string = AttrRW(str)
+ enum = AttrRW(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))
+ one_d_waveform = AttrRW(Array1D[np.int32], shape=(10,))
+ two_d_waveform = AttrRW(Array1D[np.int32], shape=(10, 10))
@pytest.fixture(scope="class")
@@ -97,7 +97,7 @@ def test_enum(
):
enum_attr = rest_controller_api.attributes["enum"]
assert isinstance(enum_attr, AttrRW)
- enum_cls = enum_attr.datatype.dtype
+ enum_cls = enum_attr.dtype
assert isinstance(enum_attr.readback, enum_cls)
assert enum_attr.readback == enum_cls(0)
expect = 0
diff --git a/tests/transports/tango/test_dsr.py b/tests/transports/tango/test_dsr.py
index 1eef2c242..abbaa439a 100644
--- a/tests/transports/tango/test_dsr.py
+++ b/tests/transports/tango/test_dsr.py
@@ -12,7 +12,7 @@
)
from fastcs.attributes import AttrR, AttrRW, AttrW
-from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform
+from fastcs.datatypes import Array1D
from fastcs.transports.tango.transport import TangoTransport
@@ -30,15 +30,15 @@ def mock_run_threadsafe_blocking(module_mocker: MockerFixture):
class TangoController(MyTestController):
- read_int = AttrR(Int())
- read_write_int = AttrRW(Int())
- read_write_float = AttrRW(Float())
- read_bool = AttrR(Bool())
- write_bool = AttrW(Bool())
- read_string = AttrRW(String())
- enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2})))
- one_d_waveform = AttrRW(Waveform(np.int32, (10,)))
- two_d_waveform = AttrRW(Waveform(np.int32, (10, 10)))
+ read_int = AttrR(int)
+ read_write_int = AttrRW(int)
+ read_write_float = AttrRW(float)
+ read_bool = AttrR(bool)
+ write_bool = AttrW(bool)
+ read_string = AttrRW(str)
+ enum = AttrRW(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))
+ one_d_waveform = AttrRW(Array1D[np.int32], shape=(10,))
+ two_d_waveform = AttrRW(Array1D[np.int32], shape=(10, 10))
@pytest.fixture(scope="class")
@@ -148,7 +148,7 @@ def test_write_bool(
def test_enum(self, tango_controller_api: AssertableControllerAPI, tango_context):
enum_attr = tango_controller_api.attributes["enum"]
assert isinstance(enum_attr, AttrRW)
- enum_cls = enum_attr.datatype.dtype
+ enum_cls = enum_attr.dtype
assert isinstance(enum_attr.readback, enum_cls)
assert enum_attr.readback == enum_cls(0)
expect = 0