Source code for VeraGridEngine.Simulations.Stochastic.stochastic_power_flow_input

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
from __future__ import annotations

import numpy as np
from sklearn.neighbors import KNeighborsRegressor

from VeraGridEngine.Simulations.Stochastic.latin_hypercube_sampling import lhs
from VeraGridEngine.Devices.multi_circuit import MultiCircuit
from VeraGridEngine.basic_structures import CDF, CxVec, CxMat


[docs] class StochasticPowerFlowInput: """ StochasticPowerFlowInput """ __slots__ = ( "n", "Scdf_fixed", "regression_model", "_has_profile_samples", ) def __init__(self, grid: MultiCircuit) -> None: """ Build the stochastic power flow input sampler. :param grid: MultiCircuit instance. """ # The number of buses determines the number of stochastic coordinates # that every generated operating point must contain. self.n: int = grid.get_bus_number() # The fixed and dispatchable time-series matrices are sampled together # because the regression model learns how dispatchable injections track # the fixed net demand profile. Sprof_fixed: CxMat = grid.get_Sbus_prof_fixed() Sprof_dispatcheable: CxMat = grid.get_Sbus_prof_dispatchable() # The bus axis must remain aligned with the network size. This check is # kept explicit because older bugs produced mismatched profile shapes. assert Sprof_fixed.shape[1] == self.n # The stochastic study needs at least one time-series row. When there # are no rows, the object stays in a non-sampleable state so callers # can stop cleanly instead of reaching a scikit-learn validation error. self._has_profile_samples: bool = Sprof_fixed.shape[0] > 0 if self._has_profile_samples: # Each bus gets its own empirical distribution so the sampled fixed # injections preserve the marginal distribution of the input data. self.Scdf_fixed: list[CDF] = list() for bus_index in range(self.n): self.Scdf_fixed.append(CDF(Sprof_fixed[:, bus_index])) # The regression model reconstructs the dispatchable contribution # from the sampled fixed demand profile for each Monte Carlo point. self.regression_model: KNeighborsRegressor | None = KNeighborsRegressor(n_neighbors=4) self.regression_model.fit(Sprof_fixed.real, Sprof_dispatcheable.real) else: self.Scdf_fixed = list() self.regression_model = None
[docs] def has_profile_samples(self) -> bool: """ Check whether the input contains at least one time-series sample. :return: ``True`` when stochastic samples can be generated. """ return self._has_profile_samples
[docs] def get(self, n_samples: int = 0, use_latin_hypercube: bool = False) -> CxMat: """ Generate stochastic samples for the grid injections. :param n_samples: number of samples :param use_latin_hypercube: use Latin Hypercube to sample :return: CxMat (p.u.) """ if n_samples == 0: raise Exception('Cannot have zero samples :(') else: pass if self.has_profile_samples(): pass else: raise ValueError('Stochastic power flow requires at least one time-series sample.') if use_latin_hypercube: lhs_points: np.ndarray = lhs(self.n, samples=n_samples, criterion='center') S_fixed: CxMat = np.zeros((n_samples, self.n), dtype=complex) for i in range(self.n): if self.Scdf_fixed[i] is not None: S_fixed[:, i] = self.Scdf_fixed[i].get_at(lhs_points[:, i]) else: pass else: S_fixed = np.zeros((n_samples, self.n), dtype=complex) for i in range(self.n): if self.Scdf_fixed[i] is not None: S_fixed[:, i] = self.Scdf_fixed[i].get_sample(n_samples) else: pass if self.regression_model is not None: # The dispatchable injections are inferred from the fixed demand # sample so the generated operating point preserves the empirical # dependence observed in the input profiles. S_dispatchable: np.ndarray = self.regression_model.predict(S_fixed.real) else: raise ValueError('Stochastic power flow regression model is not available.') # The generated dispatchable production is rescaled so that each sample # balances the sampled fixed demand at system level. for t in range(S_fixed.shape[0]): demand: float = -S_fixed[t, :].sum().real generation: float = S_dispatchable[t, :].sum() factor: float = demand / generation S_dispatchable[t, :] *= factor return S_fixed + S_dispatchable
[docs] def get_at(self, x: np.ndarray) -> CxVec: """ Get samples at x :param x: values in [0, 1] to sample the CDF :return: CxVec """ S: CxVec = np.zeros(self.n, dtype=complex) for i in range(self.n): if self.Scdf_fixed[i] is not None: S[i] = self.Scdf_fixed[i].get_at(x[i]) else: pass return S
def __call__(self, samples: int = 0, use_latin_hypercube: bool = False) -> CxMat: """ Proxy the object call to :meth:`get`. :param samples: Number of samples to generate. :param use_latin_hypercube: Whether to use Latin Hypercube sampling. :return: Generated stochastic injections. """ return self.get(n_samples=samples, use_latin_hypercube=use_latin_hypercube)