Source code for windkit.ltc.mcp

# (c) 2026 DTU Wind Energy
"""
Measure-Correlate-Predict MCP Module.
"""

import numpy as np

from ..wind import wd_to_sector
from .._errors import WindkitValidationError
from ._validation import assert_timesteps_equal, assert_valid_values, ltc_validate
from .regression import LinearRegression, VarianceRatioLinearRegression


class MCPBase:
    """Sectorwise Regressor base-class used as a blueprint
    for sectorwise MCP methods based on regression models.

    Parameters
    ----------
    n_sectors : int
        Number of sectors to fit independant regression models to.
        Default is 12 sectors.
    quantiles : bool
        Whether to use a equal probability sectors or constant
        sector widths. Default is to use constant sector widths.
        Equal probability sectors are computed on the reference data
        passed to ``fit``, pooled over all points, and sector 0 starts
        at north rather than being centred on it.

    Attributes
    ----------
    sector_edges : numpy.ndarray or None
        Interior sector cut points cached by :py:meth:`wd_to_sector` and
        reused at predict time: ``n_sectors`` ceilings for constant width
        sectors, ``n_sectors - 1`` interior edges for equal probability
        sectors. ``None`` until the first call.

    """

    def __init__(self, n_sectors=12, quantiles=False):
        self.n_sectors = n_sectors
        self.quantiles = quantiles
        self.sector_edges = None

    def wd_to_sector(self, wd, recalc_sectors=False):
        """
        Convert wind direction to sector indices using the object's
        n_sectors and quantiles settings.

        The sector cut points are computed on ``wd`` when ``recalc_sectors``
        is True or no cut points are cached yet, and cached on the object.
        Later calls reuse the cached cut points, so predictions are binned
        with the sectors the model was fitted with.

        Parameters
        ----------
        wd : array, float, shape (n_samples,)
            Wind directions.
        recalc_sectors : bool
            Recompute and cache the sector cut points from ``wd``.
            Default is False.

        Returns
        -------
        array, float, shape (n_samples, )
            Wind direction sector indices, NaN where ``wd`` is not finite.

        """
        if recalc_sectors or self.sector_edges is None:
            _, sector_coords = wd_to_sector(
                wd,
                sectors=self.n_sectors,
                output_type="indices",
                quantiles=self.quantiles,
            )
            if self.quantiles:
                # Quantile sector 0 starts at north, so the cuts are the
                # floors of sectors 1..n_sectors-1 and no wrap-around is needed.
                self.sector_edges = sector_coords.sector_floor.values[1:]
            else:
                # Constant width sector 0 straddles north, so every ceiling is a
                # cut and the last bin wraps back onto sector 0 via the modulo.
                self.sector_edges = sector_coords.sector_ceil.values

        # Fit and predict both bin against the cached cuts, so they agree by
        # construction. Wind directions are in [0, 360], so np.digitize returns
        # values in [0, len(self.sector_edges)]; the modulo only acts for constant
        # width sectors, where wd >= last edge maps back to sector 0 (North).
        wd = np.asarray(wd, dtype=np.float64)
        sector = (np.digitize(wd, self.sector_edges) % self.n_sectors).astype(
            np.float64
        )
        sector[~np.isfinite(wd)] = np.nan
        return sector


