Files

569 lines
23 KiB
Markdown
Raw Permalink Normal View History

2026-07-15 16:38:53 +02:00
# `dataabc` — Generic Data Handling
## Overview
The `dataabc` module provides the foundational abstractions for managing time-series data
in EOS. It defines a layered class hierarchy that covers individual data records, ordered
sequences of records, singleton data providers, and multi-provider containers.
All classes in this module are designed for use in predictive modelling workflows and share
three cross-cutting concerns:
- **Configuration access** via `ConfigMixin`, exposing the global EOS configuration as
`self.config`.
- **Database persistence** via `DatabaseRecordProtocolMixin`, providing optional storage
in a time-series database alongside in-memory records.
- **Async safety** via a three-level locking scheme described in detail in the
[Concurrency and Locking](#concurrency-and-locking) section.
## Class Hierarchy
```text
PydanticBaseModel
└── DataABC (ConfigMixin, StartMixin)
├── DataRecord (MutableMapping)
└── DataSequence (DatabaseRecordProtocolMixin)
└── DataProvider (SingletonMixin)
└── DataImportProvider (DataImportMixin)
DataContainer (SingletonMixin, MutableMapping)
DataImportMixin (StartMixin)
```
## Classes
### `DataABC`
Base class for all data-handling objects. Inherits from `ConfigMixin` and `StartMixin`,
making the global EOS configuration available as `self.config` on every derived instance.
Not intended to be instantiated directly.
### `DataRecord`
A single measurement or forecast point at a specific datetime, implemented as a
`MutableMapping` so that field values can be accessed and mutated both as dictionary
entries (`record["field"]`) and as attributes (`record.field`).
#### Key concepts
**Static fields** are declared as Pydantic model fields in the class body. They are always
present and validated on assignment.
**Configured fields** are dynamic: their names are returned by the classmethod
`configured_data_keys()`, which derived classes override to pull key names from the EOS
configuration. These keys are stored in the internal `configured_data` dict but appear
transparent to callers — they show up in `dir()`, `iter()`, and attribute access just like
static fields.
```python
class MeasurementDataRecord(DataRecord):
@classmethod
def configured_data_keys(cls) -> Optional[list[str]]:
return cls.config.measurement.keys
```
#### Important methods
| Method | Description |
|---|---|
| `record_keys()` | All field names, including configured keys. |
| `record_keys_writable()` | Subset of `record_keys()` that can be written. |
| `key_from_description(desc)` | Fuzzy-matches a description string to a field name. |
| `keys_from_descriptions(descs)` | Batch version of `key_from_description`. |
---
### `DataSequence`
An ordered, mutable collection of `DataRecord` instances with time-series behaviour.
Records are always kept sorted in ascending `date_time` order. `DataSequence` is also the
level at which **async safety is enforced** — see
[Concurrency and Locking](#concurrency-and-locking).
Derived classes must redeclare the `records` field with the concrete record type:
```python
class Measurement(DataSequence):
records: list[MeasurementDataRecord] = Field(default_factory=list)
```
#### Public async interface of `DataSequence`
All methods that mutate sequence state are `async`. Callers in an async context (e.g.
FastAPI endpoint handlers) must `await` them.
| Method | Description |
|---|---|
| `await insert_by_datetime(record)` | Insert or merge a record by its datetime. |
| `await update_value(date, key, value)` | Insert or update a single field value at a datetime. |
| `await key_from_lists(key, dates, values)` | Populate a field from parallel date/value lists. |
| `await key_from_series(key, series)` | Populate a field from a `pd.Series`. |
| `await save()` | Persist all records to the configured storage backend. |
| `await load()` | Load records from the configured storage backend. |
| `await import_from_dict(data)` | Import records from a key-value dictionary. |
| `await import_from_dataframe(df)` | Import records from a `pd.DataFrame`. |
| `await import_from_json(json_str)` | Import records from a JSON string. |
| `await import_from_file(path)` | Import records from a JSON file. |
#### Internal sync interface of `DataSequence`
Each public async method has a private sync counterpart prefixed with `_`. These are
intended **only** for callers that already hold the appropriate locks, or that are running
in a purely sequential context (startup, `_load()` internals, tests). See
[Choosing the right call site](#choosing-the-right-call-site).
| Internal method | Corresponding public method |
|---|---|
| `_insert_by_datetime(record)` | `.insert_by_datetime()` |
| `_update_value(date, ...)` | `.update_value()` |
| `_key_from_lists(key, dates, values)` | `.key_from_lists()` |
| `_key_from_series(key, series)` | `.key_from_series()` |
| `_save()` | `.save()` |
| `_load()` | `.load()` |
| `_import_from_dict(...)` | `.import_from_dict()` |
| `_import_from_dataframe(...)` | `.import_from_dataframe()` |
| `_import_from_json(...)` | `.import_from_json()` |
| `_import_from_file(...)` | `.import_from_file()` |
#### Read-only methods (always sync) of `DataSequence`
These methods do not modify sequence state and require no locking:
| Method | Description |
|---|---|
| `get_by_datetime(dt)` | Exact or nearest record lookup. |
| `get_nearest_by_datetime(dt)` | Nearest record within an optional time window. |
| `key_to_dict(key, ...)` | Extract a `{datetime: value}` dict for a key. |
| `key_to_lists(key, ...)` | Extract parallel date and value lists. |
| `key_to_series(key, ...)` | Extract a `pd.Series` indexed by datetime. |
| `key_to_array(key, ...)` | Extract a resampled `np.ndarray` at a fixed interval. |
| `key_to_value(key, dt)` | Scalar lookup nearest to a datetime. |
| `to_dataframe(...)` | Convert all records to a `pd.DataFrame`. |
| `delete_by_datetime(...)` | Delete records within a datetime range. |
| `key_delete_by_datetime(key, ...)` | Set a field to `None` across a datetime range. |
#### Computed properties of `DataSequence`
| Property | Description |
|---|---|
| `min_datetime` | Earliest datetime in the sequence. |
| `max_datetime` | Latest datetime in the sequence. |
| `record_keys` | All field names for this sequence's record type. |
| `record_keys_writable` | Writable subset of `record_keys`. |
### `DataProvider`
Abstract singleton base class for objects that own and update a `DataSequence`. Each
concrete provider represents one data source (e.g. weather forecast, load measurement,
grid price). `DataProvider` is a specialisation of `DataSequence` and inherits its full
async interface and locking infrastructure — `_record_lock` and `_sequence_lock` — without
adding any new locks of its own.
Derived classes must implement:
| Abstract method | Description |
|---|---|
| `provider_id() -> str` | Unique string identifier for this provider. |
| `enabled() -> bool` | Whether this provider is active per configuration. |
| `_update_data(force_update)` | Custom data fetch/update logic. |
#### `_update_data` contract of `DataProvider`
`_update_data` is always called while the provider's own `_sequence_lock` and
`_record_lock` are both held by `update_data`. Implementations must therefore observe
the following constraints to avoid deadlock:
- Use only the internal sync methods (`_insert_by_datetime`, `_update_value`,
`_key_from_lists`, `_key_from_series`). Never call their public `async` counterparts,
which would attempt to re-acquire `_record_lock`.
- Do not call `save()`, `load()`, `_save()`, or `_load()`. Both locks are already held;
attempting to re-acquire either will deadlock.
- Network or I/O calls are permitted but should be kept short. Offload long-running I/O
to a thread via `asyncio.to_thread` in the caller before entering the lock scope.
```python
# Correct — both _sequence_lock and _record_lock are already held by the caller
def _update_data(self, force_update=False):
for dt, value in self._fetch_from_api():
self._update_value(dt, "temperature_c", value)
# Wrong — would deadlock: _record_lock is not reentrant
def _update_data(self, force_update=False):
for dt, value in self._fetch_from_api():
await self.update_value(dt, "temperature_c", value)
```
#### Public async interface of `DataProvider`
<!-- pyml disable line-length -->
| Method | Description |
|---|---|
| `await update_data(force_enable, force_update)` | Call `_update_data` if enabled or forced, holding both `_sequence_lock` and `_record_lock` for the duration. |
<!-- pyml enable line-length -->
### `DataImportMixin`
Mixin that adds bulk import capability to any class that also provides `update_value` and
`record_keys_writable`. Provides `import_from_dict`, `import_from_dataframe`,
`import_from_json`, and `import_from_file`.
The mixin expects values to be lists aligned to a fixed time interval starting from a
`start_datetime`. Two special dictionary keys are handled automatically:
- `start_datetime` — overrides the start of the import window.
- `interval` — overrides the fixed time step between values.
### `DataImportProvider`
Convenience base class combining `DataImportMixin` and `DataProvider`. Derive from this
when a provider's data arrives via JSON, file, or dict import rather than a live API.
### `DataContainer`
A singleton `MutableMapping` that aggregates multiple `DataProvider` instances and
presents their combined data through a single interface. Providers are tried in order;
the first one that contains the requested key wins.
`DataContainer` carries its own pair of locks (`_record_lock` and `_container_lock`)
that are independent of the locks on each provider. See
[Concurrency and Locking](#concurrency-and-locking) for the full acquisition matrix.
#### Public async interface of `DataContainer`
| Method | Description |
|---|---|
| `await __setitem__(key, series)` | Write a `pd.Series` into the appropriate provider. |
| `await __delitem__(key)` | Clear a field across all providers. |
| `await update_data(force_enable, force_update)` | Update all providers. |
| `await save()` | Save all providers to persistent storage. |
| `await load()` | Load all providers from persistent storage. |
| `await db_vacuum()` | Remove old records from all provider databases. |
| `await db_compact()` | Apply tiered compaction to all provider databases. |
| `await keys_to_dataframe(keys, ...)` | Consistent cross-provider snapshot as a `pd.DataFrame`. |
#### Read-only methods (always sync) of `DataContainer`
| Method | Description |
|---|---|
| `__getitem__(key)` | Return a `pd.Series` for a key from the first matching provider. |
| `__iter__()` | Iterate over all unique keys across enabled providers. |
| `__len__()` | Total number of unique keys. |
| `key_to_series(key, ...)` | Extract a series from the first matching provider. |
| `key_to_array(key, ...)` | Extract a resampled array from the first matching provider. |
| `provider_by_id(provider_id)` | Look up a provider by its string identifier. |
| `enabled_providers` | List of currently active providers. |
| `record_keys` | Union of all record keys across enabled providers. |
| `record_keys_writable` | Union of all writable record keys across enabled providers. |
## Concurrency and Locking
### Problem
EOS runs under FastAPI with an async event loop. Multiple HTTP requests are handled
concurrently as coroutines on a single thread. Because coroutines yield at `await` points,
two coroutines can interleave between a read and a subsequent write, producing a
**check-then-act race condition**:
```text
Coroutine A: db_get_record(ts) → None # record does not exist yet
Coroutine B: db_get_record(ts) → None # same — A has not inserted yet
Coroutine A: db_insert_record(new_rec) # inserts successfully
Coroutine B: db_insert_record(new_rec) # raises: duplicate timestamp
```
This is the root cause of the `ValueError: Duplicate timestamp` errors seen in production.
### Solution: three-level locking
The solution uses three distinct `asyncio.Lock` objects, each protecting a different scope
of state. Each name answers the question "what is being protected":
<!-- pyml disable line-length -->
| Lock | Defined on | Attribute | Protects |
|---|---|---|---|
| Record lock | `DataSequence` | `_record_lock` | A single check-then-act on one record. |
| Sequence lock | `DataSequence` | `_sequence_lock` | The full sequence state during operations that touch many records at once. |
| Container lock | `DataContainer` | `_container_lock` | Cross-provider consistency during container-level bulk operations. |
<!-- pyml enable line-length -->
`DataProvider` inherits `_record_lock` and `_sequence_lock` from `DataSequence` without
adding any new locks of its own. It is a specialisation of `DataSequence`, not a new
locking scope.
The fixed acquisition order across all levels is:
```text
_container_lock → _record_lock → provider _sequence_lock → provider _record_lock
```
No code path may acquire a finer-grained lock and then wait for a coarser one at the
same level. This invariant prevents deadlock.
### Lock creation
All locks are created lazily on first access via `_get_or_create_lock()`, which bypasses
Pydantic's `__setattr__` using `object.__setattr__` directly. This is necessary because
Pydantic v2 rejects attributes that are not declared model fields, and `asyncio.Lock`
cannot be safely created outside a running event loop, making `__init__`-time creation
unsafe. `cached_property` is not used for the same reason — Pydantic v2's tight
`__dict__` control makes it unreliable on model instances.
```python
def _get_or_create_lock(self, attr: str) -> asyncio.Lock:
try:
return object.__getattribute__(self, attr)
except AttributeError:
lock = asyncio.Lock()
object.__setattr__(self, attr, lock)
return lock
@property
def _record_lock(self) -> asyncio.Lock:
return self._get_or_create_lock("_record_lock_instance")
@property
def _sequence_lock(self) -> asyncio.Lock: # DataSequence / DataProvider
return self._get_or_create_lock("_sequence_lock_instance")
@property
def _container_lock(self) -> asyncio.Lock: # DataContainer only
return self._get_or_create_lock("_container_lock_instance")
```
### Lock acquisition rules
**Individual record writes** acquire only `_record_lock`, held for the minimum time
needed to make the check-then-act atomic:
```python
async def update_value(self, date, *args, **kwargs):
async with self._record_lock:
self._update_value(date, *args, **kwargs)
```
**Sequence-level bulk operations** (`save`, `load`, `import_from_*`) acquire
`_sequence_lock` first, then `_record_lock`. This blocks both concurrent bulk operations
and concurrent individual writes for the full duration:
```python
async def load(self):
async with self._sequence_lock:
async with self._record_lock:
self._load()
```
**`update_data`** on a provider acquires both `_sequence_lock` and `_record_lock`,
protecting the provider's full sequence state against concurrent saves, loads, imports,
or individual record writes for the entire duration of `_update_data`:
```python
async def update_data(self, force_enable=False, force_update=False):
if not force_enable and not self.enabled():
return
async with self._sequence_lock:
async with self._record_lock:
self._update_data(force_update=force_update)
```
**Container-level bulk operations** acquire `_container_lock` and `_record_lock` on the
container, then delegate to each provider (which independently acquires its own
`_sequence_lock` and `_record_lock`):
```python
async def save(self): # DataContainer
async with self._container_lock:
async with self._record_lock:
for provider in self.providers:
await provider.save() # provider acquires _sequence_lock + _record_lock
```
**Cross-provider consistent reads** acquire only the container's `_record_lock`, which
prevents concurrent writes from producing an inconsistent snapshot without blocking
other read operations:
```python
async def keys_to_dataframe(self, keys, ...):
async with self._record_lock:
... # read from multiple providers atomically
```
### Lock acquisition matrix
#### `DataSequence` / `DataProvider` lock
| Operation | `_sequence_lock` | `_record_lock` |
|---|---|---|
| `insert_by_datetime` | | ✓ |
| `update_value` | | ✓ |
| `key_from_lists` | | ✓ |
| `key_from_series` | | ✓ |
| `update_data` | ✓ | ✓ |
| `save` | ✓ | ✓ |
| `load` | ✓ | ✓ |
| `import_from_dict` | ✓ | ✓ |
| `import_from_dataframe` | ✓ | ✓ |
| `import_from_json` | ✓ | ✓ |
| `import_from_file` | ✓ | ✓ |
| Read-only methods | | |
#### `DataContainer` lock
<!-- pyml disable line-length -->
| Operation | `_container_lock` | `_record_lock` | Provider `_sequence_lock` | Provider `_record_lock` |
|---|---|---|---|---|
| `update_data` | ✓ | ✓ | ✓ | ✓ |
| `__setitem__` | | ✓ | | |
| `__delitem__` | | ✓ | | |
| `keys_to_dataframe` | | ✓ | | |
| `save` | ✓ | ✓ | ✓ | ✓ |
| `load` | ✓ | ✓ | ✓ | ✓ |
| `db_vacuum` | ✓ | ✓ | | |
| `db_compact` | ✓ | ✓ | | |
| Read-only methods | | | | |
<!-- pyml enable line-length -->
### Why `asyncio.Lock` and not `threading.Lock`
FastAPI's default async workers run all coroutines on a **single OS thread**. Coroutines
interleave at `await` points, not at thread boundaries. `threading.Lock` would not protect
against this interleaving and would risk deadlock if the same coroutine attempts to
re-acquire it. `asyncio.Lock` yields control correctly at `await` points while keeping
other coroutines blocked.
### Why `asyncio.Lock` is not reentrant
Python's `asyncio.Lock` is intentionally non-reentrant. If a coroutine that already holds
`_record_lock` calls a public async method (which also tries to acquire `_record_lock`),
it will deadlock. This is why `_update_data()` implementations and all other internal
callers must use the private `_method()` variants rather than `await self.method()`.
### Choosing the right call site
<!-- pyml disable line-length -->
| Caller context | Correct call |
|---|---|
| FastAPI endpoint or any `async def` | `await sequence.insert_by_datetime(record)` |
| `_update_data()` implementation | `self._insert_by_datetime(record)` |
| Startup, `_load()` internals, tests | `sequence._insert_by_datetime(record)` |
| `DataContainer` delegating to a provider | `await provider.save()` — container holds its own locks; provider independently holds its own |
<!-- pyml enable line-length -->
## Usage Examples
### Reading data (sync, no locking required)
```python
measurement = get_measurement()
# Scalar lookup
value = measurement.key_to_value("grid_import_w", target_datetime=now)
# Resampled array for the next 24 hours
array = measurement.key_to_array(
key="grid_import_w",
start_datetime=now,
end_datetime=now.add(hours=24),
interval=to_duration("1 hour"),
)
# Pandas Series
series = measurement.key_to_series("grid_import_w", start_datetime=now)
```
### Writing a single value from a FastAPI endpoint
```python
@router.put("/measurement/value")
async def put_measurement_value(datetime: str, key: str, value: float):
dt = to_datetime(datetime)
await get_measurement().update_value(dt, key, value)
```
### Bulk import from a FastAPI endpoint
```python
@router.put("/measurement/import")
async def put_measurement_import(data: dict):
await get_measurement().import_from_dict(data)
```
### Writing from a sync context (startup / tests)
```python
def load_initial_data(measurement: Measurement, records: list[MeasurementDataRecord]):
# Sequential — no concurrency, use internal sync methods directly
for record in records:
measurement._insert_by_datetime(record)
```
### Implementing a custom `DataProvider`
```python
class MyProvider(DataProvider):
records: list[MyRecord] = Field(default_factory=list)
def provider_id(self) -> str:
return "MyProvider"
def enabled(self) -> bool:
return self.config.my_provider.enabled
def _update_data(self, force_update: Optional[bool] = False) -> None:
# Both _sequence_lock and _record_lock are already held by the caller.
# Use internal sync methods only — never await public async counterparts.
for dt, reading in fetch_from_hardware():
self._update_value(dt, "sensor_w", reading)
def db_namespace(self) -> str:
return "MyProvider"
def db_keep_datetime(self) -> Optional[DateTime]:
return to_datetime().subtract(hours=48)
```
---
## Design Notes
### Singleton providers and containers
`DataProvider` and `DataContainer` both inherit from `SingletonMixin`. A single instance
is shared across all coroutines handling concurrent requests. This is precisely why
locking is necessary — every concurrent request operates on the same in-memory object.
### Lock naming rationale
The three lock names were chosen to reflect *what is being protected*, not *how coarse
the operation is*:
- `_record_lock` — makes it immediately clear that a single record's check-then-act is
being made atomic. The same name is used at both `DataSequence` and `DataContainer`
level because it plays the same role in both: serialising individual write operations.
- `_sequence_lock` — defined on `DataSequence` and inherited unchanged by `DataProvider`.
The name reflects that the entire sequence state is held exclusively, not just one
record. `DataProvider` is a specialisation of `DataSequence`, so the name remains
accurate at both levels without needing a separate `_provider_lock`.
- `_container_lock` — exclusive to `DataContainer`, reflecting that cross-provider
container-level state is held exclusively during bulk operations.
This naming also makes lock misuse visible in code review: a method that acquires
`_sequence_lock` without also acquiring `_record_lock` (or acquires them in the wrong
order) stands out immediately against the documented fixed acquisition order.
### Database vs in-memory storage
`DataSequence` uses `DatabaseRecordProtocolMixin` to optionally back its records with a
persistent time-series database. When a database is configured, `save()` and `load()`
delegate to it; otherwise they fall back to a JSON file. The in-memory `records` list acts
as a write-through cache. The locking scheme covers both paths — the database operations
are included inside the lock scope so that in-memory state and database state remain
consistent.
### Pydantic v2 compatibility
The `asyncio.Lock` instances are stored on the instance using `object.__setattr__`,
bypassing Pydantic's field validation entirely. This is intentional: `asyncio.Lock` is not
serialisable and must not appear in `model_dump()` or `model_dump_json()` output. The
locks are therefore invisible to Pydantic serialisation and are reconstructed fresh on
each process start, which is correct behaviour for a lock.