Nested config, devices registry

* All config now nested.
    - Use default config from model field default values. If providers
      should be enabled by default, non-empty default config file could
      be provided again.
    - Environment variable support with EOS_ prefix and __ between levels,
      e.g. EOS_SERVER__EOS_SERVER_PORT=8503 where all values are case
      insensitive.
      For more information see:
      https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values
    - Use devices as registry for configured devices. DeviceBase as base
      class with for now just initializion support (in the future expand
      to operations during optimization).
    - Strip down ConfigEOS to the only configuration instance. Reload
      from file or reset to defaults is possible.

 * Fix multi-initialization of derived SingletonMixin classes.
This commit is contained in:
Dominique Lasserre
2025-01-12 05:19:37 +01:00
parent f09658578a
commit be26457563
72 changed files with 1297 additions and 1712 deletions

View File

@@ -64,6 +64,25 @@ def config_mixin(config_eos):
yield config_mixin_patch
@pytest.fixture
def devices_eos(config_mixin):
from akkudoktoreos.devices.devices import get_devices
devices = get_devices()
print("devices_eos reset!")
devices.reset()
return devices
@pytest.fixture
def devices_mixin(devices_eos):
with patch(
"akkudoktoreos.core.coreabc.DevicesMixin.devices", new_callable=PropertyMock
) as devices_mixin_patch:
devices_mixin_patch.return_value = devices_eos
yield devices_mixin_patch
# Test if test has side effect of writing to system (user) config file
# Before activating, make sure that no user config file exists (e.g. ~/.config/net.akkudoktoreos.eos/EOS.config.json)
@pytest.fixture(autouse=True)
@@ -114,8 +133,12 @@ def config_eos(
monkeypatch,
) -> ConfigEOS:
"""Fixture to reset EOS config to default values."""
monkeypatch.setenv("data_cache_subpath", str(config_default_dirs[-1] / "data/cache"))
monkeypatch.setenv("data_output_subpath", str(config_default_dirs[-1] / "data/output"))
monkeypatch.setenv(
"EOS_CONFIG__DATA_CACHE_SUBPATH", str(config_default_dirs[-1] / "data/cache")
)
monkeypatch.setenv(
"EOS_CONFIG__DATA_OUTPUT_SUBPATH", str(config_default_dirs[-1] / "data/output")
)
config_file = config_default_dirs[0] / ConfigEOS.CONFIG_FILE_NAME
config_file_cwd = config_default_dirs[1] / ConfigEOS.CONFIG_FILE_NAME
assert not config_file.exists()
@@ -125,9 +148,9 @@ def config_eos(
assert config_file == config_eos.config_file_path
assert config_file.exists()
assert not config_file_cwd.exists()
assert config_default_dirs[-1] / "data" == config_eos.data_folder_path
assert config_default_dirs[-1] / "data/cache" == config_eos.data_cache_path
assert config_default_dirs[-1] / "data/output" == config_eos.data_output_path
assert config_default_dirs[-1] / "data" == config_eos.general.data_folder_path
assert config_default_dirs[-1] / "data/cache" == config_eos.general.data_cache_path
assert config_default_dirs[-1] / "data/output" == config_eos.general.data_output_path
return config_eos
@@ -166,6 +189,7 @@ def server(xprocess, config_eos, config_default_dirs):
# Set environment before any subprocess run, to keep custom config dir
env = os.environ.copy()
env["EOS_DIR"] = str(config_default_dirs[-1])
project_dir = config_eos.package_root_path
# assure server to be installed
try:
@@ -175,9 +199,9 @@ def server(xprocess, config_eos, config_default_dirs):
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=project_dir,
)
except subprocess.CalledProcessError:
project_dir = config_eos.package_root_path
subprocess.run(
[sys.executable, "-m", "pip", "install", "-e", project_dir],
check=True,

View File

@@ -7,13 +7,15 @@ from akkudoktoreos.devices.battery import Battery, SolarPanelBatteryParameters
@pytest.fixture
def setup_pv_battery():
params = SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=10000,
initial_soc_percentage=50,
min_soc_percentage=20,
max_soc_percentage=80,
max_charge_power_w=8000,
hours=24,
)
battery = Battery(params, hours=24)
battery = Battery(params)
battery.reset()
return battery
@@ -113,7 +115,6 @@ def test_soc_limits(setup_pv_battery):
def test_max_charge_power_w(setup_pv_battery):
battery = setup_pv_battery
battery.setup()
assert (
battery.parameters.max_charge_power_w == 8000
), "Default max charge power should be 5000W, We ask for 8000W here"
@@ -121,7 +122,6 @@ def test_max_charge_power_w(setup_pv_battery):
def test_charge_energy_within_limits(setup_pv_battery):
battery = setup_pv_battery
battery.setup()
initial_soc_wh = battery.soc_wh
charged_wh, losses_wh = battery.charge_energy(wh=4000, hour=1)
@@ -134,7 +134,6 @@ def test_charge_energy_within_limits(setup_pv_battery):
def test_charge_energy_exceeds_capacity(setup_pv_battery):
battery = setup_pv_battery
battery.setup()
initial_soc_wh = battery.soc_wh
# Try to overcharge beyond max capacity
@@ -149,7 +148,6 @@ def test_charge_energy_exceeds_capacity(setup_pv_battery):
def test_charge_energy_not_allowed_hour(setup_pv_battery):
battery = setup_pv_battery
battery.setup()
# Disable charging for all hours
battery.set_charge_per_hour(np.zeros(battery.hours))
@@ -165,7 +163,6 @@ def test_charge_energy_not_allowed_hour(setup_pv_battery):
def test_charge_energy_relative_power(setup_pv_battery):
battery = setup_pv_battery
battery.setup()
relative_power = 0.5 # 50% of max charge power
charged_wh, losses_wh = battery.charge_energy(wh=None, hour=4, relative_power=relative_power)
@@ -183,13 +180,15 @@ def setup_car_battery():
from akkudoktoreos.devices.battery import ElectricVehicleParameters
params = ElectricVehicleParameters(
device_id="ev1",
capacity_wh=40000,
initial_soc_percentage=60,
min_soc_percentage=10,
max_soc_percentage=90,
max_charge_power_w=7000,
hours=24,
)
battery = Battery(params, hours=24)
battery = Battery(params)
battery.reset()
return battery

