Source code for emflow.data.field

"""Fields: time series with explicit availability semantics.

A :class:`Field` is one logical series of a dataset plus the rule for *when each
value becomes knowable*. This is the primitive that makes look-ahead leakage
impossible by construction: the :class:`~emflow.data.feed.DataFeed` only ever
serves values whose availability time is at or before the simulation clock.

Three kinds of field:

``actual``
    Observations stamped by measurement time. Two availability rules:

    * constant lag (default): a value stamped ``t`` becomes knowable at
      ``t + availability_lag`` โ€” the right model for settled-only archives;
    * **bitemporal** (``knowledge_col=...``): each row carries its own
      knowledge timestamp, and the same measurement time may appear multiple
      times with successive revisions (preliminary โ†’ settled). The feed
      serves, at any clock time, the latest revision already knowable โ€” so a
      backtest sees the preliminary value exactly as a live participant did.

``forecast``
    Predictions stamped by *(issue_time, valid_time)* โ€” e.g. NWP runs. The run
    issued at ``issue_time`` becomes knowable at ``issue_time +
    availability_lag`` (dissemination delay). At any clock time the feed
    serves, per valid_time, the latest run already issued. Using tomorrow's
    weather *actuals* as if they were forecasts is the classic energy-backtest
    leak; this kind exists to make that mistake unrepresentable.

``static``
    Time-invariant metadata (capacities, coordinates). Always available.
"""

from __future__ import annotations

from dataclasses import dataclass, field as _dc_field
import typing as t

import numpy as np
import pandas as pd

KINDS = ("actual", "forecast", "static")

ISSUE_LEVEL = "issue_time"
VALID_LEVEL = "valid_time"


[docs] @dataclass class Field: """One logical time series + its availability rule. Parameters ---------- name: Field name, unique within a :class:`~emflow.data.dataset.Dataset`. frame: The data. ``actual``: a DataFrame with a tz-aware ``DatetimeIndex`` (measurement time). ``forecast``: a DataFrame with a two-level MultiIndex ``(issue_time, valid_time)`` โ€” use :meth:`Field.forecast` to build one from columns. ``static``: any DataFrame. kind: ``"actual"`` (default), ``"forecast"`` or ``"static"``. availability_lag: Delay between a value's timestamp (actuals) or a run's issue time (forecasts) and the moment it becomes knowable. Anything ``pd.Timedelta`` accepts; default ``"0h"``. knowledge_col: Actuals only: name of a column holding each row's knowledge timestamp (bitemporal storage โ€” rows may repeat an index timestamp with revisions). Mutually exclusive with a non-zero ``availability_lag``. description: Optional human-readable description (surfaced in manifests). """ name: str frame: pd.DataFrame kind: str = "actual" availability_lag: t.Union[str, pd.Timedelta] = "0h" knowledge_col: t.Optional[str] = None description: t.Optional[str] = None def __post_init__(self): if self.kind not in KINDS: raise ValueError(f"Field {self.name!r}: kind must be one of {KINDS}, got {self.kind!r}") if isinstance(self.frame, pd.Series): self.frame = self.frame.to_frame() if not isinstance(self.frame, pd.DataFrame): raise TypeError(f"Field {self.name!r}: frame must be a pandas DataFrame") self.availability_lag = pd.Timedelta(self.availability_lag) if self.availability_lag < pd.Timedelta(0): raise ValueError(f"Field {self.name!r}: availability_lag must be >= 0") if self.kind == "actual": if not isinstance(self.frame.index, pd.DatetimeIndex): raise TypeError(f"actual field {self.name!r} needs a DatetimeIndex") if self.knowledge_col is not None: if self.availability_lag != pd.Timedelta(0): raise ValueError( f"Field {self.name!r}: knowledge_col and a non-zero " f"availability_lag are mutually exclusive โ€” the " f"knowledge column IS the availability rule" ) if self.knowledge_col not in self.frame.columns: raise ValueError( f"Field {self.name!r}: knowledge_col " f"{self.knowledge_col!r} not in columns " f"{list(self.frame.columns)}" ) kt = pd.to_datetime(self.frame[self.knowledge_col]) if (kt.dt.tz is None) != (self.frame.index.tz is None): raise ValueError( f"Field {self.name!r}: knowledge_col and index must " f"both be tz-aware or both tz-naive" ) if (kt < self.frame.index.to_series(index=kt.index)).any(): raise ValueError( f"Field {self.name!r}: knowledge_time before the " f"measurement timestamp โ€” a value cannot be known " f"before it happens" ) self.frame = self.frame.assign(**{self.knowledge_col: kt}) order = np.lexsort([kt.to_numpy(), self.frame.index.to_numpy()]) self.frame = self.frame.iloc[order] elif not self.frame.index.is_monotonic_increasing: self.frame = self.frame.sort_index() elif self.knowledge_col is not None: raise ValueError(f"Field {self.name!r}: knowledge_col only applies to actual fields") if self.kind == "forecast": idx = self.frame.index if not (isinstance(idx, pd.MultiIndex) and idx.nlevels == 2): raise TypeError( f"forecast field {self.name!r} needs a (issue_time, valid_time) " f"MultiIndex โ€” build it with Field.forecast(...)" ) if list(idx.names) != [ISSUE_LEVEL, VALID_LEVEL]: self.frame.index = idx.set_names([ISSUE_LEVEL, VALID_LEVEL]) if not self.frame.index.is_monotonic_increasing: self.frame = self.frame.sort_index() # -- constructors ---------------------------------------------------------
[docs] @classmethod def forecast(cls, name, frame, issue_col, valid_col, availability_lag="0h", description=None) -> "Field": """Build a forecast field from a flat frame with issue/valid columns.""" frame = frame.set_index([issue_col, valid_col]) frame.index = frame.index.set_names([ISSUE_LEVEL, VALID_LEVEL]) return cls(name=name, frame=frame, kind="forecast", availability_lag=availability_lag, description=description)
[docs] @classmethod def static(cls, name, frame, description=None) -> "Field": return cls(name=name, frame=frame, kind="static", description=description)
# -- introspection -------------------------------------------------------- @property def settlement_lag(self) -> pd.Timedelta: """How long after a timestamp its value is *finally* knowable. Environments use this to decide when an origin can settle. For constant-lag fields it's ``availability_lag``; for bitemporal fields it's the largest observed revision delay, so settlement waits for the settled (last) revision rather than scoring against a preliminary one. """ if self.kind == "actual" and self.knowledge_col is not None: kt = self.frame[self.knowledge_col] if not len(kt): return pd.Timedelta(0) delays = kt - self.frame.index.to_series(index=kt.index) return delays.max() return self.availability_lag @property def start(self) -> t.Optional[pd.Timestamp]: if self.kind == "actual": return self.frame.index[0] if len(self.frame) else None if self.kind == "forecast": valid = self.frame.index.get_level_values(VALID_LEVEL) return valid.min() if len(valid) else None return None @property def end(self) -> t.Optional[pd.Timestamp]: if self.kind == "actual": return self.frame.index[-1] if len(self.frame) else None if self.kind == "forecast": valid = self.frame.index.get_level_values(VALID_LEVEL) return valid.max() if len(valid) else None return None def __repr__(self): span = f", {self.start} โ†’ {self.end}" if self.kind != "static" else "" return (f"Field({self.name!r}, kind={self.kind!r}, " f"lag={self.availability_lag}{span}, shape={self.frame.shape})")