From e10656a0977c5eadaa736d543bc66880dd5cab84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:41:09 +0000 Subject: [PATCH 1/2] attributes(#413): replace DataType family with python types + *Meta typed dicts An attribute's datatype is now the python type it holds - `float`, an Enum subclass, `Array1D[np.int32]`, `Table` - and everything that used to hang off a `DataType` instance travels separately as metadata on `attr.meta`. - New `fastcs.datatypes`: `Array1D`/`Table` datatype spellings, the per-datatype `*Meta` typed dicts (plus the superset `Meta`), nested `NumericLimits`, and the validation the `DataType` classes used to do. - `Attr*` constructors are overloaded per datatype, so `AttrRW(str, precision=3)` is a static type error; `validate_meta` is the runtime counterpart for metadata that arrives without a static check. - Naming pass (ADR 0017): `prec` -> `precision`, and the flat `min`/`max`/`min_alarm`/`max_alarm` become nested control/display/alarm/warning limits with inheritance. - Every transport repointed from `attr.datatype.*` to `attr.dtype` + `attr.meta`; `add_update_datatype_callback` becomes `add_update_meta_callback`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G --- docs/explanations/controllers.md | 9 +- docs/explanations/datatypes.md | 285 ++++++------- docs/explanations/transports.md | 22 +- docs/how-to/arrange-epics-screens.md | 20 +- docs/how-to/table-waveform-data.md | 76 ++-- docs/how-to/update-attributes-from-device.md | 25 +- docs/how-to/wait-methods.md | 15 +- docs/snippets/dynamic.py | 16 +- docs/snippets/static03.py | 3 +- docs/snippets/static04.py | 3 +- docs/snippets/static05.py | 3 +- docs/snippets/static06.py | 3 +- docs/snippets/static07.py | 3 +- docs/snippets/static08.py | 5 +- docs/snippets/static09.py | 7 +- docs/snippets/static10.py | 15 +- docs/snippets/static11.py | 17 +- docs/snippets/static12.py | 23 +- docs/snippets/static13.py | 23 +- docs/snippets/static14.py | 23 +- docs/snippets/static15.py | 23 +- docs/tutorials/static-drivers.md | 2 +- src/fastcs/attributes/_infer_datatype.py | 41 +- src/fastcs/attributes/attr_r.py | 119 +++++- src/fastcs/attributes/attr_rw.py | 125 +++++- src/fastcs/attributes/attr_w.py | 107 ++++- src/fastcs/attributes/attribute.py | 147 +++++-- src/fastcs/controllers/base_controller.py | 4 +- src/fastcs/datatypes/__init__.py | 39 +- src/fastcs/datatypes/_numeric.py | 38 -- src/fastcs/datatypes/_util.py | 21 +- src/fastcs/datatypes/bool.py | 16 - src/fastcs/datatypes/datatype.py | 94 ----- src/fastcs/datatypes/enum.py | 37 -- src/fastcs/datatypes/float.py | 24 -- src/fastcs/datatypes/int.py | 12 - src/fastcs/datatypes/limits.py | 83 ++++ src/fastcs/datatypes/meta.py | 115 ++++++ src/fastcs/datatypes/string.py | 33 -- src/fastcs/datatypes/table.py | 40 -- src/fastcs/datatypes/types.py | 139 +++++++ src/fastcs/datatypes/validation.py | 220 ++++++++++ src/fastcs/datatypes/waveform.py | 46 --- src/fastcs/demo/eiger.py | 27 +- src/fastcs/demo/temperature_attr.py | 12 +- src/fastcs/transports/epics/ca/ioc.py | 15 +- src/fastcs/transports/epics/ca/util.py | 389 +++++++++--------- src/fastcs/transports/epics/gui.py | 89 ++-- .../transports/epics/pva/_pv_handlers.py | 12 +- src/fastcs/transports/epics/pva/gui.py | 71 ++-- src/fastcs/transports/epics/pva/types.py | 208 +++++----- src/fastcs/transports/graphql/graphql.py | 8 +- src/fastcs/transports/rest/rest.py | 12 +- src/fastcs/transports/rest/util.py | 42 +- src/fastcs/transports/tango/dsr.py | 10 +- src/fastcs/transports/tango/util.py | 118 +++--- tests/assertable_controller.py | 3 +- tests/benchmarking/controller.py | 5 +- tests/conftest.py | 13 +- tests/demo/test_eiger.py | 11 +- tests/example_p4p_ioc.py | 30 +- tests/example_softioc.py | 9 +- tests/test_attribute_logging.py | 9 +- tests/test_attributes.py | 155 +++++-- tests/test_control_system.py | 7 +- tests/test_controllers.py | 35 +- tests/test_datatypes.py | 209 +++++++--- tests/test_launch.py | 3 +- tests/test_multi_controller.py | 9 +- tests/transports/epics/ca/test_ca_util.py | 98 +++-- tests/transports/epics/ca/test_gui.py | 58 ++- .../transports/epics/ca/test_initial_value.py | 66 +-- tests/transports/epics/ca/test_softioc.py | 108 ++--- tests/transports/epics/pva/test_p4p.py | 50 ++- tests/transports/epics/pva/test_pva_gui.py | 45 +- tests/transports/epics/test_emission.py | 5 +- tests/transports/graphQL/test_graphql.py | 13 +- tests/transports/rest/test_rest.py | 22 +- tests/transports/tango/test_dsr.py | 22 +- 79 files changed, 2459 insertions(+), 1660 deletions(-) delete mode 100644 src/fastcs/datatypes/_numeric.py delete mode 100644 src/fastcs/datatypes/bool.py delete mode 100644 src/fastcs/datatypes/datatype.py delete mode 100644 src/fastcs/datatypes/enum.py delete mode 100644 src/fastcs/datatypes/float.py delete mode 100644 src/fastcs/datatypes/int.py create mode 100644 src/fastcs/datatypes/limits.py create mode 100644 src/fastcs/datatypes/meta.py delete mode 100644 src/fastcs/datatypes/string.py delete mode 100644 src/fastcs/datatypes/table.py create mode 100644 src/fastcs/datatypes/types.py create mode 100644 src/fastcs/datatypes/validation.py delete mode 100644 src/fastcs/datatypes/waveform.py 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/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..efbe77769 --- /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..d9e43f997 --- /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 `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..3f63dbcf4 --- /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..4caea6f5c 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()) @@ -680,7 +698,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 From c1fce75c6827a1fc6f965f4e08117209e370bc3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:46:14 +0000 Subject: [PATCH 2/2] fix(#413): repoint a p4p enum assertion and unresolvable doc references The p4p enum test still read `attr.datatype.members`; it cannot run in the sandbox (no PVA socket family), so CI was the first to see it. Three new docstrings also referenced `ControllerFiller`, which does not exist until #394, and an ambiguous `fastcs.datatypes.meta` - sphinx builds with warnings as errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G --- opis/240798e6-9fad-48f2-8acf-a5f92d820b11.bob | 142 ++++++++++++++++++ ...fad-48f2-8acf-a5f92d820b11_ChildVector.bob | 78 ++++++++++ ...-a5f92d820b11_ChildVector_ChildVector0.bob | 73 +++++++++ ...-a5f92d820b11_ChildVector_ChildVector1.bob | 73 +++++++++ opis/index.bob | 53 +++++++ src/fastcs/datatypes/meta.py | 2 +- src/fastcs/datatypes/types.py | 2 +- src/fastcs/datatypes/validation.py | 2 +- tests/transports/epics/pva/test_p4p.py | 7 +- 9 files changed, 423 insertions(+), 9 deletions(-) create mode 100644 opis/240798e6-9fad-48f2-8acf-a5f92d820b11.bob create mode 100644 opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector.bob create mode 100644 opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector0.bob create mode 100644 opis/240798e6-9fad-48f2-8acf-a5f92d820b11_ChildVector_ChildVector1.bob create mode 100644 opis/index.bob 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/datatypes/meta.py b/src/fastcs/datatypes/meta.py index efbe77769..4dd8a2f7a 100644 --- a/src/fastcs/datatypes/meta.py +++ b/src/fastcs/datatypes/meta.py @@ -9,7 +9,7 @@ `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. +statically - the ``ControllerFiller`` validates those at fill time instead. """ from __future__ import annotations diff --git a/src/fastcs/datatypes/types.py b/src/fastcs/datatypes/types.py index d9e43f997..a24675cc5 100644 --- a/src/fastcs/datatypes/types.py +++ b/src/fastcs/datatypes/types.py @@ -3,7 +3,7 @@ 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 `fastcs.datatypes.meta`). +(see :py:mod:`fastcs.datatypes.meta`). """ from __future__ import annotations diff --git a/src/fastcs/datatypes/validation.py b/src/fastcs/datatypes/validation.py index 3f63dbcf4..49cad726a 100644 --- a/src/fastcs/datatypes/validation.py +++ b/src/fastcs/datatypes/validation.py @@ -53,7 +53,7 @@ def validate_meta(dtype: type[DType], meta: Meta, name: str = "attribute") -> No 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. + ``ControllerFiller`` extras object, say. Args: dtype: The python type the attribute holds diff --git a/tests/transports/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py index 4caea6f5c..56fe9b897 100644 --- a/tests/transports/epics/pva/test_p4p.py +++ b/tests/transports/epics/pva/test_p4p.py @@ -543,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):