View File

@@ -1,5 +1,3 @@
from pathlib import Path
import numpy as np
import pytest
@@ -16,58 +14,58 @@ from akkudoktoreos.devices.battery import (
)
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
start_hour = 1
# Example initialization of necessary components
@pytest.fixture
def create_ems_instance(config_eos) -> EnergieManagementSystem:
def create_ems_instance(devices_eos, config_eos) -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Assure configuration holds the correct values
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
assert config_eos.prediction_hours is not None
config_eos.merge_settings_from_dict(
{"prediction": {"prediction_hours": 48}, "optimization": {"optimization_hours": 24}}
)
assert config_eos.prediction.prediction_hours == 48
# Initialize the battery and the inverter
akku = Battery(
SolarPanelBatteryParameters(
capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10
),
hours=config_eos.prediction_hours,
device_id="battery1",
capacity_wh=5000,
initial_soc_percentage=80,
min_soc_percentage=10,
)
)
# 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate
sc = SelfConsumptionProbabilityInterpolator(
Path(__file__).parent.resolve()
/ ".."
/ "src"
/ "akkudoktoreos"
/ "data"
/ "regular_grid_interpolator.pkl"
)
akku.reset()
inverter = Inverter(sc, InverterParameters(max_power_wh=10000), akku)
devices_eos.add_device(akku)
inverter = Inverter(
InverterParameters(device_id="inverter1", max_power_wh=10000, battery=akku.device_id)
)
devices_eos.add_device(inverter)
# Household device (currently not used, set to None)
home_appliance = HomeAppliance(
HomeApplianceParameters(
device_id="dishwasher1",
consumption_wh=2000,
duration_h=2,
),
hours=config_eos.prediction_hours,
)
home_appliance.set_starting_time(2)
devices_eos.add_device(home_appliance)
# Example initialization of electric car battery
eauto = Battery(
ElectricVehicleParameters(
capacity_wh=26400, initial_soc_percentage=10, min_soc_percentage=10
device_id="ev1", capacity_wh=26400, initial_soc_percentage=10, min_soc_percentage=10
),
hours=config_eos.prediction_hours,
)
eauto.set_charge_per_hour(np.full(config_eos.prediction_hours, 1))
eauto.set_charge_per_hour(np.full(config_eos.prediction.prediction_hours, 1))
devices_eos.add_device(eauto)
devices_eos.post_setup()
# Parameters based on previous example data
pv_prognose_wh = [

View File

@@ -1,5 +1,3 @@
from pathlib import Path
import numpy as np
import pytest
@@ -15,64 +13,61 @@ from akkudoktoreos.devices.battery import (
)
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
start_hour = 0
# Example initialization of necessary components
@pytest.fixture
def create_ems_instance(config_eos) -> EnergieManagementSystem:
def create_ems_instance(devices_eos, config_eos) -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Assure configuration holds the correct values
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
assert config_eos.prediction_hours is not None
config_eos.merge_settings_from_dict(
{"prediction": {"prediction_hours": 48}, "optimization": {"optimization_hours": 24}}
)
assert config_eos.prediction.prediction_hours == 48
# Initialize the battery and the inverter
akku = Battery(
SolarPanelBatteryParameters(
capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10
),
hours=config_eos.prediction_hours,
device_id="pv1", capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10
)
)
# 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate
sc = SelfConsumptionProbabilityInterpolator(
Path(__file__).parent.resolve()
/ ".."
/ "src"
/ "akkudoktoreos"
/ "data"
/ "regular_grid_interpolator.pkl"
)
akku.reset()
inverter = Inverter(sc, InverterParameters(max_power_wh=10000), akku)
devices_eos.add_device(akku)
inverter = Inverter(
InverterParameters(device_id="iv1", max_power_wh=10000, battery=akku.device_id)
)
devices_eos.add_device(inverter)
# Household device (currently not used, set to None)
home_appliance = HomeAppliance(
HomeApplianceParameters(
device_id="dishwasher1",
consumption_wh=2000,
duration_h=2,
),
hours=config_eos.prediction_hours,
)
)
home_appliance.set_starting_time(2)
devices_eos.add_device(home_appliance)
# Example initialization of electric car battery
eauto = Battery(
ElectricVehicleParameters(
capacity_wh=26400, initial_soc_percentage=100, min_soc_percentage=100
device_id="ev1", capacity_wh=26400, initial_soc_percentage=100, min_soc_percentage=100
),
hours=config_eos.prediction_hours,
)
devices_eos.add_device(eauto)
devices_eos.post_setup()
# Parameters based on previous example data
pv_prognose_wh = [0.0] * config_eos.prediction_hours
pv_prognose_wh = [0.0] * config_eos.prediction.prediction_hours
pv_prognose_wh[10] = 5000.0
pv_prognose_wh[11] = 5000.0
strompreis_euro_pro_wh = [0.001] * config_eos.prediction_hours
strompreis_euro_pro_wh = [0.001] * config_eos.prediction.prediction_hours
strompreis_euro_pro_wh[0:10] = [0.00001] * 10
strompreis_euro_pro_wh[11:15] = [0.00005] * 4
strompreis_euro_pro_wh[20] = 0.00001
@@ -146,10 +141,10 @@ def create_ems_instance(config_eos) -> EnergieManagementSystem:
home_appliance=home_appliance,
)
ac = np.full(config_eos.prediction_hours, 0.0)
ac = np.full(config_eos.prediction.prediction_hours, 0.0)
ac[20] = 1
ems.set_akku_ac_charge_hours(ac)
dc = np.full(config_eos.prediction_hours, 0.0)
dc = np.full(config_eos.prediction.prediction_hours, 0.0)
dc[11] = 1
ems.set_akku_dc_charge_hours(dc)

