Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 260 additions & 0 deletions docs/explanations/datatypes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
# Datatypes

FastCS uses a datatype system to map Python types to attributes with additional
metadata for validation, serialization, and transport handling.

## Supported Types

FastCS defines `DType` as the union of supported Python types:

:::{literalinclude} ../../src/fastcs/datatypes/datatype.py
:start-at: "DType = ("
:end-at: ")"
:::

Each has a corresponding `DataType` class.

## Scalar Datatypes

### Int and Float

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:"
:::

### Bool

Maps to Python `bool`. Initial value is `False`.

:::{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
:::

## 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:

```python
class DetectorStatus(StrEnum):
Idle = "IDLE_STATE"
Running = "RUNNING_STATE"
Error = "ERROR_STATE"
```

Clients will 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"}))
```

Clients will see the choices as `["Run Finished", "In Progress"]`.
:::

## Array Datatypes

### Waveform

For homogeneous numpy arrays (spectra, images):

:::{literalinclude} ../../src/fastcs/datatypes/waveform.py
:pyobject: Waveform
:::

Validation ensures the array fits within the declared shape and has the correct dtype.

### Table

For structured numpy arrays with named columns:

:::{literalinclude} ../../src/fastcs/datatypes/table.py
:pyobject: Table
:::

The `structured_dtype` field is a list of `(name, dtype)` tuples following
numpy's structured array conventions.

## Validation

### Built-in Numeric Validation

`Int` and `Float` datatypes support min/max limits and alarm thresholds:

```python
from fastcs.attributes import AttrRW
from fastcs.datatypes import Int, Float

# Integer with bounds
count = AttrRW(Int(min=0, max=100))

# Float with units and alarm limits
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
))
```

#### Validation Behavior

```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

Limit the display length of strings:

```python
from fastcs.datatypes import String

# Limit display to 40 characters
status = AttrR(String(length=40))
```

:::{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.
:::

### Type Coercion

All datatypes automatically coerce compatible types:

```python
from fastcs.datatypes import Int, Float

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)
```

### When Validation Runs

Validation runs automatically when:

1. **Attribute update**: `await attr.update(value)` validates before storing
2. **Put request**: `await attr.put(value)` validates before sending to device
3. **Initial value**: Values passed to `initial_value` are validated on creation

```python
from fastcs.attributes import AttrRW
from fastcs.datatypes import Int

attr = AttrRW(Int(min=0, max=10), initial_value=5)

# Updates are validated
await attr.update(7) # OK
await attr.update(15) # Raises ValueError

# Puts are validated
await attr.put(3) # OK
await attr.put(-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

## 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
:::

Subclasses can override this to add validation logic. The pattern is

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:

:::{literalinclude} ../../src/fastcs/datatypes/_numeric.py
:pyobject: _Numeric.validate
:::

### Overriding `equal()`

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()`:

:::{literalinclude} ../../src/fastcs/datatypes/waveform.py
:pyobject: Waveform.equal
:::

### Transport Compatibility

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.
Loading
Loading