class MCPRegressor(MCPBase):
    """
    Sectorwise Regressor base-class used as a blueprint
    for sectorwise MCP methods based on regression models.

    Parameters
    ----------
    ws_cutoff : float
    model_kws : dict
        kwargs to input when the model is instanciated.

    Attributes
    ----------
    model: what model to use in each sector
    models : list, shape (n_sectors,)
        one instanciated model per sector
    """

    model = None

    def __init__(self, ws_cutoff=0.0, model_kws=None, **kwargs):
        super().__init__(**kwargs)

        if model_kws is None:
            model_kws = {}
        self.ws_cutoff = ws_cutoff
        self.model_kws = model_kws

        self.models_ = [self.model(**model_kws) for _ in range(self.n_sectors)]

    @ltc_validate(n_args=2)
    def fit(self, ds_ref, ds_target, **kwargs):
        """
        Fit the reference data to the target data by looping
        through the sectors and fitting the model to the
        data for that sector.

        Parameters
        ----------
        ds_ref: xarray.Dataset
            Windkit time series wind climate dataset with wind speed
            and wind direction for the reference data.
        ds_target: xarray.Dataset
            Windkit time series wind climate dataset with wind speed
            and wind direction for the reference data.

        Returns
        -------
        self : returns an instance of self.

        """
        # check valid values
        assert_valid_values(ds_ref)
        assert_valid_values(ds_target)
        assert_timesteps_equal(ds_ref, ds_target)

        sector_ref = self.wd_to_sector(
            ds_ref.wind_direction.values.flatten(), recalc_sectors=True
        )

        for i_sec in range(self.n_sectors):
            x = ds_ref.wind_speed.values.flatten()
            y = ds_target.wind_speed.values.flatten()

            mask_sec = sector_ref == i_sec
            x = x[mask_sec]
            y = y[mask_sec]

            mask_na = np.isnan(x) | np.isnan(y)
            x = x[~mask_na]
            y = y[~mask_na]

            mask_ws = x < self.ws_cutoff
            x = x[~mask_ws]
            y = y[~mask_ws]

            X = x[:, np.newaxis]

            self.models_[i_sec].fit(X, y)

        return self

    @ltc_validate(n_args=1)
    def predict(self, ds_ref, return_std=False, **kwargs):
        """Predict wind speed from the reference data.

        Loops through sectors and predicts with the sector-specific model.

        Parameters
        ----------
        ds_ref : xarray.Dataset
            Time series wind climate dataset.
        return_std : bool
            If True, return a tuple ``(ds_pred, std)`` where ``std`` is a
            flat array of residual standard deviations (one per element in
            the flattened wind speed array) mapped from the sector models.
            Default is False.

        Returns
        -------
        xarray.Dataset or tuple of (xarray.Dataset, numpy.ndarray)
            Predicted wind speeds and directions.  If *return_std* is True,
            a tuple ``(ds_pred, std)`` is returned instead.
        """

        assert_valid_values(ds_ref)
        ws_pred = np.full_like(
            ds_ref.wind_speed.values.flatten(), np.nan, dtype=np.float64
        )
        ds_pred = ds_ref.copy()

        sector_ref = self.wd_to_sector(
            ds_ref.wind_direction.values.flatten(), recalc_sectors=False
        )

        if return_std:
            if not getattr(self.models_[0], "supports_return_std", False):
                raise NotImplementedError(
                    f"return_std is not supported for {type(self).__name__} "
                    f"because {type(self.models_[0]).__name__} does not "
                    f"compute residual_std during fit()."
                )
            std_pred = np.full_like(ws_pred, np.nan)

        for i_sec in range(self.n_sectors):
            x = ds_ref.wind_speed.values.flatten()

            mask_sec = sector_ref == i_sec
            mask_na = np.isnan(x)
            mask = mask_sec & ~mask_na

            X = x[mask, np.newaxis]

            ws_pred[mask] = self.models_[i_sec].predict(X)

            if return_std:
                std_pred[mask] = self.models_[i_sec].residual_std

        ds_pred["wind_speed"].values = ws_pred.reshape(ds_pred.wind_speed.shape)

        if return_std:
            return ds_pred, std_pred

        return ds_pred


[docs] class LinRegMCP(MCPRegressor): """Sectorwise MCP using Scipy's linear regression. Parameters ---------- n_sectors : int Number of sectors to fit independant regression models to. Default is 12 sectors. quantiles : bool Use equal probability sectors instead of constant widths. The sector edges are the quantiles of the reference wind directions passed to ``fit``, pooled over all points, and are reused at ``predict``. Sector 0 starts at north rather than being centred on it. Default is False. ws_cutoff : float model_kws : dict kwargs to input when the model is instanciated. Attributes ---------- model: what model to use in each sector models : list, shape (n_sectors,) one instanciated model per sector """ model = LinearRegression
[docs] def __init__(self, ws_cutoff=0.0, **kwargs): super().__init__(ws_cutoff=ws_cutoff, **kwargs)
[docs] def predict_with_noise(self, ds_ref, seed=0): """Predict wind speed with added Gaussian noise. Calls :meth:`predict` to obtain the deterministic prediction and the per-timestep residual standard deviation, then samples noise from ``N(0, residual_std)`` and adds it to the predicted wind speed. Negative wind speeds are clipped to zero. Parameters ---------- ds_ref : xarray.Dataset Time series wind climate dataset. seed : int Random seed for reproducibility. Default is 0. Returns ------- xarray.Dataset Predicted wind speeds with added noise. Raises ------ WindkitValidationError If any sector has an undefined residual standard deviation (too few training samples). """ unfitted = [i for i, m in enumerate(self.models_) if m.residual_std is None] if unfitted: raise WindkitValidationError( "Cannot add noise: model has not been fitted yet." ) nan_sectors = [ i for i, m in enumerate(self.models_) if np.isnan(m.residual_std) ] if nan_sectors: raise WindkitValidationError( f"Cannot add noise: sectors {nan_sectors} have undefined " f"residual_std (too few training samples)." ) ds_pred, std = self.predict(ds_ref, return_std=True) rng = np.random.default_rng(seed) noise = rng.normal(0, std) ws_noisy = ds_pred.wind_speed.values.flatten() + noise ws_noisy = np.clip(ws_noisy, 0, None) ds_pred["wind_speed"].values = ws_noisy.reshape(ds_pred.wind_speed.shape) return ds_pred
[docs] class VarRatMCP(MCPRegressor): """ Sectorwise MCP using variance ratio linear regression. Parameters ---------- n_sectors : int Number of sectors to fit independant regression models to. Default is 12 sectors. quantiles : bool Use equal probability sectors instead of constant widths. The sector edges are the quantiles of the reference wind directions passed to ``fit``, pooled over all points, and are reused at ``predict``. Sector 0 starts at north rather than being centred on it. Default is False. ws_cutoff : float model_kws : dict kwargs to input when the model is instanciated. Attributes ---------- model: what model to use in each sector models : list, shape (n_sectors,) one instanciated model per sector """ model = VarianceRatioLinearRegression
[docs] def __init__(self, fit_intercept=True, ws_cutoff=0.0, **kwargs): model_kws = {"fit_intercept": fit_intercept} super().__init__(model_kws=model_kws, ws_cutoff=ws_cutoff, **kwargs)