View File

@@ -49,7 +49,9 @@ def test_optimize(
):
"""Test optimierung_ems."""
# Assure configuration holds the correct values
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48})
config_eos.merge_settings_from_dict(
{"prediction": {"prediction_hours": 48}, "optimization": {"optimization_hours": 48}}
)
# Load input and output data
file = DIR_TESTDATA / fn_in

View File

@@ -38,15 +38,19 @@ def test_config_constants(config_eos):
def test_computed_paths(config_eos):
"""Test computed paths for output and cache."""
config_eos.merge_settings_from_dict(
{
"data_folder_path": "/base/data",
"data_output_subpath": "output",
"data_cache_subpath": "cache",
}
)
assert config_eos.data_output_path == Path("/base/data/output")
assert config_eos.data_cache_path == Path("/base/data/cache")
# Don't actually try to create the data folder
with patch("pathlib.Path.mkdir"):
config_eos.merge_settings_from_dict(
{
"general": {
"data_folder_path": "/base/data",
"data_output_subpath": "extra/output",
"data_cache_subpath": "somewhere/cache",
}
}
)
assert config_eos.general.data_output_path == Path("/base/data/extra/output")
assert config_eos.general.data_cache_path == Path("/base/data/somewhere/cache")
# reset settings so the config_eos fixture can verify the default paths
config_eos.reset_settings()
@@ -87,7 +91,7 @@ def test_config_file_priority(config_default_dirs):
config_file = Path(config_default_dir_user) / ConfigEOS.CONFIG_FILE_NAME
config_file.parent.mkdir()
config_file.write_text("{}")
config_eos = get_config()
config_eos.update()
assert config_eos.config_file_path == config_file
@@ -141,5 +145,5 @@ def test_config_copy(config_eos, monkeypatch):
assert not temp_config_file_path.exists()
with patch("akkudoktoreos.config.config.user_config_dir", return_value=temp_dir):
assert config_eos._get_config_file_path() == (temp_config_file_path, False)
config_eos.from_config_file()
config_eos.update()
assert temp_config_file_path.exists()

View File

@@ -23,9 +23,10 @@ FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
@pytest.fixture
def elecprice_provider(monkeypatch):
def elecprice_provider(monkeypatch, config_eos):
"""Fixture to create a ElecPriceProvider instance."""
monkeypatch.setenv("elecprice_provider", "ElecPriceAkkudoktor")
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceAkkudoktor")
config_eos.reset_settings()
return ElecPriceAkkudoktor()
@@ -56,9 +57,9 @@ def test_singleton_instance(elecprice_provider):
def test_invalid_provider(elecprice_provider, monkeypatch):
"""Test requesting an unsupported elecprice_provider."""
monkeypatch.setenv("elecprice_provider", "<invalid>")
elecprice_provider.config.update()
assert elecprice_provider.enabled() == False
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
elecprice_provider.config.reset_settings()
assert not elecprice_provider.enabled()
# ------------------------------------------------

View File

