Source code for hydrocouple.helpers

"""
Non-normative convenience helpers for the HydroCouple Python bindings.

Mirrors ``hydrocouplehelpers.h`` — the single sanctioned exception to the
standard's no-implementation rule. Nothing here is required to implement or
consume the standard; it may be ignored entirely.

The :data:`DATA_KIND_TO_DTYPE` / :data:`DTYPE_TO_DATA_KIND` maps are the
Python spelling of the C++ ``DataKindOf<T>`` trait: they tie the standard's
:class:`~hydrocouple.core.DataKind` vocabulary to NumPy dtypes, which are
the Python-side ``BufferDescriptor``.
"""

from __future__ import annotations

import datetime as _dt
from typing import TYPE_CHECKING, Optional, Sequence

import numpy as np

from hydrocouple.core import ComponentStatus, DataKind

if TYPE_CHECKING:
    from hydrocouple.core import IComponentDataItem

__all__ = [
    "DATA_KIND_TO_DTYPE",
    "DTYPE_TO_DATA_KIND",
    "JULIAN_DAY_UNIX_EPOCH",
    "data_kind_size",
    "data_kind_of",
    "dtype_of",
    "is_valid_component_status_transition",
    "get_value",
    "set_value",
    "get_values",
    "set_values",
    "get_values_or_raise",
    "set_values_or_raise",
    "julian_day_to_datetime",
    "datetime_to_julian_day",
    "days_to_timedelta",
]


# ---------------------------------------------------------------------------
# DataKind <-> NumPy dtype mapping (the Python DataKindOf<T>)
# ---------------------------------------------------------------------------

DATA_KIND_TO_DTYPE: dict[DataKind, np.dtype] = {
    DataKind.Int8: np.dtype(np.int8),
    DataKind.UInt8: np.dtype(np.uint8),
    DataKind.Int16: np.dtype(np.int16),
    DataKind.UInt16: np.dtype(np.uint16),
    DataKind.Int32: np.dtype(np.int32),
    DataKind.UInt32: np.dtype(np.uint32),
    DataKind.Int64: np.dtype(np.int64),
    DataKind.UInt64: np.dtype(np.uint64),
    DataKind.Float32: np.dtype(np.float32),
    DataKind.Float64: np.dtype(np.float64),
    DataKind.Boolean: np.dtype(np.bool_),
}
"""Native-endian NumPy dtype for each numeric :class:`DataKind`.

``String``, ``Opaque``, and ``Unknown`` have no dtype mapping.
"""

DTYPE_TO_DATA_KIND: dict[np.dtype, DataKind] = {
    dtype: kind for kind, dtype in DATA_KIND_TO_DTYPE.items()
}
"""Inverse of :data:`DATA_KIND_TO_DTYPE`."""


