gridpulse / tests /test_features.py
adwitiyashukla's picture
Sync from GitHub 1db426b
deab3f9 verified
Raw
History Blame Contribute Delete
4.76 kB
"""Feature engineering, with particular attention to temporal leakage.
Leakage is the failure mode that silently ruins time-series projects: the model
scores beautifully in validation and collapses in production because a feature
encoded information that would not have existed at prediction time. These tests
assert it cannot happen.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from gridpulse.config import FORECAST_HORIZON
from gridpulse.features.build import (
FEATURE_COLUMNS,
TARGET,
build_features,
chronological_split,
)
@pytest.fixture
def raw_frame(synthetic_grid: pd.DataFrame) -> pd.DataFrame:
frame = synthetic_grid.copy()
local = frame["period_utc"].dt.tz_convert("America/New_York")
frame["date_local"] = local.dt.date
frame["hour_local"] = local.dt.hour
frame["day_of_week"] = local.dt.dayofweek
frame["month"] = local.dt.month
frame["year"] = local.dt.year
frame["is_weekend"] = frame["day_of_week"] >= 5
frame["is_holiday"] = False
frame["is_business_day"] = ~frame["is_weekend"]
frame["is_day_before_holiday"] = False
frame["is_day_after_holiday"] = False
for column in ["apparent_temperature", "relative_humidity_2m", "dew_point_2m",
"cloud_cover", "wind_speed_10m", "shortwave_radiation"]:
frame[column] = 10.0
return frame
def test_all_declared_features_are_produced(raw_frame):
featured = build_features(frame=raw_frame)
missing = set(FEATURE_COLUMNS) - set(featured.columns)
assert not missing, f"Declared but not built: {sorted(missing)}"
def test_no_nulls_remain_in_lag_features(raw_frame):
featured = build_features(frame=raw_frame)
lag_columns = [c for c in FEATURE_COLUMNS if c.startswith("demand_lag")]
assert featured[lag_columns].isna().sum().sum() == 0
def test_cyclical_encoding_wraps_around(raw_frame):
"""Hour 23 and hour 0 must be neighbours in the encoded space."""
featured = build_features(frame=raw_frame)
hour_23 = featured[featured["hour_local"] == 23].iloc[0]
hour_0 = featured[featured["hour_local"] == 0].iloc[0]
hour_12 = featured[featured["hour_local"] == 12].iloc[0]
def distance(a, b):
return np.hypot(a["hour_sin"] - b["hour_sin"], a["hour_cos"] - b["hour_cos"])
assert distance(hour_23, hour_0) < distance(hour_23, hour_12)
def test_lag_features_reference_the_correct_past_value(raw_frame):
"""demand_lag_24h at time t must equal actual demand at t - 24h."""
featured = build_features(frame=raw_frame)
single = featured[featured["ba_code"] == "PJM"].sort_values("period_utc")
source = raw_frame[raw_frame["ba_code"] == "PJM"].set_index("period_utc")[TARGET]
probe = single.iloc[500]
expected = source.loc[probe["period_utc"] - pd.Timedelta(hours=24)]
assert probe["demand_lag_24h"] == pytest.approx(expected)
def test_rolling_features_do_not_leak_the_present(raw_frame):
"""Rolling statistics must be shifted by at least the forecast horizon."""
featured = build_features(frame=raw_frame)
single = featured[featured["ba_code"] == "PJM"].sort_values("period_utc").reset_index(drop=True)
source = raw_frame[raw_frame["ba_code"] == "PJM"].sort_values("period_utc").reset_index(drop=True)
probe_index = 800
probe = single.iloc[probe_index]
position = source.index[source["period_utc"] == probe["period_utc"]][0]
window = source[TARGET].iloc[position - FORECAST_HORIZON - 23 : position - FORECAST_HORIZON + 1]
assert probe["demand_roll_mean_24h"] == pytest.approx(window.mean(), rel=1e-6)
def test_degree_days_split_the_temperature_response(raw_frame):
featured = build_features(frame=raw_frame)
both_positive = (featured["heating_degrees"] > 0) & (featured["cooling_degrees"] > 0)
assert not both_positive.any()
assert (featured["heating_degrees"] >= 0).all()
assert (featured["cooling_degrees"] >= 0).all()
def test_chronological_split_never_overlaps(raw_frame):
featured = build_features(frame=raw_frame)
train, valid, test = chronological_split(featured, test_days=30, valid_days=30)
assert train["period_utc"].max() <= valid["period_utc"].min()
assert valid["period_utc"].max() <= test["period_utc"].min()
assert len(train) + len(valid) + len(test) == len(featured)
def test_inference_frame_keeps_rows_without_a_target(raw_frame):
"""Future rows have no actual demand yet and must survive feature building."""
frame = raw_frame.copy()
frame.loc[frame.index[-12:], TARGET] = np.nan
with_target = build_features(frame=frame, dropna_target=True)
without = build_features(frame=frame, dropna_target=False)
assert len(without) > len(with_target)