@@ -16,9 +16,13 @@ FILE_TESTDATA_ELECPRICEIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.jso
def elecprice_provider(sample_import_1_json, config_eos):
"""Fixture to create a ElecPriceProvider instance."""
settings = {
"elecprice_provider": "ElecPriceImport",
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
"elecpriceimport_json": json.dumps(sample_import_1_json),
"elecprice": {
"elecprice_provider": "ElecPriceImport",
"provider_settings": {
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
"elecpriceimport_json": json.dumps(sample_import_1_json),
},
}
}
config_eos.merge_settings_from_dict(settings)
provider = ElecPriceImport()
@@ -48,8 +52,12 @@ def test_singleton_instance(elecprice_provider):
def test_invalid_provider(elecprice_provider, config_eos):
"""Test requesting an unsupported elecprice_provider."""
settings = {
"elecprice_provider": "<invalid>",
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
"elecprice": {
"elecprice_provider": "<invalid>",
"provider_settings": {
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
},
}
}
config_eos.merge_settings_from_dict(settings)
assert not elecprice_provider.enabled()
@@ -78,11 +86,11 @@ def test_import(elecprice_provider, sample_import_1_json, start_datetime, from_f
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file:
config_eos.elecpriceimport_json = None
assert config_eos.elecpriceimport_json is None
config_eos.elecprice.provider_settings.elecpriceimport_json = None
assert config_eos.elecprice.provider_settings.elecpriceimport_json is None
else:
config_eos.elecpriceimport_file_path = None
assert config_eos.elecpriceimport_file_path is None
config_eos.elecprice.provider_settings.elecpriceimport_file_path = None
assert config_eos.elecprice.provider_settings.elecpriceimport_file_path is None
elecprice_provider.clear()
# Call the method

View File

@@ -1,4 +1,4 @@
from unittest.mock import Mock
from unittest.mock import Mock, patch
import pytest
@@ -6,22 +6,29 @@ from akkudoktoreos.devices.inverter import Inverter, InverterParameters
@pytest.fixture
def mock_battery():
def mock_battery() -> Mock:
mock_battery = Mock()
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
mock_battery.discharge_energy = Mock(return_value=(0.0, 0.0))
mock_battery.device_id = "battery1"
return mock_battery
@pytest.fixture
def inverter(mock_battery):
def inverter(mock_battery, devices_eos) -> Inverter:
devices_eos.add_device(mock_battery)
mock_self_consumption_predictor = Mock()
mock_self_consumption_predictor.calculate_self_consumption.return_value = 1.0
return Inverter(
mock_self_consumption_predictor,
InverterParameters(max_power_wh=500.0),
battery=mock_battery,
)
with patch(
"akkudoktoreos.devices.inverter.get_eos_load_interpolator",
return_value=mock_self_consumption_predictor,
):
iv = Inverter(
InverterParameters(device_id="iv1", max_power_wh=500.0, battery=mock_battery.device_id),
)
devices_eos.add_device(iv)
devices_eos.post_setup()
return iv
def test_process_energy_excess_generation(inverter, mock_battery):

View File

@@ -17,9 +17,13 @@ from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_
def load_provider(config_eos):
"""Fixture to initialise the LoadAkkudoktor instance."""
settings = {
"load_provider": "LoadAkkudoktor",
"load_name": "Akkudoktor Profile",
"loadakkudoktor_year_energy": "1000",
"load": {
"load_provider": "LoadAkkudoktor",
"provider_settings": {
"load_name": "Akkudoktor Profile",
"loadakkudoktor_year_energy": "1000",
},
}
}
config_eos.merge_settings_from_dict(settings)
return LoadAkkudoktor()

View File

@@ -3,7 +3,11 @@ import pytest
from pendulum import datetime, duration
from akkudoktoreos.config.config import SettingsEOS
from akkudoktoreos.measurement.measurement import MeasurementDataRecord, get_measurement
from akkudoktoreos.measurement.measurement import (
MeasurementCommonSettings,
MeasurementDataRecord,
get_measurement,
)
@pytest.fixture
@@ -186,8 +190,10 @@ def test_load_total_no_data(measurement_eos):
def test_name_to_key(measurement_eos):
"""Test name_to_key functionality."""
settings = SettingsEOS(
measurement_load0_name="Household",
measurement_load1_name="Heat Pump",
measurement=MeasurementCommonSettings(
measurement_load0_name="Household",
measurement_load1_name="Heat Pump",
)
)
measurement_eos.config.merge_settings(settings)
@@ -199,8 +205,10 @@ def test_name_to_key(measurement_eos):
def test_name_to_key_invalid_topic(measurement_eos):
"""Test name_to_key with an invalid topic."""
settings = SettingsEOS(
measurement_load0_name="Household",
measurement_load1_name="Heat Pump",
MeasurementCommonSettings(
measurement_load0_name="Household",
measurement_load1_name="Heat Pump",
)
)
measurement_eos.config.merge_settings(settings)

View File

@@ -126,9 +126,9 @@ def test_prediction_common_settings_with_location():
def test_prediction_common_settings_timezone_none_when_coordinates_missing():
"""Test that timezone is None when latitude or longitude is missing."""
config_no_latitude = PredictionCommonSettings(longitude=-74.0060)
config_no_longitude = PredictionCommonSettings(latitude=40.7128)
config_no_coords = PredictionCommonSettings()
config_no_latitude = PredictionCommonSettings(latitude=None, longitude=-74.0060)
config_no_longitude = PredictionCommonSettings(latitude=40.7128, longitude=None)
config_no_coords = PredictionCommonSettings(latitude=None, longitude=None)
assert config_no_latitude.timezone is None
assert config_no_longitude.timezone is None

View File

@@ -88,31 +88,31 @@ class TestPredictionBase:
@pytest.fixture
def base(self, monkeypatch):
# Provide default values for configuration
monkeypatch.setenv("latitude", "50.0")
monkeypatch.setenv("longitude", "10.0")
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "50.0")
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "10.0")
derived = DerivedBase()
derived.config.update()
derived.config.reset_settings()
return derived
def test_config_value_from_env_variable(self, base, monkeypatch):
# From Prediction Config
monkeypatch.setenv("latitude", "2.5")
base.config.update()
assert base.config.latitude == 2.5
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "2.5")
base.config.reset_settings()
assert base.config.prediction.latitude == 2.5
def test_config_value_from_field_default(self, base, monkeypatch):
assert base.config.model_fields["prediction_hours"].default == 48
assert base.config.prediction_hours == 48
monkeypatch.setenv("prediction_hours", "128")
base.config.update()
assert base.config.prediction_hours == 128
monkeypatch.delenv("prediction_hours")
base.config.update()
assert base.config.prediction_hours == 48
assert base.config.prediction.model_fields["prediction_hours"].default == 48
assert base.config.prediction.prediction_hours == 48
monkeypatch.setenv("EOS_PREDICTION__PREDICTION_HOURS", "128")
base.config.reset_settings()
assert base.config.prediction.prediction_hours == 128
monkeypatch.delenv("EOS_PREDICTION__PREDICTION_HOURS")
base.config.reset_settings()
assert base.config.prediction.prediction_hours == 48
def test_get_config_value_key_error(self, base):
with pytest.raises(AttributeError):
base.config.non_existent_key
base.config.prediction.non_existent_key
# TestPredictionRecord fully covered by TestDataRecord
@@ -159,14 +159,14 @@ class TestPredictionProvider:
"""Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated."""
ems_eos = get_ems()
ems_eos.set_start_datetime(sample_start_datetime)
provider.config.prediction_hours = 24 # 24 hours into the future
provider.config.prediction_historic_hours = 48 # 48 hours into the past
provider.config.prediction.prediction_hours = 24 # 24 hours into the future
provider.config.prediction.prediction_historic_hours = 48 # 48 hours into the past
expected_end_datetime = sample_start_datetime + to_duration(
provider.config.prediction_hours * 3600
provider.config.prediction.prediction_hours * 3600
)
expected_keep_datetime = sample_start_datetime - to_duration(
provider.config.prediction_historic_hours * 3600
provider.config.prediction.prediction_historic_hours * 3600
)
assert (
@@ -183,31 +183,32 @@ class TestPredictionProvider:
# EOS config supersedes
ems_eos = get_ems()
# The following values are currently not set in EOS config, we can override
monkeypatch.setenv("prediction_historic_hours", "2")
assert os.getenv("prediction_historic_hours") == "2"
monkeypatch.setenv("latitude", "37.7749")
assert os.getenv("latitude") == "37.7749"
monkeypatch.setenv("longitude", "-122.4194")
assert os.getenv("longitude") == "-122.4194"
monkeypatch.setenv("EOS_PREDICTION__PREDICTION_HISTORIC_HOURS", "2")
assert os.getenv("EOS_PREDICTION__PREDICTION_HISTORIC_HOURS") == "2"
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "37.7749")
assert os.getenv("EOS_PREDICTION__LATITUDE") == "37.7749"
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "-122.4194")
assert os.getenv("EOS_PREDICTION__LONGITUDE") == "-122.4194"
provider.config.reset_settings()
ems_eos.set_start_datetime(sample_start_datetime)
provider.update_data()
assert provider.config.prediction_hours == config_eos.prediction_hours
assert provider.config.prediction_historic_hours == 2
assert provider.config.latitude == 37.7749
assert provider.config.longitude == -122.4194
assert provider.config.prediction.prediction_hours == config_eos.prediction.prediction_hours
assert provider.config.prediction.prediction_historic_hours == 2
assert provider.config.prediction.latitude == 37.7749
assert provider.config.prediction.longitude == -122.4194
assert provider.start_datetime == sample_start_datetime
assert provider.end_datetime == sample_start_datetime + to_duration(
f"{provider.config.prediction_hours} hours"
f"{provider.config.prediction.prediction_hours} hours"
)
assert provider.keep_datetime == sample_start_datetime - to_duration("2 hours")
def test_update_method_force_enable(self, provider, monkeypatch):
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
# Preset values that are needed by update
monkeypatch.setenv("latitude", "37.7749")
monkeypatch.setenv("longitude", "-122.4194")
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "37.7749")
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "-122.4194")
# Override enabled to return False for this test
DerivedPredictionProvider.provider_enabled = False
@@ -288,7 +289,9 @@ class TestPredictionContainer:
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = {
"prediction_hours": hours,
"prediction": {
"prediction_hours": hours,
}
}
container.config.merge_settings_from_dict(settings)
expected = to_datetime(end, in_timezone="Europe/Berlin")
@@ -316,7 +319,9 @@ class TestPredictionContainer:
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = {
"prediction_historic_hours": historic_hours,
"prediction": {
"prediction_historic_hours": historic_hours,
}
}
container.config.merge_settings_from_dict(settings)
expected = to_datetime(expected_keep, in_timezone="Europe/Berlin")
@@ -336,7 +341,9 @@ class TestPredictionContainer:
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = {
"prediction_hours": prediction_hours,
"prediction": {
"prediction_hours": prediction_hours,
}
}
container.config.merge_settings_from_dict(settings)
assert container.total_hours == expected_hours
@@ -355,7 +362,9 @@ class TestPredictionContainer:
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = {
"prediction_historic_hours": historic_hours,
"prediction": {
"prediction_historic_hours": historic_hours,
}
}
container.config.merge_settings_from_dict(settings)
assert container.keep_hours == expected_hours

