"""DataFeed: the only object that hands out data during a run.
Everything a model sees while being evaluated flows through here, bound to the
simulation clock (*asof*). Availability rules live on the
:class:`~emflow.data.field.Field`; the feed enforces them:
* actuals โ values stamped ``t`` are served iff ``t + availability_lag <= asof``;
* forecasts โ runs issued at ``i`` are served iff ``i + availability_lag <= asof``,
and per valid_time the **latest** such run wins;
* statics โ always served.
With the feed as the sole gate, look-ahead leakage is impossible by
construction rather than by runner discipline.
"""
from __future__ import annotations
import typing as t
import pandas as pd
from .dataset import Dataset
from .field import Field, ISSUE_LEVEL, VALID_LEVEL
[docs]
class DataFeed:
"""Point-in-time access to a :class:`Dataset`."""
def __init__(self, dataset: Dataset):
self.dataset = dataset
# -- core queries -----------------------------------------------------------
[docs]
def history(self, asof, name: str, window=None, strict=False) -> pd.DataFrame:
"""Actual values of field ``name`` knowable at ``asof``.
For bitemporal fields (``knowledge_col`` set) the filter runs on the
knowledge axis and, per measurement timestamp, the **latest revision**
already knowable wins โ a backtest sees preliminary values exactly as
a live participant did. The knowledge column is dropped from the
returned frame.
``window`` (optional, anything ``pd.Timedelta`` accepts) limits the
result to the trailing window before the availability cutoff โ an
efficiency knob, never a leakage one.
``strict`` excludes the cutoff instant itself (``<`` instead of
``<=``). Training views use this so a training boundary placed exactly
on the first scored target never serves it.
"""
f = self._actual(name)
asof = pd.Timestamp(asof)
tz = f.frame.index.tz
if tz is not None and asof.tz is None:
asof = asof.tz_localize(tz)
if f.knowledge_col is not None:
kt = f.frame[f.knowledge_col]
knowable = (kt < asof) if strict else (kt <= asof)
out = f.frame[knowable.to_numpy()]
# Frame is sorted by (timestamp, knowledge_time): the last
# occurrence per timestamp is the latest knowable revision.
out = out[~out.index.duplicated(keep="last")]
out = out.drop(columns=[f.knowledge_col])
if window is not None:
out = out.loc[asof - pd.Timedelta(window):]
return out
cutoff = asof - f.availability_lag
out = f.frame.loc[:cutoff]
if strict and len(out) and out.index[-1] == cutoff:
out = out.iloc[:-1]
if window is not None:
out = out.loc[cutoff - pd.Timedelta(window):]
return out
[docs]
def forecasts(self, asof, name: str, columns=None) -> pd.DataFrame:
"""Latest forecast run of field ``name`` knowable at ``asof``.
Returns a frame indexed by ``valid_time`` where, for each valid_time,
the value comes from the most recent run with
``issue_time + availability_lag <= asof``. An ``issue_time`` column
records which run each row came from.
"""
f = self.dataset.field(name)
if f.kind != "forecast":
raise ValueError(f"field {name!r} is {f.kind!r}, not a forecast field")
asof = pd.Timestamp(asof)
cutoff = asof - f.availability_lag
issues = f.frame.index.get_level_values(ISSUE_LEVEL)
avail = f.frame[issues <= cutoff]
if avail.empty:
empty = f.frame.iloc[:0].reset_index(level=ISSUE_LEVEL)
return empty if columns is None else empty[[ISSUE_LEVEL, *columns]]
# Frame is sorted by (issue, valid); the last occurrence per valid_time
# therefore comes from the most recent available run.
flat = avail.reset_index(level=ISSUE_LEVEL)
flat = flat[~flat.index.duplicated(keep="last")].sort_index()
if columns is not None:
flat = flat[[ISSUE_LEVEL, *columns]]
return flat
[docs]
def static(self, name: str) -> pd.DataFrame:
f = self.dataset.field(name)
if f.kind != "static":
raise ValueError(f"field {name!r} is {f.kind!r}, not a static field")
return f.frame
[docs]
def actuals_between(self, name: str, start, end, asof) -> pd.DataFrame:
"""Actuals of ``name`` in ``[start, end]`` โ only if knowable at ``asof``.
Used by environments to settle scores: returns the slice restricted to
what the availability rule allows at ``asof`` (so an env can only score
an origin once its targets have actually arrived).
"""
hist = self.history(asof, name)
tz = hist.index.tz
start, end = pd.Timestamp(start), pd.Timestamp(end)
if tz is not None:
start = start.tz_localize(tz) if start.tz is None else start
end = end.tz_localize(tz) if end.tz is None else end
return hist.loc[start:end]
# -- views ------------------------------------------------------------------
[docs]
def view(self, asof, strict=False) -> "TimeView":
"""A feed bound to a fixed clock time (what observations wrap).
``strict=True`` makes the boundary exclusive โ used for training views.
"""
return TimeView(self, asof, strict=strict)
# -- helpers ------------------------------------------------------------------
def _actual(self, name: str) -> Field:
f = self.dataset.field(name)
if f.kind != "actual":
raise ValueError(f"field {name!r} is {f.kind!r}, not an actual field")
return f
[docs]
class TimeView:
"""A :class:`DataFeed` frozen at one clock time.
This is the whole world a model gets to see: observations and training
views are ``TimeView`` s. There is deliberately no way to reach the
underlying dataset or move the clock from here.
"""
def __init__(self, feed: DataFeed, asof, strict=False):
self._feed = feed
self.asof = pd.Timestamp(asof)
self.strict = strict
[docs]
def history(self, name: str, window=None) -> pd.DataFrame:
return self._feed.history(self.asof, name, window=window, strict=self.strict)
[docs]
def forecasts(self, name: str, columns=None) -> pd.DataFrame:
return self._feed.forecasts(self.asof, name, columns=columns)
[docs]
def static(self, name: str) -> pd.DataFrame:
return self._feed.static(name)
@property
def fields(self):
return sorted(self._feed.dataset.fields)
def __repr__(self):
return f"TimeView(asof={self.asof}, fields={self.fields})"