From f9f35d6367aed95f260f9d5a0948e7d13e4ce774 Mon Sep 17 00:00:00 2001 From: elmartinj Date: Tue, 1 Sep 2026 21:33:44 -0600 Subject: [PATCH] feat: add centralized input validation for forecasters Adds Forecaster.validate_input to catch missing columns, empty DataFrames, and non-positive horizons before model inference. Also rejects FoundationForecast(models=[]) at construction time. Closes #7. --- foundationforecast/_foundation_forecast.py | 2 ++ foundationforecast/core/forecaster.py | 27 ++++++++++++++++ foundationforecast/core/multi_model.py | 1 + tests/core/test_forecaster.py | 37 ++++++++++++++++++++++ tests/test_foundation_forecast.py | 12 +++++++ 5 files changed, 79 insertions(+) diff --git a/foundationforecast/_foundation_forecast.py b/foundationforecast/_foundation_forecast.py index 4eeced3..8b9e4e7 100644 --- a/foundationforecast/_foundation_forecast.py +++ b/foundationforecast/_foundation_forecast.py @@ -19,6 +19,8 @@ def __init__( fallback_model: Forecaster | None = None, clean_cache: bool = False, ): + if not models: + raise ValueError("At least one model is required.") self._validate_unique_aliases(models) self.models = models self.fallback_model = fallback_model diff --git a/foundationforecast/core/forecaster.py b/foundationforecast/core/forecaster.py index 4df7e10..2a0b1b4 100644 --- a/foundationforecast/core/forecaster.py +++ b/foundationforecast/core/forecaster.py @@ -63,6 +63,32 @@ def maybe_convert_col_to_datetime(df: pd.DataFrame, col_name: str) -> pd.DataFra class Forecaster: alias: str + @staticmethod + def validate_input( + df: pd.DataFrame, + h: int | None, + ) -> None: + """Validate that the input DataFrame and horizon are suitable for forecasting. + + Args: + df: DataFrame containing the time series. Must include the columns + `unique_id`, `ds`, and `y`. + h: Forecast horizon. If provided, must be a positive integer. + """ + if not isinstance(df, pd.DataFrame): + raise ValueError("df must be a pandas DataFrame.") + required_cols = ["unique_id", "ds", "y"] + missing = [c for c in required_cols if c not in df.columns] + if missing: + raise ValueError( + f"Input df is missing required columns: {missing}. " + "Expected columns are: 'unique_id', 'ds', 'y'." + ) + if h is not None and (not isinstance(h, int | np.integer) or h <= 0): + raise ValueError("h must be a positive integer.") + if len(df) == 0: + raise ValueError("df must contain at least one row.") + @staticmethod def plot( df: pd.DataFrame | None = None, @@ -161,6 +187,7 @@ def cross_validation( level: list[int | float] | None = None, quantiles: list[float] | None = None, ) -> pd.DataFrame: + self.validate_input(df, h) freq = self._maybe_infer_freq(df, freq) df = maybe_convert_col_to_datetime(df, "ds") results = [] diff --git a/foundationforecast/core/multi_model.py b/foundationforecast/core/multi_model.py index 87ac9d0..3d6635b 100644 --- a/foundationforecast/core/multi_model.py +++ b/foundationforecast/core/multi_model.py @@ -48,6 +48,7 @@ def _call_models( quantiles: list[float] | None, **kwargs, ) -> pd.DataFrame: + Forecaster.validate_input(df, h) freq = maybe_infer_freq(df, freq) res_df: pd.DataFrame | None = None for model in self.models: diff --git a/tests/core/test_forecaster.py b/tests/core/test_forecaster.py index 9079255..07402c2 100644 --- a/tests/core/test_forecaster.py +++ b/tests/core/test_forecaster.py @@ -308,3 +308,40 @@ def test_detect_anomalies_short_series_error(): ) with pytest.raises(ValueError, match="Cannot perform anomaly detection"): model.detect_anomalies(df, h=5, freq="D") + + +def test_validate_input(): + df = generate_series(n_series=1, freq="D", min_length=10, max_length=10) + DummyModel().validate_input(df, h=2) + + +@pytest.mark.parametrize( + "df,h,match", + [ + ( + pd.DataFrame({"ds": pd.date_range("2023-01-01", periods=2, freq="D")}), + 2, + "missing required columns", + ), + (pd.DataFrame(), 2, "missing required columns"), + ( + pd.DataFrame(columns=["unique_id", "ds", "y"]), + 2, + "must contain at least one row", + ), + ("not a dataframe", 2, "must be a pandas DataFrame"), + ( + generate_series(n_series=1, freq="D", min_length=5, max_length=5), + 0, + "h must be a positive integer", + ), + ( + generate_series(n_series=1, freq="D", min_length=5, max_length=5), + -1, + "h must be a positive integer", + ), + ], +) +def test_validate_input_errors(df, h, match): + with pytest.raises(ValueError, match=match): + DummyModel().validate_input(df, h) diff --git a/tests/test_foundation_forecast.py b/tests/test_foundation_forecast.py index 3d9019c..0cf16f7 100644 --- a/tests/test_foundation_forecast.py +++ b/tests/test_foundation_forecast.py @@ -184,3 +184,15 @@ def test_foundation_forecast_duplicate_aliases_with_moirai(): ValueError, match="Duplicate model aliases found: \\['Moirai'\\]" ): FoundationForecast(models=[model1, model2]) + + +def test_foundation_forecast_rejects_empty_models(): + with pytest.raises(ValueError, match="At least one model is required"): + FoundationForecast(models=[]) + + +def test_foundation_forecast_validates_input(): + df = generate_series(n_series=1, freq="D", min_length=5, max_length=5) + forecaster = FoundationForecast(models=[DummyModel()]) + with pytest.raises(ValueError, match="h must be a positive integer"): + forecaster.forecast(df=df, h=0, freq="D")