View File

@@ -25,36 +25,41 @@ FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1
def sample_settings(config_eos):
"""Fixture that adds settings data to the global config."""
settings = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
"pvforecast4_peakpower": None,
"prediction": {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
},
"pvforecast": {
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
"pvforecast4_peakpower": None,
},
}
# Merge settings to config
config_eos.merge_settings_from_dict(settings)
assert config_eos.pvforecast.pvforecast_provider == "PVForecastAkkudoktor"
return config_eos
@@ -141,15 +146,19 @@ sample_value = AkkudoktorForecastValue(
windspeed_10m=10.0,
)
sample_config_data = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": 180,
"pvforecast0_surface_tilt": 30,
"pvforecast0_inverter_paco": 10000,
"prediction": {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
},
"pvforecast": {
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": 180,
"pvforecast0_surface_tilt": 30,
"pvforecast0_inverter_paco": 10000,
},
}

View File

@@ -16,9 +16,13 @@ FILE_TESTDATA_PVFORECASTIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.js
def pvforecast_provider(sample_import_1_json, config_eos):
"""Fixture to create a PVForecastProvider instance."""
settings = {
"pvforecast_provider": "PVForecastImport",
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
"pvforecastimport_json": json.dumps(sample_import_1_json),
"pvforecast": {
"pvforecast_provider": "PVForecastImport",
"provider_settings": {
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
"pvforecastimport_json": json.dumps(sample_import_1_json),
},
}
}
config_eos.merge_settings_from_dict(settings)
provider = PVForecastImport()
@@ -48,8 +52,12 @@ def test_singleton_instance(pvforecast_provider):
def test_invalid_provider(pvforecast_provider, config_eos):
"""Test requesting an unsupported pvforecast_provider."""
settings = {
"pvforecast_provider": "<invalid>",
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
"pvforecast": {
"pvforecast_provider": "<invalid>",
"provider_settings": {
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
},
}
}
config_eos.merge_settings_from_dict(settings)
assert not pvforecast_provider.enabled()
@@ -78,11 +86,11 @@ def test_import(pvforecast_provider, sample_import_1_json, start_datetime, from_
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file:
config_eos.pvforecastimport_json = None
assert config_eos.pvforecastimport_json is None
config_eos.pvforecast.provider_settings.pvforecastimport_json = None
assert config_eos.pvforecast.provider_settings.pvforecastimport_json is None
else:
config_eos.pvforecastimport_file_path = None
assert config_eos.pvforecastimport_file_path is None
config_eos.pvforecast.provider_settings.pvforecastimport_file_path = None
assert config_eos.pvforecast.provider_settings.pvforecastimport_file_path is None
pvforecast_provider.clear()
# Call the method

View File

@@ -6,8 +6,8 @@ import requests
def test_server(server, config_eos):
"""Test the server."""
# validate correct path in server
assert config_eos.data_folder_path is not None
assert config_eos.data_folder_path.is_dir()
assert config_eos.general.data_folder_path is not None
assert config_eos.general.data_folder_path.is_dir()
result = requests.get(f"{server}/v1/config")
assert result.status_code == HTTPStatus.OK

View File

@@ -13,7 +13,7 @@ reference_file = DIR_TESTDATA / "test_example_report.pdf"
def test_generate_pdf_example(config_eos):
"""Test generation of example visualization report."""
output_dir = config_eos.data_output_path
output_dir = config_eos.general.data_output_path
assert output_dir is not None
output_file = output_dir / filename
assert not output_file.exists()

View File

@@ -19,9 +19,9 @@ FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_b
@pytest.fixture
def weather_provider(monkeypatch):
"""Fixture to create a WeatherProvider instance."""
monkeypatch.setenv("weather_provider", "BrightSky")
monkeypatch.setenv("latitude", "50.0")
monkeypatch.setenv("longitude", "10.0")
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "BrightSky")
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "50.0")
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "10.0")
return WeatherBrightSky()
@@ -60,19 +60,19 @@ def test_singleton_instance(weather_provider):
def test_invalid_provider(weather_provider, monkeypatch):
"""Test requesting an unsupported weather_provider."""
monkeypatch.setenv("weather_provider", "<invalid>")
weather_provider.config.update()
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "<invalid>")
weather_provider.config.reset_settings()
assert not weather_provider.enabled()
def test_invalid_coordinates(weather_provider, monkeypatch):
"""Test invalid coordinates raise ValueError."""
monkeypatch.setenv("latitude", "1000")
monkeypatch.setenv("longitude", "1000")
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "1000")
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "1000")
with pytest.raises(
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
):
weather_provider.config.update()
weather_provider.config.reset_settings()
# ------------------------------------------------

