"""Tests for Banister CTL/ATL/TSB and the forecast splice helper. Two things to lock down: - the EWMA math itself, via a closed-form impulse-response check. - the forecast splice, which must produce the same output as feeding the whole series at once (i.e. no double-counting on the boundary day). """ from __future__ import annotations import math import numpy as np import pandas as pd import pytest from openrun.model import banister, banister_forecast def _daily_zero_load(start: str, days: int) -> pd.Series: idx = pd.date_range(start, periods=days, freq="D") return pd.Series(0.0, index=idx) # --------------------------------------------------------------------------- # banister() — closed-form correctness # --------------------------------------------------------------------------- def test_banister_impulse_decays_exponentially() -> None: """Single load on day 0, zeros after. The recursion CTL[i] = CTL[i-1]*decay + load[i]*(1-decay) with decay = exp(-1/tau) yields CTL[i] = L * (1-decay) * decay**i. """ load = _daily_zero_load("2026-01-01", 50) load.iloc[0] = 100.0 pmc = banister(load, ctl_tau=42.0, atl_tau=7.0) decay_ctl = math.exp(-1 / 42) decay_atl = math.exp(-1 / 7) for i in (0, 1, 7, 21, 42): expected_ctl = 100.0 * (1 - decay_ctl) * decay_ctl**i expected_atl = 100.0 * (1 - decay_atl) * decay_atl**i assert pmc["CTL"].iloc[i] == pytest.approx(expected_ctl, rel=1e-9) assert pmc["ATL"].iloc[i] == pytest.approx(expected_atl, rel=1e-9) def test_banister_steady_state_approaches_input() -> None: """Constant input L converges to CTL=L; after `tau` days CTL ≈ L·(1-1/e).""" load = pd.Series(100.0, index=pd.date_range("2026-01-01", periods=200, freq="D")) pmc = banister(load, ctl_tau=42.0, atl_tau=7.0) assert pmc["CTL"].iloc[41] == pytest.approx(100.0 * (1 - 1 / math.e), rel=1e-3) # After ~5τ days (200/42 ≈ 4.76), CTL is within 1% of the steady-state value. assert pmc["CTL"].iloc[-1] == pytest.approx(100.0, rel=0.01) def test_banister_rest_days_filled_with_zero() -> None: """Sparse input (only some dates) must be reindexed daily with zeros so both EWMAs continue to decay on rest days.""" idx = pd.to_datetime(["2026-01-01", "2026-01-10"]) load = pd.Series([100.0, 0.0], index=idx) pmc = banister(load, ctl_tau=42.0, atl_tau=7.0) assert len(pmc) == 10 # 2026-01-01 through 2026-01-10 inclusive def test_banister_empty_input_returns_empty_frame() -> None: out = banister(pd.Series(dtype=float)) assert list(out.columns) == ["CTL", "ATL", "TSB"] assert len(out) == 0 # --------------------------------------------------------------------------- # banister_forecast() — splice + invariant # --------------------------------------------------------------------------- def test_banister_forecast_matches_full_history() -> None: """Splitting a full series into (history, future) and running through the forecast helper produces the identical PMC frame to feeding the whole series at once. This is the load-bearing invariant.""" rng = np.random.default_rng(seed=42) idx = pd.date_range("2026-01-01", periods=120, freq="D") full = pd.Series(rng.integers(0, 150, size=120).astype(float), index=idx) cutoff = idx[60] history = full[full.index <= cutoff] future = full[full.index > cutoff] pmc_full = banister(full) pmc_spliced = banister_forecast(history, future, today=cutoff) pd.testing.assert_frame_equal(pmc_full, pmc_spliced) def test_banister_forecast_default_today_is_history_max() -> None: history = pd.Series([10.0, 20.0, 30.0], index=pd.date_range("2026-01-01", periods=3, freq="D")) future = pd.Series([5.0, 5.0], index=pd.date_range("2026-01-04", periods=2, freq="D")) out_default = banister_forecast(history, future) out_explicit = banister_forecast(history, future, today="2026-01-03") pd.testing.assert_frame_equal(out_default, out_explicit) def test_banister_forecast_drops_future_dates_at_or_before_today() -> None: """A future row dated ≤ today must be dropped — history is authoritative for that day.""" history = pd.Series([100.0], index=pd.date_range("2026-01-03", periods=1, freq="D")) # Future contains an "overlapping" entry at today that would otherwise sum with history. future = pd.Series([999.0, 5.0], index=pd.to_datetime(["2026-01-03", "2026-01-04"])) pmc = banister_forecast(history, future, today="2026-01-03") # Day 0 should reflect history's 100, not the 999 from future. decay_ctl = math.exp(-1 / 42) assert pmc["CTL"].iloc[0] == pytest.approx(100.0 * (1 - decay_ctl), rel=1e-9) def test_banister_forecast_extends_through_future_end() -> None: history = pd.Series([10.0], index=pd.date_range("2026-01-01", periods=1, freq="D")) future = pd.Series([5.0] * 5, index=pd.date_range("2026-01-02", periods=5, freq="D")) pmc = banister_forecast(history, future) assert pmc.index.max() == pd.Timestamp("2026-01-06")