[docs] def data_kind_size(kind: DataKind) -> int: """Size in bytes of one element of the given kind. Mirrors ``Helpers::dataKindSize``; 0 for String, Opaque, and Unknown. """ dtype = DATA_KIND_TO_DTYPE.get(kind) return int(dtype.itemsize) if dtype is not None else 0
[docs] def data_kind_of(dtype: "np.dtype | type") -> DataKind: """The :class:`DataKind` corresponding to a NumPy dtype. Mirrors ``Helpers::DataKindOf``; unmapped dtypes are :attr:`DataKind.Opaque`. Byte-swapped (non-native-endian) dtypes are deliberately unmapped: the wire and memory formats of the standard are native-endian. """ dt = np.dtype(dtype) if dt.byteorder not in ("=", "|") and dt.byteorder != _NATIVE_ORDER: return DataKind.Opaque return DTYPE_TO_DATA_KIND.get(dt.newbyteorder("="), DataKind.Opaque)
_NATIVE_ORDER = "<" if np.little_endian else ">"
[docs] def dtype_of(kind: DataKind) -> np.dtype: """The NumPy dtype for a numeric :class:`DataKind`. :raises ValueError: for String, Opaque, and Unknown. """ dtype = DATA_KIND_TO_DTYPE.get(kind) if dtype is None: raise ValueError(f"DataKind.{kind.name} has no NumPy dtype mapping") return dtype
# --------------------------------------------------------------------------- # Component lifecycle transition table # --------------------------------------------------------------------------- _CS = ComponentStatus _VALID_TRANSITIONS: dict[ComponentStatus, frozenset[ComponentStatus]] = { _CS.Created: frozenset({_CS.Initializing}), _CS.Initializing: frozenset({_CS.Initialized, _CS.Failed}), _CS.Initialized: frozenset({_CS.Validating, _CS.Initializing}), _CS.Validating: frozenset({_CS.Valid, _CS.Invalid}), _CS.Valid: frozenset({_CS.Preparing, _CS.Validating}), _CS.Invalid: frozenset({_CS.Validating}), _CS.Preparing: frozenset({_CS.Updated, _CS.Failed}), _CS.Updating: frozenset({_CS.Updated, _CS.Done, _CS.WaitingForData, _CS.Failed}), _CS.WaitingForData: frozenset({_CS.Updating, _CS.Failed}), _CS.Updated: frozenset({_CS.Updating, _CS.Checkpointing, _CS.Finishing}), _CS.Checkpointing: frozenset({_CS.Updated, _CS.Failed}), _CS.Done: frozenset({_CS.Finishing}), _CS.Finishing: frozenset({_CS.Finished, _CS.Created}), _CS.Finished: frozenset(), _CS.Failed: frozenset({_CS.Initializing, _CS.Finishing}), }
[docs] def is_valid_component_status_transition(from_status: ComponentStatus, to_status: ComponentStatus) -> bool: """Whether a component status transition is legal. Mirrors ``Helpers::isValidComponentStatusTransition`` — the normative statement of the component lifecycle state machine. Implementations must not perform transitions for which this returns ``False``. """ return to_status in _VALID_TRANSITIONS[from_status]
# --------------------------------------------------------------------------- # Typed convenience wrappers over the hyperslab data plane # ---------------------------------------------------------------------------
[docs] def get_value(item: "IComponentDataItem", index: Sequence[int]): """Read the single element at ``index``; returns ``(ok, value, message)``. Mirrors ``Helpers::getValue``. """ dest = np.empty(1, dtype=dtype_of(item.data_kind)) ok, message = item.get_values_into(dest, list(index), [1] * len(index)) return ok, dest[0] if ok else None, message
[docs] def set_value(item: "IComponentDataItem", value, index: Sequence[int]) -> tuple[bool, str]: """Write the single element at ``index``; returns ``(ok, message)``. Mirrors ``Helpers::setValue``. """ src = np.asarray([value], dtype=dtype_of(item.data_kind)) return item.set_values_from(src, list(index), [1] * len(index))
[docs] def get_values(item: "IComponentDataItem", start: Sequence[int], count: Sequence[int], out: Optional[np.ndarray] = None) -> tuple[bool, np.ndarray, str]: """Read a hyperslab into a C-ordered array of shape ``count``. Mirrors ``Helpers::getValues``; allocates ``out`` when not supplied. :returns: ``(ok, values, message)``. """ if out is None: out = np.empty(tuple(count), dtype=dtype_of(item.data_kind)) ok, message = item.get_values_into(out, start, count) return ok, out, message
[docs] def set_values(item: "IComponentDataItem", values: np.ndarray, start: Sequence[int], count: Sequence[int]) -> tuple[bool, str]: """Write a hyperslab from ``values``; returns ``(ok, message)``. Mirrors ``Helpers::setValues``. """ return item.set_values_from(values, start, count)
[docs] def get_values_or_raise(item: "IComponentDataItem", start: Sequence[int], count: Sequence[int], out: Optional[np.ndarray] = None) -> np.ndarray: """Raising wrapper over :func:`get_values`. :raises RuntimeError: with the item's failure message on error. """ ok, values, message = get_values(item, start, count, out) if not ok: raise RuntimeError(f"get_values_into failed on '{item.id}': {message}") return values
[docs] def set_values_or_raise(item: "IComponentDataItem", values: np.ndarray, start: Sequence[int], count: Sequence[int]) -> None: """Raising wrapper over :func:`set_values`. :raises RuntimeError: with the item's failure message on error. """ ok, message = set_values(item, values, start, count) if not ok: raise RuntimeError(f"set_values_from failed on '{item.id}': {message}")
# --------------------------------------------------------------------------- # Julian day conversions (standard calendar, UTC — the IDateTime convention) # --------------------------------------------------------------------------- JULIAN_DAY_UNIX_EPOCH = 2440587.5 """The Julian day of the Unix epoch (1970-01-01 00:00:00 UTC)."""
[docs] def julian_day_to_datetime(julian_day: float) -> _dt.datetime: """Convert a Julian day value to a timezone-aware UTC datetime.""" seconds = (julian_day - JULIAN_DAY_UNIX_EPOCH) * 86400.0 return _dt.datetime.fromtimestamp(seconds, tz=_dt.timezone.utc)
[docs] def datetime_to_julian_day(value: _dt.datetime) -> float: """Convert a datetime (naive values are taken as UTC) to a Julian day.""" if value.tzinfo is None: value = value.replace(tzinfo=_dt.timezone.utc) return JULIAN_DAY_UNIX_EPOCH + value.timestamp() / 86400.0
[docs] def days_to_timedelta(days: float) -> _dt.timedelta: """Convert a duration in days (e.g. ITimeSpan.duration) to a timedelta.""" return _dt.timedelta(days=days)