"""IssueSchedule: when forecasts are issued and what they cover โ as data.
An energy-forecasting problem's cadence (zipline's trading calendar, renamed):
each :class:`Origin` is one forecast issue โ a clock time ``asof`` at which the
model acts, plus the ``target_index`` of timestamps it must forecast. Both the
event-driven and vectorized execution modes consume the same origins, so the
schedule is the single source of truth about timing.
"""
from __future__ import annotations
from dataclasses import dataclass
import typing as t
import pandas as pd
[docs]
@dataclass(frozen=True)
class Origin:
"""One forecast issue: act at ``asof``, forecast ``target_index``.
``column`` scopes the origin to one column of the target field โ
multi-zone competitions (GEFCom2014 wind's 10 zones) score each zone
separately, so each (task, zone) pair is its own origin."""
asof: pd.Timestamp
target_index: pd.DatetimeIndex
column: t.Optional[str] = None
@property
def target_start(self) -> pd.Timestamp:
return self.target_index[0]
@property
def target_end(self) -> pd.Timestamp:
return self.target_index[-1]
def __repr__(self):
return (f"Origin(asof={self.asof}, targets={self.target_start} โ "
f"{self.target_end}, n={len(self.target_index)})")
[docs]
class IssueSchedule:
"""Generates :class:`Origin` s over an evaluation period.
Construct with one of the classmethods; call :meth:`origins` with the
period to evaluate over.
"""
def __init__(self, origin_freq, cover_start, cover_end, target_freq,
origin_time=None):
self.origin_freq = origin_freq
self.cover_start = pd.Timedelta(cover_start)
self.cover_end = pd.Timedelta(cover_end)
self.target_freq = target_freq
self.origin_time = origin_time
if self.cover_end < self.cover_start:
raise ValueError("cover_end must be >= cover_start")
if self.cover_start <= pd.Timedelta(0):
raise ValueError(
"cover_start must be > 0: targets at or before the origin's asof "
"would already be observable"
)
# -- constructors ---------------------------------------------------------
[docs]
@classmethod
def hourly(cls, horizon="1h", target_freq="1h") -> "IssueSchedule":
"""One origin per hour, forecasting ``asof + horizon`` (rolling 1-step)."""
return cls(origin_freq="1h", cover_start=horizon, cover_end=horizon,
target_freq=target_freq)
[docs]
@classmethod
def daily(cls, at="09:00", covers=("1D", "2D"), target_freq="1h") -> "IssueSchedule":
"""One origin per day at ``at`` (UTC), covering ``asof + covers[0]`` to
``asof + covers[1]`` at ``target_freq`` โ the day-ahead pattern."""
return cls(origin_freq="1D", cover_start=covers[0], cover_end=covers[1],
target_freq=target_freq, origin_time=at)
[docs]
@classmethod
def single(cls, asof, target_index) -> "IssueSchedule":
"""A one-shot schedule with an explicit target index (GEFCom task style)."""
return _ExplicitSchedule([Origin(pd.Timestamp(asof), pd.DatetimeIndex(target_index))])
[docs]
@classmethod
def explicit(cls, origins: t.Sequence[Origin]) -> "IssueSchedule":
return _ExplicitSchedule(list(origins))
# -- origin generation -------------------------------------------------------
[docs]
def origins(self, start, end) -> t.List[Origin]:
"""All origins whose *targets* fall inside the half-open ``[start, end)``.
``start``/``end`` delimit the evaluation period (target time, not issue
time). Half-open, so adjacent splits partition scored timestamps with
no double-counted boundary point.
"""
start, end = pd.Timestamp(start), pd.Timestamp(end)
# Earliest origin whose first target could be >= start:
first_asof = start - self.cover_start
asofs = pd.date_range(first_asof.floor(self.origin_freq), end,
freq=self.origin_freq, tz=start.tz)
if self.origin_time is not None:
at = pd.Timedelta(self.origin_time + ":00" if len(self.origin_time) == 5
else self.origin_time)
asofs = (asofs.normalize() + at).unique()
out = []
for asof in asofs:
targets = pd.date_range(asof + self.cover_start, asof + self.cover_end,
freq=self.target_freq)
targets = targets[(targets >= start) & (targets < end)]
if len(targets):
out.append(Origin(asof, targets))
return out
class _ExplicitSchedule(IssueSchedule):
def __init__(self, origins):
self._origins = sorted(origins, key=lambda o: o.asof)
def origins(self, start=None, end=None):
out = self._origins
if start is not None:
start = pd.Timestamp(start)
out = [o for o in out if o.target_end >= start]
if end is not None:
end = pd.Timestamp(end)
out = [o for o in out if o.target_start < end]
return list(out)