mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-09 18:36:11 +00:00
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)
|