View File

@@ -24,9 +24,13 @@ FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA = DIR_TESTDATA.joinpath("weatherforecas
def weather_provider(config_eos):
"""Fixture to create a WeatherProvider instance."""
settings = {
"weather_provider": "ClearOutside",
"latitude": 50.0,
"longitude": 10.0,
"weather": {
"weather_provider": "ClearOutside",
},
"prediction": {
"latitude": 50.0,
"longitude": 10.0,
},
}
config_eos.merge_settings_from_dict(settings)
return WeatherClearOutside()
@@ -69,7 +73,9 @@ def test_singleton_instance(weather_provider):
def test_invalid_provider(weather_provider, config_eos):
"""Test requesting an unsupported weather_provider."""
settings = {
"weather_provider": "<invalid>",
"weather": {
"weather_provider": "<invalid>",
}
}
config_eos.merge_settings_from_dict(settings)
assert not weather_provider.enabled()
@@ -78,9 +84,13 @@ def test_invalid_provider(weather_provider, config_eos):
def test_invalid_coordinates(weather_provider, config_eos):
"""Test invalid coordinates raise ValueError."""
settings = {
"weather_provider": "ClearOutside",
"latitude": 1000.0,
"longitude": 1000.0,
"weather": {
"weather_provider": "ClearOutside",
},
"prediction": {
"latitude": 1000.0,
"longitude": 1000.0,
},
}
with pytest.raises(
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
@@ -150,8 +160,8 @@ def test_update_data(mock_get, weather_provider, sample_clearout_1_html, sample_
weather_provider.update_data()
# Check for correct prediction time window
assert weather_provider.config.prediction_hours == 48
assert weather_provider.config.prediction_historic_hours == 48
assert weather_provider.config.prediction.prediction_hours == 48
assert weather_provider.config.prediction.prediction_historic_hours == 48
assert compare_datetimes(weather_provider.start_datetime, expected_start).equal
assert compare_datetimes(weather_provider.end_datetime, expected_end).equal
assert compare_datetimes(weather_provider.keep_datetime, expected_keep).equal

View File

@@ -16,9 +16,13 @@ FILE_TESTDATA_WEATHERIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json"
def weather_provider(sample_import_1_json, config_eos):
"""Fixture to create a WeatherProvider instance."""
settings = {
"weather_provider": "WeatherImport",
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
"weatherimport_json": json.dumps(sample_import_1_json),
"weather": {
"weather_provider": "WeatherImport",
"provider_settings": {
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
"weatherimport_json": json.dumps(sample_import_1_json),
},
}
}
config_eos.merge_settings_from_dict(settings)
provider = WeatherImport()
@@ -48,8 +52,12 @@ def test_singleton_instance(weather_provider):
def test_invalid_provider(weather_provider, config_eos, monkeypatch):
"""Test requesting an unsupported weather_provider."""
settings = {
"weather_provider": "<invalid>",
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
"weather": {
"weather_provider": "<invalid>",
"provider_settings": {
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
},
}
}
config_eos.merge_settings_from_dict(settings)
assert weather_provider.enabled() == False
@@ -78,11 +86,11 @@ def test_import(weather_provider, sample_import_1_json, start_datetime, from_fil
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file:
config_eos.weatherimport_json = None
assert config_eos.weatherimport_json is None
config_eos.weather.provider_settings.weatherimport_json = None
assert config_eos.weather.provider_settings.weatherimport_json is None
else:
config_eos.weatherimport_file_path = None
assert config_eos.weatherimport_file_path is None
config_eos.weather.provider_settings.weatherimport_file_path = None
assert config_eos.weather.provider_settings.weatherimport_file_path is None
weather_provider.clear()
# Call the method

View File

@@ -1,110 +0,0 @@
{
"config_file_path": null,
"config_folder_path": null,
"data_cache_path": null,
"data_cache_subpath": null,
"data_folder_path": null,
"data_output_path": null,
"data_output_subpath": null,
"elecprice_provider": null,
"elecpriceimport_file_path": null,
"latitude": null,
"load_import_file_path": null,
"load_name": null,
"load_provider": null,
"loadakkudoktor_year_energy": null,
"longitude": null,
"optimization_ev_available_charge_rates_percent": [],
"optimization_hours": 24,
"optimization_penalty": null,
"prediction_historic_hours": 48,
"prediction_hours": 48,
"pvforecast0_albedo": null,
"pvforecast0_inverter_model": null,
"pvforecast0_inverter_paco": null,
"pvforecast0_loss": null,
"pvforecast0_module_model": null,
"pvforecast0_modules_per_string": null,
"pvforecast0_mountingplace": "free",
"pvforecast0_optimal_surface_tilt": false,
"pvforecast0_optimalangles": false,
"pvforecast0_peakpower": null,
"pvforecast0_pvtechchoice": "crystSi",
"pvforecast0_strings_per_inverter": null,
"pvforecast0_surface_azimuth": null,
"pvforecast0_surface_tilt": null,
"pvforecast0_trackingtype": null,
"pvforecast0_userhorizon": null,
"pvforecast1_albedo": null,
"pvforecast1_inverter_model": null,
"pvforecast1_inverter_paco": null,
"pvforecast1_loss": 0,
"pvforecast1_module_model": null,
"pvforecast1_modules_per_string": null,
"pvforecast1_mountingplace": "free",
"pvforecast1_optimal_surface_tilt": false,
"pvforecast1_optimalangles": false,
"pvforecast1_peakpower": null,
"pvforecast1_pvtechchoice": "crystSi",
"pvforecast1_strings_per_inverter": null,
"pvforecast1_surface_azimuth": null,
"pvforecast1_surface_tilt": null,
"pvforecast1_trackingtype": null,
"pvforecast1_userhorizon": null,
"pvforecast2_albedo": null,
"pvforecast2_inverter_model": null,
"pvforecast2_inverter_paco": null,
"pvforecast2_loss": 0,
"pvforecast2_module_model": null,
"pvforecast2_modules_per_string": null,
"pvforecast2_mountingplace": "free",
"pvforecast2_optimal_surface_tilt": false,
"pvforecast2_optimalangles": false,
"pvforecast2_peakpower": null,
"pvforecast2_pvtechchoice": "crystSi",
"pvforecast2_strings_per_inverter": null,
"pvforecast2_surface_azimuth": null,
"pvforecast2_surface_tilt": null,
"pvforecast2_trackingtype": null,
"pvforecast2_userhorizon": null,
"pvforecast3_albedo": null,
"pvforecast3_inverter_model": null,
"pvforecast3_inverter_paco": null,
"pvforecast3_loss": 0,
"pvforecast3_module_model": null,
"pvforecast3_modules_per_string": null,
"pvforecast3_mountingplace": "free",
"pvforecast3_optimal_surface_tilt": false,
"pvforecast3_optimalangles": false,
"pvforecast3_peakpower": null,
"pvforecast3_pvtechchoice": "crystSi",
"pvforecast3_strings_per_inverter": null,
"pvforecast3_surface_azimuth": null,
"pvforecast3_surface_tilt": null,
"pvforecast3_trackingtype": null,
"pvforecast3_userhorizon": null,
"pvforecast4_albedo": null,
"pvforecast4_inverter_model": null,
"pvforecast4_inverter_paco": null,
"pvforecast4_loss": 0,
"pvforecast4_module_model": null,
"pvforecast4_modules_per_string": null,
"pvforecast4_mountingplace": "free",
"pvforecast4_optimal_surface_tilt": false,
"pvforecast4_optimalangles": false,
"pvforecast4_peakpower": null,
"pvforecast4_pvtechchoice": "crystSi",
"pvforecast4_strings_per_inverter": null,
"pvforecast4_surface_azimuth": null,
"pvforecast4_surface_tilt": null,
"pvforecast4_trackingtype": null,
"pvforecast4_userhorizon": null,
"pvforecast_provider": null,
"pvforecastimport_file_path": null,
"server_eos_host": "0.0.0.0",
"server_eos_port": 8503,
"server_eosdash_host": "0.0.0.0",
"server_eosdash_port": 8504,
"weather_provider": null,
"weatherimport_file_path": null
}

View File

@@ -26,15 +26,19 @@
]
},
"pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400,
"max_charge_power_w": 5000,
"initial_soc_percentage": 80,
"min_soc_percentage": 15
},
"inverter": {
"max_power_wh": 10000
"device_id": "inverter1",
"max_power_wh": 10000,
"battery": "battery1"
},
"eauto": {
"device_id": "ev1",
"capacity_wh": 60000,
"charging_efficiency": 0.95,
"discharging_efficiency": 1.0,

View File

@@ -154,6 +154,7 @@
]
},
"pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400,
"initial_soc_percentage": 80,
"min_soc_percentage": 0
@@ -162,13 +163,20 @@
"max_power_wh": 10000
},
"eauto": {
"device_id": "ev1",
"capacity_wh": 60000,
"charging_efficiency": 0.95,
"max_charge_power_w": 11040,
"initial_soc_percentage": 5,
"min_soc_percentage": 80
},
"inverter": {
"device_id": "inverter1",
"max_power_wh": 10000,
"battery": "battery1"
},
"dishwasher": {
"device_id": "dishwasher1",
"consumption_wh": 5000,
"duration_h": 2
},

View File

@@ -557,6 +557,7 @@
]
},
"eauto_obj": {
"device_id": "ev1",
"charge_array": [
1.0,
1.0,

View File

@@ -606,6 +606,7 @@
]
},
"eauto_obj": {
"device_id": "ev1",
"charge_array": [
1.0,
1.0,

View File

@@ -606,6 +606,7 @@
]
},
"eauto_obj": {
"device_id": "ev1",
"charge_array": [
1.0,
1.0,