mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-09 18:36:11 +00:00
Add a PV forecast provider that calculates the forecast using a PVLib system model and weather forecast from the EOS weather forecast provider. Additional module and inverter models can be easily added as the database is build from PVLib and SAM databases and a bundled csv file. The module model and inververt model names are provided by new endpoints to be used in configuration. The provider is based on the fantastic work of EMHASS. See https://github.com/davidusb-geek/emhass/blob/master/src/emhass/forecast.py A short description of the provider is added to the documentation. Besides the new features there are the fixes and improvements: * feat: improve EOSdash config page * fix: kex_to_series for start_datetime Make key_to_series always start the series at start_datetime. * fix: default provider for GENETIC and GENETIC0 optimization To make the default less dependent on internet servers (with API changes and availability issues) the default for PVForecast is set to PVForecastPVLib and for ElecPrice to ElecPriceFixed. The default weather provider is changed to OpenMeteo. * fix: EOSdash display resampled prediction values Make EOSdash display resampled prediction values where resampling fits to the prediction value type. Use bar width that fits to 15 minutes value samples. * chore: add a UI hints system to EOSdash The UI hints system eases the definition of forms for configuration items. There are also forms for items in maps and lists. These forms allow to add and delete items to/ from maps and lists. The forms ensure that all required fields of newly added items are filled. * chore: Create an enum for valid optimization algorithms * chore. Make config also provide the available energy management modes. Used for configuration hints. * chore: Randomize default device id in configuration Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
1716 lines
44 KiB
Python
1716 lines
44 KiB
Python
|
|
"""Tests for PVForecastPVLib prediction provider."""
|
|
|
|
import bz2
|
|
import pickle
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from akkudoktoreos.core.cache import CacheFileStore
|
|
from akkudoktoreos.prediction.prediction import Prediction
|
|
from akkudoktoreos.prediction.pvforecastpvlib import (
|
|
PVForecastPVLib,
|
|
PVForecastPVLibCommonSettings,
|
|
_cec_cache,
|
|
_cec_inverters,
|
|
_cec_inverters_path,
|
|
_cec_modules,
|
|
_cec_modules_path,
|
|
_load_cec_database,
|
|
)
|
|
from akkudoktoreos.utils.datetimeutil import to_duration
|
|
|
|
DIR_TESTDATA = Path(__file__).parent / "testdata" / "pvforecastpvlib"
|
|
|
|
FILE_TESTDATA_CEC_INVERTERS_PBZ2 = DIR_TESTDATA / "cec_inverters.pbz2"
|
|
FILE_TESTDATA_CEC_MODULES_PBZ2 = DIR_TESTDATA / "cec_modules.pbz2"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# conftest for cec_databases fixture
|
|
|
|
@pytest.fixture
|
|
def provider(monkeypatch, config_eos):
|
|
"""Create a fresh PVForecastPVLib provider."""
|
|
|
|
monkeypatch.setenv(
|
|
"EOS_PVFORECAST__PVFORECAST_PROVIDER",
|
|
"PVForecastPVLib",
|
|
)
|
|
|
|
PVForecastPVLib.reset_instance()
|
|
return PVForecastPVLib()
|
|
|
|
@pytest.fixture
|
|
def cache_store():
|
|
"""Provide a fresh cache store."""
|
|
return CacheFileStore()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_database_cache():
|
|
"""Clear global CEC cache before every test."""
|
|
_cec_cache.clear()
|
|
yield
|
|
_cec_cache.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_settings_4planes(config_eos):
|
|
"""Fixture that adds settings data to the global config."""
|
|
settings = {
|
|
"general": {
|
|
"latitude": 52.52,
|
|
"longitude": 13.405,
|
|
},
|
|
"prediction": {
|
|
"hours": 48,
|
|
"historic_hours": 24,
|
|
},
|
|
"pvforecast": {
|
|
"provider": "PVForecastPVLib",
|
|
"max_planes": 4,
|
|
"planes": [
|
|
{
|
|
"surface_tilt": 7,
|
|
"surface_azimuth": 170,
|
|
"userhorizon": [20, 27, 22, 20],
|
|
"peakpower": 5.0,
|
|
"module_model": "AXITEC_AC_410MH_144S",
|
|
"inverter_model": "Sungrow__SH25T",
|
|
"inverter_paco": 10000,
|
|
"modules_per_string": 12,
|
|
"strings_per_inverter": 1,
|
|
},
|
|
{
|
|
"surface_tilt": 7,
|
|
"surface_azimuth": 90,
|
|
"userhorizon": [30, 30, 30, 50],
|
|
"peakpower": 4.8,
|
|
"module_model": "AXITEC_AC_410MH_144S",
|
|
"inverter_model": "Sungrow__SH25T",
|
|
"inverter_paco": 10000,
|
|
"modules_per_string": 12,
|
|
"strings_per_inverter": 1,
|
|
},
|
|
{
|
|
"surface_tilt": 60,
|
|
"surface_azimuth": 140,
|
|
"userhorizon": [60, 30, 0, 30],
|
|
"peakpower": 1.4,
|
|
"module_model": "AXITEC_AC_410MH_144S",
|
|
"inverter_model": "Sungrow__SH25T",
|
|
"inverter_paco": 2000,
|
|
"modules_per_string": 5,
|
|
"strings_per_inverter": 1,
|
|
},
|
|
{
|
|
"surface_tilt": 45,
|
|
"surface_azimuth": 185,
|
|
"userhorizon": [45, 25, 30, 60],
|
|
"peakpower": 1.6,
|
|
"module_model": "AXITEC_AC_410MH_144S",
|
|
"inverter_model": "Sungrow__SH25T",
|
|
"inverter_paco": 1400,
|
|
"modules_per_string": 4,
|
|
"strings_per_inverter": 1,
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
# Merge settings to config
|
|
config_eos.merge_settings_from_dict(settings)
|
|
assert config_eos.pvforecast.provider == "PVForecastPVLib"
|
|
return config_eos
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_settings_1plane(config_eos):
|
|
"""Fixture that adds settings data to the global config."""
|
|
settings = {
|
|
"general": {
|
|
"latitude": 52.52,
|
|
"longitude": 13.405,
|
|
},
|
|
"prediction": {
|
|
"hours": 48,
|
|
"historic_hours": 24,
|
|
},
|
|
"pvforecast": {
|
|
"provider": "PVForecastPVLib",
|
|
"planes": [
|
|
{
|
|
"surface_tilt": 7,
|
|
"surface_azimuth": 170,
|
|
"userhorizon": [20, 27, 22, 20],
|
|
"peakpower": 5.0,
|
|
"module_model": "AXITEC_AC_410MH_144S",
|
|
"inverter_model": "Sungrow__SH25T",
|
|
"inverter_paco": 10000,
|
|
"modules_per_string": 12,
|
|
"strings_per_inverter": 1,
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
# Merge settings to config
|
|
config_eos.merge_settings_from_dict(settings)
|
|
assert config_eos.pvforecast.provider == "PVForecastPVLib"
|
|
return config_eos
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Database
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLoadUpdateCECDatabase:
|
|
"""Test for CEC databases."""
|
|
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib._cec_inverters_path",
|
|
)
|
|
def test_load_cec_inverters_database(
|
|
self,
|
|
mock_cec_inverters_path,
|
|
config_eos,
|
|
):
|
|
"""Test generated inverter database looks sane."""
|
|
|
|
mock_cec_inverters_path.return_value = FILE_TESTDATA_CEC_INVERTERS_PBZ2
|
|
|
|
inverters = _cec_inverters()
|
|
|
|
assert "Sungrow__SH25T" in inverters
|
|
|
|
inverter = inverters["Sungrow__SH25T"]
|
|
|
|
required = [
|
|
"Paco",
|
|
"Pdco",
|
|
"Vdco",
|
|
"Pso",
|
|
"Mppt_low",
|
|
"Mppt_high",
|
|
]
|
|
|
|
for key in required:
|
|
assert key in inverter.index
|
|
assert pd.notna(inverter[key]), f"{key} is NaN"
|
|
|
|
assert inverter["Paco"] > 0
|
|
assert inverter["Pdco"] > 0
|
|
assert inverter["Vdco"] > 0
|
|
assert inverter["Mppt_low"] < inverter["Mppt_high"]
|
|
|
|
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib._cec_modules_path",
|
|
)
|
|
def test_load_cec_modules_database(
|
|
self,
|
|
mock_cec_modules_path,
|
|
config_eos,
|
|
):
|
|
"""Test generated module database looks sane."""
|
|
|
|
mock_cec_modules_path.return_value = FILE_TESTDATA_CEC_MODULES_PBZ2
|
|
|
|
modules = _cec_modules()
|
|
|
|
assert "AXITEC_AC_410MH_144S" in modules
|
|
|
|
module = modules["AXITEC_AC_410MH_144S"]
|
|
|
|
#
|
|
# Required CEC parameters
|
|
#
|
|
required = [
|
|
"STC",
|
|
"PTC",
|
|
"I_sc_ref",
|
|
"V_oc_ref",
|
|
"I_mp_ref",
|
|
"V_mp_ref",
|
|
"a_ref",
|
|
"I_L_ref",
|
|
"I_o_ref",
|
|
"R_s",
|
|
"R_sh_ref",
|
|
"Adjust",
|
|
"alpha_sc",
|
|
"beta_oc",
|
|
"gamma_pmp",
|
|
"N_s",
|
|
]
|
|
for key in required:
|
|
assert key in module.index
|
|
assert pd.notna(module[key]), f"{key} is NaN"
|
|
|
|
#
|
|
# Numeric CEC parameters
|
|
#
|
|
numeric = [
|
|
"STC",
|
|
"I_sc_ref",
|
|
"V_oc_ref",
|
|
"I_mp_ref",
|
|
"V_mp_ref",
|
|
"a_ref",
|
|
"I_L_ref",
|
|
"I_o_ref",
|
|
"R_s",
|
|
"R_sh_ref",
|
|
]
|
|
for key in numeric:
|
|
assert isinstance(module[key], (int, float))
|
|
|
|
#
|
|
# Physical sanity checks
|
|
#
|
|
assert module["STC"] > 0
|
|
assert module["PTC"] > 0
|
|
|
|
assert module["V_mp_ref"] > 0
|
|
assert module["I_mp_ref"] > 0
|
|
assert module["V_oc_ref"] > module["V_mp_ref"]
|
|
assert module["I_sc_ref"] > module["I_mp_ref"]
|
|
|
|
assert module["a_ref"] > 0
|
|
assert module["I_L_ref"] > 0
|
|
assert module["I_o_ref"] > 0
|
|
assert module["R_s"] >= 0
|
|
assert module["R_sh_ref"] > 0
|
|
|
|
#
|
|
# STC consistency (within ~5%)
|
|
#
|
|
assert abs(
|
|
module["V_mp_ref"] * module["I_mp_ref"] - module["STC"]
|
|
) < 0.05 * module["STC"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPVForecastPVLibCommonSettings:
|
|
"""Tests for PVForecastPVLibCommonSettings."""
|
|
|
|
def test_create_settings(self):
|
|
"""Settings object can be created."""
|
|
settings = PVForecastPVLibCommonSettings()
|
|
|
|
assert settings is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Database
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCECDatabase:
|
|
"""Tests for CEC database handling."""
|
|
|
|
def test_modules_database_path(self, config_eos):
|
|
"""Module database path ends with expected filename."""
|
|
|
|
path = _cec_modules_path()
|
|
|
|
assert path.name == "cec_modules.pbz2"
|
|
|
|
def test_inverters_database_path(self, config_eos):
|
|
"""Inverter database path ends with expected filename."""
|
|
|
|
path = _cec_inverters_path()
|
|
|
|
assert path.name == "cec_inverters.pbz2"
|
|
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.pickle.load",
|
|
)
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.bz2.BZ2File",
|
|
)
|
|
def test_load_database(
|
|
self,
|
|
mock_bz2,
|
|
mock_pickle,
|
|
tmp_path,
|
|
):
|
|
"""Database is loaded from pickle."""
|
|
|
|
database = pd.DataFrame({"A": [1]})
|
|
|
|
mock_pickle.return_value = database
|
|
|
|
path = tmp_path / "db.pbz2"
|
|
|
|
path.touch()
|
|
|
|
loaded = _load_cec_database(path)
|
|
|
|
assert loaded is database
|
|
|
|
mock_pickle.assert_called_once()
|
|
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.pickle.load",
|
|
)
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.bz2.BZ2File",
|
|
)
|
|
def test_load_database_uses_cache(
|
|
self,
|
|
mock_bz2,
|
|
mock_pickle,
|
|
tmp_path,
|
|
):
|
|
"""Loading same database twice uses memory cache."""
|
|
|
|
database = pd.DataFrame({"A": [1]})
|
|
|
|
mock_pickle.return_value = database
|
|
|
|
path = tmp_path / "db.pbz2"
|
|
|
|
path.touch()
|
|
|
|
db1 = _load_cec_database(path)
|
|
db2 = _load_cec_database(path)
|
|
|
|
assert db1 is db2
|
|
|
|
mock_pickle.assert_called_once()
|
|
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib._update_cec_database",
|
|
)
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.pickle.load",
|
|
)
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.bz2.BZ2File",
|
|
)
|
|
def test_missing_database_creates_database(
|
|
self,
|
|
mock_bz2,
|
|
mock_pickle,
|
|
mock_update,
|
|
tmp_path,
|
|
):
|
|
"""Missing database triggers recreation."""
|
|
|
|
database = pd.DataFrame({"A": [1]})
|
|
|
|
mock_pickle.return_value = database
|
|
|
|
path = tmp_path / "missing.pbz2"
|
|
|
|
#
|
|
# Simulate creation by update routine.
|
|
#
|
|
def create_database():
|
|
path.touch()
|
|
|
|
mock_update.side_effect = create_database
|
|
|
|
loaded = _load_cec_database(path)
|
|
|
|
assert loaded is database
|
|
|
|
mock_update.assert_called_once()
|
|
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.pickle.load",
|
|
)
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.bz2.BZ2File",
|
|
)
|
|
def test_cache_contains_loaded_database(
|
|
self,
|
|
mock_bz2,
|
|
mock_pickle,
|
|
tmp_path,
|
|
):
|
|
"""Database is inserted into cache after loading."""
|
|
|
|
database = pd.DataFrame({"A": [1]})
|
|
|
|
mock_pickle.return_value = database
|
|
|
|
path = tmp_path / "db.pbz2"
|
|
|
|
path.touch()
|
|
|
|
_load_cec_database(path)
|
|
|
|
assert path in _cec_cache
|
|
|
|
_, cached = _cec_cache[path]
|
|
|
|
assert cached is database
|
|
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.pickle.load",
|
|
)
|
|
@patch(
|
|
"akkudoktoreos.prediction.pvforecastpvlib.bz2.BZ2File",
|
|
)
|
|
def test_reload_when_timestamp_changes(
|
|
self,
|
|
mock_bz2,
|
|
mock_pickle,
|
|
tmp_path,
|
|
):
|
|
"""Changing mtime forces reload."""
|
|
|
|
db1 = pd.DataFrame({"A": [1]})
|
|
db2 = pd.DataFrame({"A": [2]})
|
|
|
|
mock_pickle.side_effect = [
|
|
db1,
|
|
db2,
|
|
]
|
|
|
|
path = tmp_path / "db.pbz2"
|
|
|
|
path.touch()
|
|
|
|
first = _load_cec_database(path)
|
|
|
|
#
|
|
# Force new modification time.
|
|
#
|
|
path.touch()
|
|
|
|
second = _load_cec_database(path)
|
|
|
|
assert first is db1
|
|
assert second is db2
|
|
|
|
assert mock_pickle.call_count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper methods
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHelpers:
|
|
"""Tests for helper methods."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("device_type", "params", "expected"),
|
|
[
|
|
(
|
|
"module",
|
|
{"STC": 410.0},
|
|
410.0,
|
|
),
|
|
(
|
|
"module",
|
|
{
|
|
"I_mp_ref": 10.0,
|
|
"V_mp_ref": 40.0,
|
|
},
|
|
400.0,
|
|
),
|
|
(
|
|
"inverter",
|
|
{"Paco": 5000.0},
|
|
5000.0,
|
|
),
|
|
(
|
|
"inverter",
|
|
{"Pdco": 5100.0},
|
|
5100.0,
|
|
),
|
|
(
|
|
"module",
|
|
{},
|
|
None,
|
|
),
|
|
(
|
|
"inverter",
|
|
{},
|
|
None,
|
|
),
|
|
(
|
|
"unknown",
|
|
{},
|
|
None,
|
|
),
|
|
],
|
|
)
|
|
def test_get_model_power(
|
|
self,
|
|
provider,
|
|
device_type,
|
|
params,
|
|
expected,
|
|
):
|
|
"""Test extraction of power from model parameters."""
|
|
|
|
assert (
|
|
provider._get_model_power(
|
|
params,
|
|
device_type,
|
|
)
|
|
== expected
|
|
)
|
|
|
|
@patch("akkudoktoreos.prediction.pvforecastpvlib.logger.warning")
|
|
def test_warn_once(self, mock_warning, provider):
|
|
"""A warning shall only be logged once."""
|
|
provider._warned_features.clear()
|
|
|
|
provider._warn_once("feature1", "test warning")
|
|
provider._warn_once("feature1", "test warning")
|
|
|
|
mock_warning.assert_called_once_with("test warning")
|
|
|
|
@patch("akkudoktoreos.prediction.pvforecastpvlib.logger.warning")
|
|
def test_warn_twice_for_different_features(self, mock_warning, provider):
|
|
"""Different features shall each generate one warning."""
|
|
provider._warned_features.clear()
|
|
|
|
provider._warn_once("feature1", "test warning1")
|
|
provider._warn_once("feature2", "test warning2")
|
|
|
|
assert mock_warning.call_count == 2
|
|
|
|
def test_find_closest_module(self, provider):
|
|
"""Closest module shall be selected."""
|
|
|
|
database = pd.DataFrame(
|
|
{
|
|
"ModuleA": {
|
|
"STC": 400.0,
|
|
},
|
|
"ModuleB": {
|
|
"STC": 425.0,
|
|
},
|
|
"ModuleC": {
|
|
"STC": 600.0,
|
|
},
|
|
}
|
|
)
|
|
|
|
model = provider._find_closest_model(
|
|
430.0,
|
|
database,
|
|
"module",
|
|
)
|
|
|
|
assert model is not None
|
|
assert model.name == "ModuleB"
|
|
|
|
def test_find_closest_inverter(self, provider):
|
|
"""Closest inverter shall be selected."""
|
|
|
|
database = pd.DataFrame(
|
|
{
|
|
"InvA": {
|
|
"Paco": 3000,
|
|
},
|
|
"InvB": {
|
|
"Paco": 5000,
|
|
},
|
|
"InvC": {
|
|
"Paco": 8000,
|
|
},
|
|
}
|
|
)
|
|
|
|
model = provider._find_closest_model(
|
|
5200,
|
|
database,
|
|
"inverter",
|
|
)
|
|
|
|
assert model is not None
|
|
assert model.name == "InvB"
|
|
|
|
def test_find_closest_returns_none(self, provider):
|
|
"""No suitable model returns None."""
|
|
|
|
database = pd.DataFrame(
|
|
{
|
|
"ModuleA": {
|
|
"Foo": 1,
|
|
},
|
|
"ModuleB": {
|
|
"Bar": 2,
|
|
},
|
|
}
|
|
)
|
|
|
|
model = provider._find_closest_model(
|
|
400,
|
|
database,
|
|
"module",
|
|
)
|
|
|
|
assert model is None
|
|
|
|
def test_get_model_by_name(self, provider):
|
|
"""Retrieve model by exact name."""
|
|
|
|
database = {
|
|
"ModuleA": {
|
|
"STC": 400,
|
|
},
|
|
"ModuleB": {
|
|
"STC": 500,
|
|
},
|
|
}
|
|
|
|
model = provider._get_model(
|
|
"ModuleB",
|
|
database,
|
|
"module",
|
|
)
|
|
|
|
assert model["STC"] == 500
|
|
|
|
def test_get_model_by_numeric_string(self, provider):
|
|
"""Numeric strings shall search nearest model."""
|
|
|
|
database = pd.DataFrame(
|
|
{
|
|
"ModuleA": {
|
|
"STC": 400,
|
|
},
|
|
"ModuleB": {
|
|
"STC": 450,
|
|
},
|
|
}
|
|
)
|
|
|
|
model = provider._get_model(
|
|
"430",
|
|
database,
|
|
"module",
|
|
)
|
|
|
|
assert model.name == "ModuleB"
|
|
|
|
def test_get_model_by_integer(self, provider):
|
|
"""Integer power shall search nearest model."""
|
|
|
|
database = pd.DataFrame(
|
|
{
|
|
"ModuleA": {
|
|
"STC": 300,
|
|
},
|
|
"ModuleB": {
|
|
"STC": 420,
|
|
},
|
|
}
|
|
)
|
|
|
|
model = provider._get_model(
|
|
410,
|
|
database,
|
|
"module",
|
|
)
|
|
|
|
assert model.name == "ModuleB"
|
|
|
|
def test_get_model_by_float(self, provider):
|
|
"""Float power shall search nearest model."""
|
|
|
|
database = pd.DataFrame(
|
|
{
|
|
"ModuleA": {
|
|
"STC": 410.0,
|
|
},
|
|
"ModuleB": {
|
|
"STC": 600.0,
|
|
},
|
|
}
|
|
)
|
|
|
|
model = provider._get_model(
|
|
405.0,
|
|
database,
|
|
"module",
|
|
)
|
|
|
|
assert model.name == "ModuleA"
|
|
|
|
def test_get_model_invalid_name(self, provider):
|
|
"""Unknown model names shall raise KeyError."""
|
|
|
|
database = {
|
|
"ModuleA": {
|
|
"STC": 300,
|
|
},
|
|
}
|
|
|
|
with pytest.raises(KeyError):
|
|
provider._get_model(
|
|
"UnknownModule",
|
|
database,
|
|
"module",
|
|
)
|
|
|
|
def test_get_model_invalid_type(self, provider):
|
|
"""Unsupported model specification returns None."""
|
|
|
|
database: dict = {}
|
|
|
|
model = provider._get_model(
|
|
["invalid"],
|
|
database,
|
|
"module",
|
|
)
|
|
|
|
assert model is None
|
|
|
|
def test_get_model_empty_database(self, provider):
|
|
"""Empty database returns None."""
|
|
|
|
database = pd.DataFrame()
|
|
|
|
model = provider._find_closest_model(
|
|
400,
|
|
database,
|
|
"module",
|
|
)
|
|
|
|
assert model is None
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static helper methods
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStaticMethods:
|
|
"""Tests for static helper methods."""
|
|
|
|
def test_add_cyclic_hour_features(self):
|
|
"""Hour sine/cosine features shall be added."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-01",
|
|
periods=24,
|
|
freq="h",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(index=index)
|
|
|
|
result = PVForecastPVLib.add_cyclic_hour_features(weather)
|
|
|
|
assert "hour_sin" in result.columns
|
|
assert "hour_cos" in result.columns
|
|
|
|
assert len(result) == 24
|
|
|
|
def test_add_cyclic_hour_features_preserves_dataframe(self):
|
|
"""Original weather columns shall be preserved."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-01",
|
|
periods=4,
|
|
freq="h",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [100, 200, 300, 400],
|
|
"temp_air": [10, 11, 12, 13],
|
|
},
|
|
index=index,
|
|
)
|
|
|
|
result = PVForecastPVLib.add_cyclic_hour_features(weather)
|
|
|
|
assert "ghi" in result.columns
|
|
assert "temp_air" in result.columns
|
|
assert "hour_sin" in result.columns
|
|
assert "hour_cos" in result.columns
|
|
|
|
def test_add_cyclic_hour_features_midnight(self):
|
|
"""Midnight encoding shall be (0,+1)."""
|
|
|
|
index = pd.DatetimeIndex(
|
|
[
|
|
"2024-06-01 00:00",
|
|
],
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(index=index)
|
|
|
|
result = PVForecastPVLib.add_cyclic_hour_features(weather)
|
|
|
|
assert abs(result.iloc[0]["hour_sin"]) < 1e-10
|
|
assert result.iloc[0]["hour_cos"] == pytest.approx(1.0)
|
|
|
|
def test_add_cyclic_hour_features_noon(self):
|
|
"""Noon encoding shall be (0,-1)."""
|
|
|
|
index = pd.DatetimeIndex(
|
|
[
|
|
"2024-06-01 12:00",
|
|
],
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(index=index)
|
|
|
|
result = PVForecastPVLib.add_cyclic_hour_features(weather)
|
|
|
|
assert abs(result.iloc[0]["hour_sin"]) < 1e-10
|
|
assert result.iloc[0]["hour_cos"] == pytest.approx(-1.0)
|
|
|
|
def test_add_cyclic_hour_features_requires_datetimeindex(self):
|
|
"""DatetimeIndex is required."""
|
|
|
|
weather = pd.DataFrame(index=[0, 1, 2])
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="DatetimeIndex",
|
|
):
|
|
PVForecastPVLib.add_cyclic_hour_features(weather)
|
|
|
|
def test_compute_solar_angles(self):
|
|
"""Solar angles shall be added."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-01",
|
|
periods=8,
|
|
freq="h",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(index=index)
|
|
|
|
result = PVForecastPVLib.compute_solar_angles(
|
|
weather,
|
|
latitude=48.0,
|
|
longitude=10.0,
|
|
)
|
|
|
|
assert "solar_azimuth" in result.columns
|
|
assert "solar_elevation" in result.columns
|
|
|
|
assert len(result) == len(weather)
|
|
|
|
def test_compute_solar_angles_preserves_columns(self):
|
|
"""Existing weather columns shall remain."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-01",
|
|
periods=3,
|
|
freq="h",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [0, 200, 800],
|
|
"dni": [0, 150, 700],
|
|
},
|
|
index=index,
|
|
)
|
|
|
|
result = PVForecastPVLib.compute_solar_angles(
|
|
weather,
|
|
latitude=48.0,
|
|
longitude=10.0,
|
|
)
|
|
|
|
assert "ghi" in result.columns
|
|
assert "dni" in result.columns
|
|
assert "solar_azimuth" in result.columns
|
|
assert "solar_elevation" in result.columns
|
|
|
|
def test_compute_solar_angles_elevation_range(self):
|
|
"""Solar elevation shall stay within physical limits."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-01",
|
|
periods=24,
|
|
freq="h",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(index=index)
|
|
|
|
result = PVForecastPVLib.compute_solar_angles(
|
|
weather,
|
|
latitude=48.0,
|
|
longitude=10.0,
|
|
)
|
|
|
|
assert (result["solar_elevation"] <= 90).all()
|
|
assert (result["solar_elevation"] >= -90).all()
|
|
|
|
def test_compute_solar_angles_azimuth_range(self):
|
|
"""Solar azimuth shall stay within physical limits."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-01",
|
|
periods=24,
|
|
freq="h",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(index=index)
|
|
|
|
result = PVForecastPVLib.compute_solar_angles(
|
|
weather,
|
|
latitude=48.0,
|
|
longitude=10.0,
|
|
)
|
|
|
|
assert (result["solar_azimuth"] >= 0).all()
|
|
assert (result["solar_azimuth"] <= 360).all()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provider
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProvider:
|
|
"""Tests for PVForecastPVLib provider."""
|
|
|
|
def test_provider_id(self, provider):
|
|
"""Provider shall return correct identifier."""
|
|
|
|
assert provider.provider_id() == "PVForecastPVLib"
|
|
|
|
def test_singleton_instance(self, provider):
|
|
"""Provider behaves as singleton."""
|
|
|
|
another = PVForecastPVLib()
|
|
|
|
assert provider is another
|
|
|
|
def test_enabled(self, config_eos, provider):
|
|
"""Provider shall be enabled by default."""
|
|
config_eos.pvforecast.provider = "PVForecastPVLib"
|
|
|
|
assert provider.enabled()
|
|
|
|
def test_disabled_when_wrong_provider(
|
|
self,
|
|
monkeypatch,
|
|
provider,
|
|
):
|
|
"""Wrong provider name disables provider."""
|
|
|
|
monkeypatch.setenv(
|
|
"EOS_PVFORECAST__PVFORECAST_PROVIDER",
|
|
"InvalidProvider",
|
|
)
|
|
|
|
provider.config.reset_settings()
|
|
|
|
assert not provider.enabled()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_without_planes(
|
|
self,
|
|
provider,
|
|
config_eos,
|
|
):
|
|
"""No PV planes shall raise."""
|
|
|
|
config_eos.pvforecast.planes = []
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="plane",
|
|
):
|
|
await provider.update_data(
|
|
force_enable=True,
|
|
force_update=True,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_requests_weather_dataframe(
|
|
self,
|
|
provider,
|
|
sample_settings_4planes,
|
|
):
|
|
"""Weather dataframe shall be requested with correct parameters."""
|
|
|
|
weather = pd.DataFrame()
|
|
|
|
|
|
|
|
with patch.object(
|
|
Prediction,
|
|
"keys_to_dataframe",
|
|
return_value=weather,
|
|
) as mock_weather:
|
|
|
|
with patch.object(
|
|
provider,
|
|
"_calculate_pvlib_power",
|
|
return_value=pd.DataFrame(columns=["dc", "ac"]),
|
|
):
|
|
|
|
await provider.update_data(
|
|
force_enable=True,
|
|
force_update=True,
|
|
)
|
|
|
|
mock_weather.assert_called_once()
|
|
|
|
kwargs = mock_weather.call_args.kwargs
|
|
|
|
assert kwargs["interval"] == to_duration("15 minutes")
|
|
assert kwargs["fill_method"] == "linear"
|
|
assert kwargs["resample_method"] == "mean"
|
|
assert kwargs["dropna"] is True
|
|
assert kwargs["boundary"] == "context"
|
|
assert kwargs["align_to_interval"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_requests_expected_weather_keys(
|
|
self,
|
|
provider,
|
|
sample_settings_4planes,
|
|
):
|
|
"""Expected weather keys shall be requested."""
|
|
|
|
with patch.object(
|
|
Prediction,
|
|
"keys_to_dataframe",
|
|
return_value=pd.DataFrame(),
|
|
) as mock_weather:
|
|
|
|
with patch.object(
|
|
provider,
|
|
"_calculate_pvlib_power",
|
|
return_value=pd.DataFrame(columns=["dc", "ac"]),
|
|
):
|
|
|
|
await provider.update_data(
|
|
force_enable=True,
|
|
force_update=True,
|
|
)
|
|
|
|
keys = mock_weather.call_args.kwargs["keys"]
|
|
|
|
assert keys == [
|
|
"weather_temp_air",
|
|
"weather_relative_humidity",
|
|
"weather_preciptable_water",
|
|
"weather_total_clouds",
|
|
"weather_wind_speed",
|
|
"weather_ghi",
|
|
"weather_dhi",
|
|
"weather_dni",
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weather_columns_are_renamed(
|
|
self,
|
|
provider,
|
|
sample_settings_4planes,
|
|
):
|
|
"""Weather dataframe shall be renamed before calculation."""
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"weather_temp_air": [20],
|
|
"weather_relative_humidity": [60],
|
|
"weather_preciptable_water": [2],
|
|
"weather_total_clouds": [50],
|
|
"weather_wind_speed": [4],
|
|
"weather_ghi": [500],
|
|
"weather_dhi": [100],
|
|
"weather_dni": [600],
|
|
}
|
|
)
|
|
|
|
with patch.object(
|
|
Prediction,
|
|
"keys_to_dataframe",
|
|
return_value=weather,
|
|
):
|
|
|
|
with patch.object(
|
|
provider,
|
|
"_calculate_pvlib_power",
|
|
return_value=pd.DataFrame(columns=["dc", "ac"]),
|
|
) as mock_calc:
|
|
|
|
await provider.update_data(
|
|
force_enable=True,
|
|
force_update=True,
|
|
)
|
|
|
|
dataframe = mock_calc.call_args.args[0]
|
|
|
|
assert "temp_air" in dataframe.columns
|
|
assert "relative_humidity" in dataframe.columns
|
|
assert "precipitable_water" in dataframe.columns
|
|
assert "cloud_cover" in dataframe.columns
|
|
assert "wind_speed" in dataframe.columns
|
|
assert "ghi" in dataframe.columns
|
|
assert "dhi" in dataframe.columns
|
|
assert "dni" in dataframe.columns
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calculate_called_once(
|
|
self,
|
|
provider,
|
|
sample_settings_1plane,
|
|
):
|
|
"""PVLib calculation shall be executed once."""
|
|
|
|
with patch.object(
|
|
Prediction,
|
|
"keys_to_dataframe",
|
|
return_value=pd.DataFrame(),
|
|
):
|
|
|
|
with patch.object(
|
|
provider,
|
|
"_calculate_pvlib_power",
|
|
return_value=pd.DataFrame(columns=["dc", "ac"]),
|
|
) as mock_calc:
|
|
|
|
await provider.update_data(
|
|
force_enable=True,
|
|
force_update=True,
|
|
)
|
|
|
|
mock_calc.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_values_written(
|
|
self,
|
|
provider,
|
|
sample_settings_4planes,
|
|
):
|
|
"""Calculated values shall be stored."""
|
|
|
|
weather = pd.DataFrame()
|
|
|
|
forecast = pd.DataFrame(
|
|
{
|
|
"pv_dc_power": [1000.0, 1100.0],
|
|
"ac_power": [950.0, 1050.0],
|
|
},
|
|
index=pd.date_range(
|
|
"2024-01-01",
|
|
periods=2,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
),
|
|
)
|
|
|
|
with patch.object(
|
|
Prediction,
|
|
"keys_to_dataframe",
|
|
return_value=weather,
|
|
):
|
|
|
|
with patch.object(
|
|
provider,
|
|
"_calculate_pvlib_power",
|
|
return_value=forecast,
|
|
):
|
|
|
|
with patch.object(
|
|
provider,
|
|
"_update_value",
|
|
) as mock_update:
|
|
|
|
await provider.update_data(
|
|
force_enable=True,
|
|
force_update=True,
|
|
)
|
|
|
|
assert mock_update.call_count == 4
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_forecast(
|
|
self,
|
|
provider,
|
|
sample_settings_4planes,
|
|
):
|
|
"""Empty forecast shall not write values."""
|
|
|
|
with patch.object(
|
|
Prediction,
|
|
"keys_to_dataframe",
|
|
return_value=pd.DataFrame(),
|
|
):
|
|
|
|
with patch.object(
|
|
provider,
|
|
"_calculate_pvlib_power",
|
|
return_value=pd.DataFrame(columns=["dc", "ac"]),
|
|
):
|
|
|
|
with patch.object(
|
|
provider,
|
|
"_update_value",
|
|
) as mock_update:
|
|
|
|
await provider.update_data(
|
|
force_enable=True,
|
|
force_update=True,
|
|
)
|
|
|
|
mock_update.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PVLib calculation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCalculation:
|
|
"""Tests for PVLib power calculation."""
|
|
|
|
@pytest.mark.skip("PVLib bails out on empty weather data")
|
|
def test_calculate_empty_weather(self, provider, sample_settings_4planes):
|
|
"""Empty weather dataframe returns empty result."""
|
|
|
|
weather = pd.DataFrame(
|
|
columns=[
|
|
"ghi",
|
|
"dni",
|
|
"dhi",
|
|
"temp_air",
|
|
"wind_speed",
|
|
"relative_humidity",
|
|
"precipitable_water",
|
|
"cloud_cover",
|
|
],
|
|
index=pd.DatetimeIndex([], tz="Europe/Berlin"),
|
|
)
|
|
|
|
result = provider._calculate_pvlib_power(weather)
|
|
|
|
assert isinstance(result, pd.DataFrame)
|
|
assert result.empty
|
|
|
|
@patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain")
|
|
@patch("akkudoktoreos.prediction.pvforecastpvlib.Location")
|
|
def test_location_created(
|
|
self,
|
|
mock_location,
|
|
mock_modelchain,
|
|
provider,
|
|
sample_settings_1plane,
|
|
):
|
|
"""Location shall be created with configured coordinates."""
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [0.0],
|
|
"dni": [0.0],
|
|
"dhi": [0.0],
|
|
"temp_air": [20.0],
|
|
"wind_speed": [2.0],
|
|
"relative_humidity": [60.0],
|
|
"precipitable_water": [1.5],
|
|
"cloud_cover": [50.0],
|
|
},
|
|
index=pd.date_range(
|
|
"2024-06-01",
|
|
periods=1,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
),
|
|
)
|
|
|
|
mc = MagicMock()
|
|
mc.results.ac = pd.Series([0.0], index=weather.index)
|
|
mc.results.dc = pd.DataFrame(
|
|
{
|
|
"p_mp": [0.0],
|
|
},
|
|
index=weather.index,
|
|
)
|
|
mock_modelchain.return_value = mc
|
|
|
|
provider._calculate_pvlib_power(weather)
|
|
|
|
mock_location.assert_called_once()
|
|
|
|
@patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain")
|
|
def test_modelchain_run_called(
|
|
self,
|
|
mock_modelchain,
|
|
provider,
|
|
sample_settings_1plane,
|
|
):
|
|
"""ModelChain.run_model shall be executed."""
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [0.0],
|
|
"dni": [0.0],
|
|
"dhi": [0.0],
|
|
"temp_air": [20.0],
|
|
"wind_speed": [2.0],
|
|
"relative_humidity": [60.0],
|
|
"precipitable_water": [1.5],
|
|
"cloud_cover": [50.0],
|
|
},
|
|
index=pd.date_range(
|
|
"2024-06-01",
|
|
periods=1,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
),
|
|
)
|
|
|
|
mc = MagicMock()
|
|
mc.results.ac = pd.Series([0.0], index=weather.index)
|
|
mc.results.dc = pd.DataFrame(
|
|
{
|
|
"p_mp": [0.0],
|
|
},
|
|
index=weather.index,
|
|
)
|
|
|
|
mock_modelchain.return_value = mc
|
|
|
|
provider._calculate_pvlib_power(weather)
|
|
|
|
mc.run_model.assert_called_once_with(weather)
|
|
|
|
@patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain")
|
|
def test_negative_power_clipped(
|
|
self,
|
|
mock_modelchain,
|
|
provider,
|
|
sample_settings_4planes,
|
|
):
|
|
"""Negative power shall be clipped to zero."""
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [0.0],
|
|
"dni": [0.0],
|
|
"dhi": [0.0],
|
|
"temp_air": [20.0],
|
|
"wind_speed": [2.0],
|
|
"relative_humidity": [60.0],
|
|
"precipitable_water": [1.5],
|
|
"cloud_cover": [50.0],
|
|
},
|
|
index=pd.date_range(
|
|
"2024-06-01",
|
|
periods=2,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
),
|
|
)
|
|
|
|
mc = MagicMock()
|
|
|
|
mc.results.dc = pd.DataFrame(
|
|
{
|
|
"p_mp": [-100.0, 500.0],
|
|
},
|
|
index=weather.index,
|
|
)
|
|
|
|
mc.results.ac = pd.Series(
|
|
[-50.0, 450.0],
|
|
index=weather.index,
|
|
)
|
|
|
|
mock_modelchain.return_value = mc
|
|
|
|
result = provider._calculate_pvlib_power(weather)
|
|
|
|
assert (result["pv_dc_power"] >= 0).all()
|
|
assert (result["ac_power"] >= 0).all()
|
|
|
|
@patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain")
|
|
def test_dataframe_columns(
|
|
self,
|
|
mock_modelchain,
|
|
provider,
|
|
sample_settings_4planes,
|
|
):
|
|
"""Returned dataframe contains expected columns."""
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [0.0],
|
|
"dni": [0.0],
|
|
"dhi": [0.0],
|
|
"temp_air": [20.0],
|
|
"wind_speed": [2.0],
|
|
"relative_humidity": [60.0],
|
|
"precipitable_water": [1.5],
|
|
"cloud_cover": [50.0],
|
|
},
|
|
index=pd.date_range(
|
|
"2024-06-01",
|
|
periods=1,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
),
|
|
)
|
|
|
|
mc = MagicMock()
|
|
|
|
mc.results.dc = pd.DataFrame(
|
|
{
|
|
"p_mp": [1000.0],
|
|
},
|
|
index=weather.index,
|
|
)
|
|
mc.results.ac = pd.Series([950.0], index=weather.index)
|
|
|
|
mock_modelchain.return_value = mc
|
|
|
|
result = provider._calculate_pvlib_power(weather)
|
|
|
|
assert "pv_dc_power" in result.columns
|
|
assert "ac_power" in result.columns
|
|
|
|
@patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain")
|
|
def test_result_index_preserved(
|
|
self,
|
|
mock_modelchain,
|
|
provider,
|
|
sample_settings_4planes,
|
|
):
|
|
"""Returned dataframe preserves weather index."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-01",
|
|
periods=4,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [0.0] * 4,
|
|
"dni": [0.0] * 4,
|
|
"dhi": [0.0] * 4,
|
|
"temp_air": [20.0] * 4,
|
|
"wind_speed": [2.0] * 4,
|
|
"relative_humidity": [60.0] * 4,
|
|
"precipitable_water": [1.5] * 4,
|
|
"cloud_cover": [50.0] * 4,
|
|
},
|
|
index=index,
|
|
)
|
|
|
|
mc = MagicMock()
|
|
|
|
mc.results.dc = pd.DataFrame(
|
|
{
|
|
"p_mp": [1.0] * 4,
|
|
},
|
|
index=index,
|
|
)
|
|
|
|
mc.results.ac = pd.Series(
|
|
[1.0] * 4,
|
|
index=index,
|
|
)
|
|
|
|
mock_modelchain.return_value = mc
|
|
|
|
result = provider._calculate_pvlib_power(weather)
|
|
|
|
pd.testing.assert_index_equal(
|
|
result.index,
|
|
weather.index,
|
|
)
|
|
|
|
def test_get_model_power_none(self, provider):
|
|
"""Unknown model parameters return None."""
|
|
|
|
assert provider._get_model_power({}, "module") is None
|
|
|
|
def test_find_closest_model_empty(self, provider):
|
|
"""Empty model database returns None."""
|
|
|
|
model = provider._find_closest_model(
|
|
400,
|
|
pd.DataFrame(),
|
|
"module",
|
|
)
|
|
|
|
assert model is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIntegration:
|
|
"""Integration tests using the real PVLib implementation."""
|
|
|
|
def test_single_plane_produces_power(
|
|
self,
|
|
provider,
|
|
sample_settings_1plane,
|
|
):
|
|
"""A realistic weather profile shall produce PV power."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-21 08:00",
|
|
periods=16,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [0, 50, 150, 300, 500, 700, 850, 900,
|
|
900, 850, 700, 500, 300, 150, 50, 0],
|
|
"dni": [0, 30, 120, 250, 450, 650, 800, 850,
|
|
850, 800, 650, 450, 250, 120, 30, 0],
|
|
"dhi": [0, 20, 30, 50, 60, 70, 80, 90,
|
|
90, 80, 70, 60, 50, 30, 20, 0],
|
|
"temp_air": [20.0] * 16,
|
|
"wind_speed": [2.0] * 16,
|
|
"relative_humidity": [55.0] * 16,
|
|
"precipitable_water": [1.5] * 16,
|
|
"cloud_cover": [10.0] * 16,
|
|
},
|
|
index=index,
|
|
)
|
|
|
|
result = provider._calculate_pvlib_power(weather)
|
|
|
|
assert not result.empty
|
|
assert (result["pv_dc_power"] >= 0).all()
|
|
assert (result["ac_power"] >= 0).all()
|
|
|
|
#
|
|
# At least one timestep shall generate power.
|
|
#
|
|
assert result["pv_dc_power"].max() > 0
|
|
assert result["ac_power"].max() > 0
|
|
|
|
def test_four_planes_generate_more_energy(
|
|
self,
|
|
provider,
|
|
sample_settings_4planes,
|
|
):
|
|
"""Four PV planes shall produce positive total power."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-21 11:00",
|
|
periods=8,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [900.0] * 8,
|
|
"dni": [800.0] * 8,
|
|
"dhi": [100.0] * 8,
|
|
"temp_air": [25.0] * 8,
|
|
"wind_speed": [2.0] * 8,
|
|
"relative_humidity": [45.0] * 8,
|
|
"precipitable_water": [1.5] * 8,
|
|
"cloud_cover": [0.0] * 8,
|
|
},
|
|
index=index,
|
|
)
|
|
|
|
result = provider._calculate_pvlib_power(weather)
|
|
|
|
assert result["pv_dc_power"].sum() > 0
|
|
assert result["ac_power"].sum() > 0
|
|
|
|
def test_zero_irradiance_gives_zero_power(
|
|
self,
|
|
provider,
|
|
sample_settings_1plane,
|
|
):
|
|
"""No irradiance shall result in zero power."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-21",
|
|
periods=8,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [0.0] * 8,
|
|
"dni": [0.0] * 8,
|
|
"dhi": [0.0] * 8,
|
|
"temp_air": [20.0] * 8,
|
|
"wind_speed": [2.0] * 8,
|
|
"relative_humidity": [60.0] * 8,
|
|
"precipitable_water": [1.5] * 8,
|
|
"cloud_cover": [100.0] * 8,
|
|
},
|
|
index=index,
|
|
)
|
|
|
|
result = provider._calculate_pvlib_power(weather)
|
|
|
|
assert result["pv_dc_power"].max() == pytest.approx(0.0)
|
|
assert result["ac_power"].max() == pytest.approx(0.0)
|
|
|
|
def test_result_preserves_weather_index(
|
|
self,
|
|
provider,
|
|
sample_settings_1plane,
|
|
):
|
|
"""Returned dataframe shall preserve the weather index."""
|
|
|
|
index = pd.date_range(
|
|
"2024-06-21 10:00",
|
|
periods=12,
|
|
freq="15min",
|
|
tz="Europe/Berlin",
|
|
)
|
|
|
|
weather = pd.DataFrame(
|
|
{
|
|
"ghi": [500.0] * 12,
|
|
"dni": [450.0] * 12,
|
|
"dhi": [50.0] * 12,
|
|
"temp_air": [22.0] * 12,
|
|
"wind_speed": [2.5] * 12,
|
|
"relative_humidity": [50.0] * 12,
|
|
"precipitable_water": [1.4] * 12,
|
|
"cloud_cover": [15.0] * 12,
|
|
},
|
|
index=index,
|
|
)
|
|
|
|
# sanity check on module data
|
|
module = _cec_modules()[sample_settings_1plane.pvforecast.planes[0].module_model]
|
|
assert module["I_o_ref"] > 0
|
|
assert pd.notna(module["a_ref"])
|
|
assert pd.notna(module["R_sh_ref"])
|
|
|
|
result = provider._calculate_pvlib_power(weather)
|
|
|
|
pd.testing.assert_index_equal(result.index, weather.index)
|