From be26457563f46a7b74934f3e4255edd38b587ea2 Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Sun, 12 Jan 2025 05:19:37 +0100 Subject: [PATCH] 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. --- docs/_generated/openapi.md | 304 +----------- requirements.txt | 1 + scripts/generate_config_md.py | 6 +- single_test_optimization.py | 84 ++-- single_test_prediction.py | 98 ++-- src/akkudoktoreos/config/config.py | 436 +++++++---------- src/akkudoktoreos/config/configabc.py | 6 +- src/akkudoktoreos/core/coreabc.py | 6 + src/akkudoktoreos/core/dataabc.py | 12 +- src/akkudoktoreos/core/ems.py | 15 +- src/akkudoktoreos/core/pydantic.py | 15 + src/akkudoktoreos/data/default.config.json | 111 ----- src/akkudoktoreos/devices/battery.py | 109 ++--- src/akkudoktoreos/devices/devices.py | 456 +++++++----------- src/akkudoktoreos/devices/devicesabc.py | 127 ++++- src/akkudoktoreos/devices/generic.py | 49 +- src/akkudoktoreos/devices/inverter.py | 56 +-- src/akkudoktoreos/devices/settings.py | 25 + src/akkudoktoreos/measurement/measurement.py | 14 +- src/akkudoktoreos/optimization/genetic.py | 81 ++-- .../optimization/optimization.py | 2 +- src/akkudoktoreos/prediction/elecprice.py | 3 + src/akkudoktoreos/prediction/elecpriceabc.py | 2 +- .../prediction/elecpriceakkudoktor.py | 14 +- .../prediction/elecpriceimport.py | 13 +- src/akkudoktoreos/prediction/interpolator.py | 18 +- src/akkudoktoreos/prediction/load.py | 8 +- src/akkudoktoreos/prediction/loadabc.py | 2 +- .../prediction/loadakkudoktor.py | 8 +- src/akkudoktoreos/prediction/loadimport.py | 12 +- src/akkudoktoreos/prediction/prediction.py | 4 +- src/akkudoktoreos/prediction/predictionabc.py | 10 +- src/akkudoktoreos/prediction/pvforecast.py | 7 +- src/akkudoktoreos/prediction/pvforecastabc.py | 4 +- .../prediction/pvforecastakkudoktor.py | 94 ++-- .../prediction/pvforecastimport.py | 14 +- src/akkudoktoreos/prediction/weather.py | 3 + src/akkudoktoreos/prediction/weatherabc.py | 2 +- .../prediction/weatherbrightsky.py | 6 +- .../prediction/weatherclearoutside.py | 8 +- src/akkudoktoreos/prediction/weatherimport.py | 12 +- src/akkudoktoreos/server/eos.py | 59 ++- src/akkudoktoreos/server/eosdash.py | 8 +- src/akkudoktoreos/utils/cacheutil.py | 4 +- src/akkudoktoreos/utils/utils.py | 10 +- src/akkudoktoreos/utils/visualize.py | 8 +- tests/conftest.py | 36 +- tests/test_battery.py | 13 +- tests/test_class_ems.py | 48 +- tests/test_class_ems_2.py | 53 +- tests/test_class_optimize.py | 4 +- tests/test_config.py | 26 +- tests/test_elecpriceakkudoktor.py | 11 +- tests/test_elecpriceimport.py | 26 +- tests/test_inverter.py | 23 +- tests/test_loadakkudoktor.py | 10 +- tests/test_measurement.py | 18 +- tests/test_prediction.py | 6 +- tests/test_predictionabc.py | 81 ++-- tests/test_pvforecastakkudoktor.py | 79 +-- tests/test_pvforecastimport.py | 26 +- tests/test_server.py | 4 +- tests/test_visualize.py | 2 +- tests/test_weatherbrightsky.py | 16 +- tests/test_weatherclearoutside.py | 28 +- tests/test_weatherimport.py | 26 +- tests/testdata/EOS.config.json | 110 ----- tests/testdata/optimize_input_1.json | 6 +- tests/testdata/optimize_input_2.json | 8 + tests/testdata/optimize_result_1.json | 1 + tests/testdata/optimize_result_2.json | 1 + tests/testdata/optimize_result_2_full.json | 1 + 72 files changed, 1297 insertions(+), 1712 deletions(-) create mode 100644 src/akkudoktoreos/devices/settings.py delete mode 100644 tests/testdata/EOS.config.json diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index 4c1c74f..d1f2110 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -205,309 +205,29 @@ Returns: **Parameters**: -- `server_eos_host` (query, optional): EOS server IP address. +- `general` (query, optional): No description provided. -- `server_eos_port` (query, optional): EOS server IP port number. +- `logging` (query, optional): No description provided. -- `server_eos_verbose` (query, optional): Enable debug output +- `devices` (query, optional): No description provided. -- `server_eos_startup_eosdash` (query, optional): EOS server to start EOSdash server. +- `measurement` (query, optional): No description provided. -- `server_eosdash_host` (query, optional): EOSdash server IP address. +- `optimization` (query, optional): No description provided. -- `server_eosdash_port` (query, optional): EOSdash server IP port number. +- `prediction` (query, optional): No description provided. -- `weatherimport_file_path` (query, optional): Path to the file to import weather data from. +- `elecprice` (query, optional): No description provided. -- `weatherimport_json` (query, optional): JSON string, dictionary of weather forecast value lists. +- `load` (query, optional): No description provided. -- `weather_provider` (query, optional): Weather provider id of provider to be used. +- `pvforecast` (query, optional): No description provided. -- `pvforecastimport_file_path` (query, optional): Path to the file to import PV forecast data from. +- `weather` (query, optional): No description provided. -- `pvforecastimport_json` (query, optional): JSON string, dictionary of PV forecast value lists. +- `server` (query, optional): No description provided. -- `pvforecast_provider` (query, optional): PVForecast provider id of provider to be used. - -- `pvforecast0_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking. - -- `pvforecast0_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - -- `pvforecast0_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - -- `pvforecast0_peakpower` (query, optional): Nominal power of PV system in kW. - -- `pvforecast0_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. - -- `pvforecast0_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. - -- `pvforecast0_loss` (query, optional): Sum of PV system losses in percent - -- `pvforecast0_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. - -- `pvforecast0_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking. - -- `pvforecast0_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. - -- `pvforecast0_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back. - -- `pvforecast0_module_model` (query, optional): Model of the PV modules of this plane. - -- `pvforecast0_inverter_model` (query, optional): Model of the inverter of this plane. - -- `pvforecast0_inverter_paco` (query, optional): AC power rating of the inverter. [W] - -- `pvforecast0_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane. - -- `pvforecast0_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane. - -- `pvforecast1_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking. - -- `pvforecast1_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - -- `pvforecast1_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - -- `pvforecast1_peakpower` (query, optional): Nominal power of PV system in kW. - -- `pvforecast1_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. - -- `pvforecast1_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. - -- `pvforecast1_loss` (query, optional): Sum of PV system losses in percent - -- `pvforecast1_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. - -- `pvforecast1_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking. - -- `pvforecast1_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. - -- `pvforecast1_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back. - -- `pvforecast1_module_model` (query, optional): Model of the PV modules of this plane. - -- `pvforecast1_inverter_model` (query, optional): Model of the inverter of this plane. - -- `pvforecast1_inverter_paco` (query, optional): AC power rating of the inverter. [W] - -- `pvforecast1_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane. - -- `pvforecast1_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane. - -- `pvforecast2_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking. - -- `pvforecast2_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - -- `pvforecast2_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - -- `pvforecast2_peakpower` (query, optional): Nominal power of PV system in kW. - -- `pvforecast2_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. - -- `pvforecast2_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. - -- `pvforecast2_loss` (query, optional): Sum of PV system losses in percent - -- `pvforecast2_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. - -- `pvforecast2_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking. - -- `pvforecast2_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. - -- `pvforecast2_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back. - -- `pvforecast2_module_model` (query, optional): Model of the PV modules of this plane. - -- `pvforecast2_inverter_model` (query, optional): Model of the inverter of this plane. - -- `pvforecast2_inverter_paco` (query, optional): AC power rating of the inverter. [W] - -- `pvforecast2_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane. - -- `pvforecast2_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane. - -- `pvforecast3_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking. - -- `pvforecast3_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - -- `pvforecast3_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - -- `pvforecast3_peakpower` (query, optional): Nominal power of PV system in kW. - -- `pvforecast3_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. - -- `pvforecast3_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. - -- `pvforecast3_loss` (query, optional): Sum of PV system losses in percent - -- `pvforecast3_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. - -- `pvforecast3_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking. - -- `pvforecast3_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. - -- `pvforecast3_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back. - -- `pvforecast3_module_model` (query, optional): Model of the PV modules of this plane. - -- `pvforecast3_inverter_model` (query, optional): Model of the inverter of this plane. - -- `pvforecast3_inverter_paco` (query, optional): AC power rating of the inverter. [W] - -- `pvforecast3_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane. - -- `pvforecast3_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane. - -- `pvforecast4_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking. - -- `pvforecast4_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - -- `pvforecast4_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - -- `pvforecast4_peakpower` (query, optional): Nominal power of PV system in kW. - -- `pvforecast4_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. - -- `pvforecast4_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. - -- `pvforecast4_loss` (query, optional): Sum of PV system losses in percent - -- `pvforecast4_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. - -- `pvforecast4_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking. - -- `pvforecast4_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. - -- `pvforecast4_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back. - -- `pvforecast4_module_model` (query, optional): Model of the PV modules of this plane. - -- `pvforecast4_inverter_model` (query, optional): Model of the inverter of this plane. - -- `pvforecast4_inverter_paco` (query, optional): AC power rating of the inverter. [W] - -- `pvforecast4_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane. - -- `pvforecast4_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane. - -- `pvforecast5_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking. - -- `pvforecast5_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - -- `pvforecast5_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - -- `pvforecast5_peakpower` (query, optional): Nominal power of PV system in kW. - -- `pvforecast5_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. - -- `pvforecast5_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. - -- `pvforecast5_loss` (query, optional): Sum of PV system losses in percent - -- `pvforecast5_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. - -- `pvforecast5_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking. - -- `pvforecast5_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. - -- `pvforecast5_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back. - -- `pvforecast5_module_model` (query, optional): Model of the PV modules of this plane. - -- `pvforecast5_inverter_model` (query, optional): Model of the inverter of this plane. - -- `pvforecast5_inverter_paco` (query, optional): AC power rating of the inverter. [W] - -- `pvforecast5_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane. - -- `pvforecast5_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane. - -- `load_import_file_path` (query, optional): Path to the file to import load data from. - -- `load_import_json` (query, optional): JSON string, dictionary of load forecast value lists. - -- `loadakkudoktor_year_energy` (query, optional): Yearly energy consumption (kWh). - -- `load_provider` (query, optional): Load provider id of provider to be used. - -- `elecpriceimport_file_path` (query, optional): Path to the file to import elecprice data from. - -- `elecpriceimport_json` (query, optional): JSON string, dictionary of electricity price forecast value lists. - -- `elecprice_provider` (query, optional): Electricity price provider id of provider to be used. - -- `elecprice_charges_kwh` (query, optional): Electricity price charges (€/kWh). - -- `prediction_hours` (query, optional): Number of hours into the future for predictions - -- `prediction_historic_hours` (query, optional): Number of hours into the past for historical predictions data - -- `latitude` (query, optional): Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°) - -- `longitude` (query, optional): Longitude in decimal degrees, within -180 to 180 (°) - -- `optimization_hours` (query, optional): Number of hours into the future for optimizations. - -- `optimization_penalty` (query, optional): Penalty factor used in optimization. - -- `optimization_ev_available_charge_rates_percent` (query, optional): Charge rates available for the EV in percent of maximum charge. - -- `measurement_load0_name` (query, optional): Name of the load0 source (e.g. 'Household', 'Heat Pump') - -- `measurement_load1_name` (query, optional): Name of the load1 source (e.g. 'Household', 'Heat Pump') - -- `measurement_load2_name` (query, optional): Name of the load2 source (e.g. 'Household', 'Heat Pump') - -- `measurement_load3_name` (query, optional): Name of the load3 source (e.g. 'Household', 'Heat Pump') - -- `measurement_load4_name` (query, optional): Name of the load4 source (e.g. 'Household', 'Heat Pump') - -- `battery_provider` (query, optional): Id of Battery simulation provider. - -- `battery_capacity` (query, optional): Battery capacity [Wh]. - -- `battery_initial_soc` (query, optional): Battery initial state of charge [%]. - -- `battery_soc_min` (query, optional): Battery minimum state of charge [%]. - -- `battery_soc_max` (query, optional): Battery maximum state of charge [%]. - -- `battery_charging_efficiency` (query, optional): Battery charging efficiency [%]. - -- `battery_discharging_efficiency` (query, optional): Battery discharging efficiency [%]. - -- `battery_max_charging_power` (query, optional): Battery maximum charge power [W]. - -- `bev_provider` (query, optional): Id of Battery Electric Vehicle simulation provider. - -- `bev_capacity` (query, optional): Battery Electric Vehicle capacity [Wh]. - -- `bev_initial_soc` (query, optional): Battery Electric Vehicle initial state of charge [%]. - -- `bev_soc_max` (query, optional): Battery Electric Vehicle maximum state of charge [%]. - -- `bev_charging_efficiency` (query, optional): Battery Electric Vehicle charging efficiency [%]. - -- `bev_discharging_efficiency` (query, optional): Battery Electric Vehicle discharging efficiency [%]. - -- `bev_max_charging_power` (query, optional): Battery Electric Vehicle maximum charge power [W]. - -- `dishwasher_provider` (query, optional): Id of Dish Washer simulation provider. - -- `dishwasher_consumption` (query, optional): Dish Washer energy consumption [Wh]. - -- `dishwasher_duration` (query, optional): Dish Washer usage duration [h]. - -- `inverter_provider` (query, optional): Id of PV Inverter simulation provider. - -- `inverter_power_max` (query, optional): Inverter maximum power [W]. - -- `logging_level_default` (query, optional): EOS default logging level. - -- `data_folder_path` (query, optional): Path to EOS data directory. - -- `data_output_subpath` (query, optional): Sub-path for the EOS output data directory. - -- `data_cache_subpath` (query, optional): Sub-path for the EOS cache data directory. +- `utils` (query, optional): No description provided. **Responses**: diff --git a/requirements.txt b/requirements.txt index 0221c80..8038da7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ platformdirs==4.3.6 pvlib==0.11.2 pydantic==2.10.5 statsmodels==0.14.4 +pydantic-settings==2.7.0 diff --git a/scripts/generate_config_md.py b/scripts/generate_config_md.py index ae7120c..c58b8ec 100755 --- a/scripts/generate_config_md.py +++ b/scripts/generate_config_md.py @@ -79,9 +79,11 @@ def generate_config_md() -> str: Returns: str: The Markdown representation of the configuration spec. """ + # FIXME: Support for nested configs = {} - config_keys = config_eos.config_keys - config_keys_read_only = config_eos.config_keys_read_only + config_keys = config_eos.model_fields_set + # config_keys_read_only = config_eos.config_keys_read_only + config_keys_read_only: list[str] = [] for config_key in config_keys: config = {} config["name"] = config_key diff --git a/single_test_optimization.py b/single_test_optimization.py index 761e11e..a1389a1 100755 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -30,42 +30,52 @@ def prepare_optimization_real_parameters() -> OptimizationParameters: """ # Make a config settings = { - # -- General -- - "prediction_hours": 48, - "prediction_historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, - # -- Predictions -- + "prediction": { + "prediction_hours": 48, + "prediction_historic_hours": 24, + "latitude": 52.52, + "longitude": 13.405, + }, # PV Forecast - "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, + "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, + }, # Weather Forecast - "weather_provider": "ClearOutside", + "weather": { + "weather_provider": "ClearOutside", + }, # Electricity Price Forecast - "elecprice_provider": "ElecPriceAkkudoktor", + "elecprice": { + "elecprice_provider": "ElecPriceAkkudoktor", + }, # Load Forecast - "load_provider": "LoadAkkudoktor", - "loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh + "load": { + "load_provider": "LoadAkkudoktor", + "provider_settings": { + "loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh + }, + }, # -- Simulations -- } config_eos = get_config() @@ -129,11 +139,14 @@ def prepare_optimization_real_parameters() -> OptimizationParameters: "strompreis_euro_pro_wh": strompreis_euro_pro_wh, }, "pv_akku": { + "device_id": "battery1", "capacity_wh": 26400, "initial_soc_percentage": 15, "min_soc_percentage": 15, }, + "inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery": "battery1"}, "eauto": { + "device_id": "ev1", "min_soc_percentage": 50, "capacity_wh": 60000, "charging_efficiency": 0.95, @@ -283,11 +296,14 @@ def prepare_optimization_parameters() -> OptimizationParameters: "strompreis_euro_pro_wh": strompreis_euro_pro_wh, }, "pv_akku": { + "device_id": "battery1", "capacity_wh": 26400, "initial_soc_percentage": 15, "min_soc_percentage": 15, }, + "inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery": "battery1"}, "eauto": { + "device_id": "ev1", "min_soc_percentage": 50, "capacity_wh": 60000, "charging_efficiency": 0.95, @@ -330,7 +346,9 @@ def run_optimization( # Initialize the optimization problem using the default configuration config_eos = get_config() - 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}} + ) opt_class = optimization_problem(verbose=verbose, fixed_seed=seed) # Perform the optimisation based on the provided parameters and start hour diff --git a/single_test_prediction.py b/single_test_prediction.py index 870425d..7b6911f 100644 --- a/single_test_prediction.py +++ b/single_test_prediction.py @@ -16,32 +16,36 @@ prediction_eos = get_prediction() def config_pvforecast() -> dict: """Configure settings for PV forecast.""" 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, + }, } return settings @@ -49,10 +53,13 @@ def config_pvforecast() -> dict: def config_weather() -> dict: """Configure settings for weather forecast.""" settings = { - "prediction_hours": 48, - "prediction_historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, + "prediction": { + "prediction_hours": 48, + "prediction_historic_hours": 24, + "latitude": 52.52, + "longitude": 13.405, + }, + "weather": dict(), } return settings @@ -60,10 +67,13 @@ def config_weather() -> dict: def config_elecprice() -> dict: """Configure settings for electricity price forecast.""" settings = { - "prediction_hours": 48, - "prediction_historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, + "prediction": { + "prediction_hours": 48, + "prediction_historic_hours": 24, + "latitude": 52.52, + "longitude": 13.405, + }, + "elecprice": dict(), } return settings @@ -71,10 +81,12 @@ def config_elecprice() -> dict: def config_load() -> dict: """Configure settings for load forecast.""" settings = { - "prediction_hours": 48, - "prediction_historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, + "prediction": { + "prediction_hours": 48, + "prediction_historic_hours": 24, + "latitude": 52.52, + "longitude": 13.405, + } } return settings @@ -96,17 +108,17 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str: print(f"\nProvider ID: {provider_id}") if provider_id in ("PVForecastAkkudoktor",): settings = config_pvforecast() - settings["pvforecast_provider"] = provider_id + settings["pvforecast"]["pvforecast_provider"] = provider_id elif provider_id in ("BrightSky", "ClearOutside"): settings = config_weather() - settings["weather_provider"] = provider_id + settings["weather"]["weather_provider"] = provider_id elif provider_id in ("ElecPriceAkkudoktor",): settings = config_elecprice() - settings["elecprice_provider"] = provider_id + settings["elecprice"]["elecprice_provider"] = provider_id elif provider_id in ("LoadAkkudoktor",): settings = config_elecprice() - settings["loadakkudoktor_year_energy"] = 1000 - settings["load_provider"] = provider_id + settings["load"]["loadakkudoktor_year_energy"] = 1000 + settings["load"]["load_provider"] = provider_id else: raise ValueError(f"Unknown provider '{provider_id}'.") config_eos.merge_settings_from_dict(settings) diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index eae406a..891b6d5 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -12,31 +12,34 @@ Key features: import os import shutil from pathlib import Path -from typing import Any, ClassVar, List, Optional +from typing import Any, ClassVar, Optional, Type from platformdirs import user_config_dir, user_data_dir -from pydantic import Field, ValidationError, computed_field +from pydantic import Field, computed_field +from pydantic_settings import ( + BaseSettings, + JsonConfigSettingsSource, + PydanticBaseSettingsSource, + SettingsConfigDict, +) +from pydantic_settings.sources import ConfigFileSourceMixin # settings from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.coreabc import SingletonMixin from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logsettings import LoggingCommonSettings -from akkudoktoreos.devices.devices import DevicesCommonSettings +from akkudoktoreos.core.pydantic import merge_models +from akkudoktoreos.devices.settings import DevicesCommonSettings from akkudoktoreos.measurement.measurement import MeasurementCommonSettings from akkudoktoreos.optimization.optimization import OptimizationCommonSettings from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings -from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings from akkudoktoreos.prediction.load import LoadCommonSettings -from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings -from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings from akkudoktoreos.prediction.prediction import PredictionCommonSettings from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings -from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings from akkudoktoreos.prediction.weather import WeatherCommonSettings -from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings from akkudoktoreos.server.server import ServerCommonSettings -from akkudoktoreos.utils.utils import UtilsCommonSettings +from akkudoktoreos.utils.utils import UtilsCommonSettings, classproperty logger = get_logger(__name__) @@ -67,11 +70,11 @@ class ConfigCommonSettings(SettingsBaseModel): ) data_output_subpath: Optional[Path] = Field( - "output", description="Sub-path for the EOS output data directory." + default="output", description="Sub-path for the EOS output data directory." ) data_cache_subpath: Optional[Path] = Field( - "cache", description="Sub-path for the EOS cache data directory." + default="cache", description="Sub-path for the EOS cache data directory." ) # Computed fields @@ -89,31 +92,51 @@ class ConfigCommonSettings(SettingsBaseModel): return get_absolute_path(self.data_folder_path, self.data_cache_subpath) -class SettingsEOS( - ConfigCommonSettings, - LoggingCommonSettings, - DevicesCommonSettings, - MeasurementCommonSettings, - OptimizationCommonSettings, - PredictionCommonSettings, - ElecPriceCommonSettings, - ElecPriceImportCommonSettings, - LoadCommonSettings, - LoadAkkudoktorCommonSettings, - LoadImportCommonSettings, - PVForecastCommonSettings, - PVForecastImportCommonSettings, - WeatherCommonSettings, - WeatherImportCommonSettings, - ServerCommonSettings, - UtilsCommonSettings, -): - """Settings for all EOS.""" +class SettingsEOS(BaseSettings): + """Settings for all EOS. - pass + Used by updating the configuration with specific settings only. + """ + + general: Optional[ConfigCommonSettings] = None + logging: Optional[LoggingCommonSettings] = None + devices: Optional[DevicesCommonSettings] = None + measurement: Optional[MeasurementCommonSettings] = None + optimization: Optional[OptimizationCommonSettings] = None + prediction: Optional[PredictionCommonSettings] = None + elecprice: Optional[ElecPriceCommonSettings] = None + load: Optional[LoadCommonSettings] = None + pvforecast: Optional[PVForecastCommonSettings] = None + weather: Optional[WeatherCommonSettings] = None + server: Optional[ServerCommonSettings] = None + utils: Optional[UtilsCommonSettings] = None + + model_config = SettingsConfigDict( + env_nested_delimiter="__", nested_model_default_partial_update=True, env_prefix="EOS_" + ) -class ConfigEOS(SingletonMixin, SettingsEOS): +class SettingsEOSDefaults(SettingsEOS): + """Settings for all of EOS with defaults. + + Used by ConfigEOS instance to make all fields available. + """ + + general: ConfigCommonSettings = ConfigCommonSettings() + logging: LoggingCommonSettings = LoggingCommonSettings() + devices: DevicesCommonSettings = DevicesCommonSettings() + measurement: MeasurementCommonSettings = MeasurementCommonSettings() + optimization: OptimizationCommonSettings = OptimizationCommonSettings() + prediction: PredictionCommonSettings = PredictionCommonSettings() + elecprice: ElecPriceCommonSettings = ElecPriceCommonSettings() + load: LoadCommonSettings = LoadCommonSettings() + pvforecast: PVForecastCommonSettings = PVForecastCommonSettings() + weather: WeatherCommonSettings = WeatherCommonSettings() + server: ServerCommonSettings = ServerCommonSettings() + utils: UtilsCommonSettings = UtilsCommonSettings() + + +class ConfigEOS(SingletonMixin, SettingsEOSDefaults): """Singleton configuration handler for the EOS application. ConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic @@ -143,8 +166,6 @@ class ConfigEOS(SingletonMixin, SettingsEOS): in one part of the application reflects across all references to this class. Attributes: - _settings (ClassVar[SettingsEOS]): Holds application-wide settings. - _file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file. config_folder_path (Optional[Path]): Path to the configuration directory. config_file_path (Optional[Path]): Path to the configuration file. @@ -155,7 +176,7 @@ class ConfigEOS(SingletonMixin, SettingsEOS): To initialize and access configuration attributes (only one instance is created): ```python config_eos = ConfigEOS() # Always returns the same instance - print(config_eos.prediction_hours) # Access a setting from the loaded configuration + print(config_eos.prediction.prediction_hours) # Access a setting from the loaded configuration ``` """ @@ -167,111 +188,126 @@ class ConfigEOS(SingletonMixin, SettingsEOS): ENCODING: ClassVar[str] = "UTF-8" CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json" - _settings: ClassVar[Optional[SettingsEOS]] = None - _file_settings: ClassVar[Optional[SettingsEOS]] = None + _config_folder_path: ClassVar[Optional[Path]] = None + _config_file_path: ClassVar[Optional[Path]] = None - _config_folder_path: Optional[Path] = None - _config_file_path: Optional[Path] = None + @classmethod + def settings_customise_sources( + cls, + settings_cls: Type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Customizes the order and handling of settings sources for a Pydantic BaseSettings subclass. + + This method determines the sources for application configuration settings, including + environment variables, dotenv files, JSON configuration files, and file secrets. + It ensures that a default configuration file exists and creates one if necessary. + + Args: + settings_cls (Type[BaseSettings]): The Pydantic BaseSettings class for which sources are customized. + init_settings (PydanticBaseSettingsSource): The initial settings source, typically passed at runtime. + env_settings (PydanticBaseSettingsSource): Settings sourced from environment variables. + dotenv_settings (PydanticBaseSettingsSource): Settings sourced from a dotenv file. + file_secret_settings (PydanticBaseSettingsSource): Settings sourced from secret files. + + Returns: + tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied. + + Behavior: + 1. Checks for the existence of a JSON configuration file in the expected location. + 2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a + default configuration file to the location. If the copy fails, uses the default configuration file directly. + 3. Creates a `JsonConfigSettingsSource` for both the configuration file and the default configuration file. + 4. Updates class attributes `_config_folder_path` and `_config_file_path` to reflect the determined paths. + 5. Returns a tuple containing all provided and newly created settings sources in the desired order. + + Notes: + - This method logs a warning if the default configuration file cannot be copied. + - It ensures that a fallback to the default configuration file is always possible. + """ + file_settings: Optional[ConfigFileSourceMixin] = None + config_file, exists = cls._get_config_file_path() + config_dir = config_file.parent + if not exists: + config_dir.mkdir(parents=True, exist_ok=True) + try: + shutil.copy2(cls.config_default_file_path, config_file) + except Exception as exc: + logger.warning(f"Could not copy default config: {exc}. Using default config...") + config_file = cls.config_default_file_path + config_dir = config_file.parent + file_settings = JsonConfigSettingsSource(settings_cls, json_file=config_file) + default_settings = JsonConfigSettingsSource( + settings_cls, json_file=cls.config_default_file_path + ) + cls._config_folder_path = config_dir + cls._config_file_path = config_file + + return ( + init_settings, + env_settings, + dotenv_settings, + file_settings, + file_secret_settings, + default_settings, + ) - # Computed fields - @computed_field # type: ignore[prop-decorator] @property def config_folder_path(self) -> Optional[Path]: """Path to EOS configuration directory.""" return self._config_folder_path - @computed_field # type: ignore[prop-decorator] @property def config_file_path(self) -> Optional[Path]: """Path to EOS configuration file.""" return self._config_file_path - @computed_field # type: ignore[prop-decorator] - @property - def config_default_file_path(self) -> Path: + @classmethod + @classproperty + def config_default_file_path(cls) -> Path: """Compute the default config file path.""" - return self.package_root_path.joinpath("data/default.config.json") + return cls.package_root_path.joinpath("data/default.config.json") - @computed_field # type: ignore[prop-decorator] - @property - def package_root_path(self) -> Path: + @classmethod + @classproperty + def package_root_path(cls) -> Path: """Compute the package root path.""" return Path(__file__).parent.parent.resolve() - # Computed fields - @computed_field # type: ignore[prop-decorator] - @property - def config_keys(self) -> List[str]: - """Returns the keys of all fields in the configuration.""" - key_list = [] - key_list.extend(list(self.model_fields.keys())) - key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys())) - return key_list - - # Computed fields - @computed_field # type: ignore[prop-decorator] - @property - def config_keys_read_only(self) -> List[str]: - """Returns the keys of all read only fields in the configuration.""" - key_list = [] - key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys())) - return key_list - - def __init__(self) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: """Initializes the singleton ConfigEOS instance. Configuration data is loaded from a configuration file or a default one is created if none exists. """ - super().__init__() - self.from_config_file() - self.update() + if hasattr(self, "_initialized"): + return + super().__init__(*args, **kwargs) + self._create_initial_config_file() + self._update_data_folder_path() - @property - def settings(self) -> Optional[SettingsEOS]: - """Returns global settings for EOS. + def _setup(self, *args: Any, **kwargs: Any) -> None: + """Re-initialize global settings.""" + SettingsEOSDefaults.__init__(self, *args, **kwargs) + self._create_initial_config_file() + self._update_data_folder_path() - Settings generally provide configuration for EOS and are typically set only once. - - Returns: - SettingsEOS: The settings for EOS or None. - """ - return ConfigEOS._settings - - @classmethod - def _merge_and_update_settings(cls, settings: SettingsEOS) -> None: - """Merge new and available settings. - - Args: - settings (SettingsEOS): The new settings to apply. - """ - for key in SettingsEOS.model_fields: - if value := getattr(settings, key, None): - setattr(cls._settings, key, value) - - def merge_settings(self, settings: SettingsEOS, force: Optional[bool] = None) -> None: + def merge_settings(self, settings: SettingsEOS) -> None: """Merges the provided settings into the global settings for EOS, with optional overwrite. Args: settings (SettingsEOS): The settings to apply globally. - force (Optional[bool]): If True, overwrites the existing settings completely. - If False, the new settings are merged to the existing ones with priority for - the new ones. Defaults to False. Raises: - ValueError: If settings are already set and `force` is not True or - if the `settings` is not a `SettingsEOS` instance. + ValueError: If the `settings` is not a `SettingsEOS` instance. """ if not isinstance(settings, SettingsEOS): raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.") - if ConfigEOS._settings is None or force: - ConfigEOS._settings = settings - else: - self._merge_and_update_settings(settings) - - # Update configuration after merging - self.update() + self.merge_settings_from_dict(settings.model_dump()) def merge_settings_from_dict(self, data: dict) -> None: """Merges the provided dictionary data into the current instance. @@ -289,141 +325,78 @@ class ConfigEOS(SingletonMixin, SettingsEOS): Example: >>> config = get_config() - >>> new_data = {"prediction_hours": 24, "server_eos_port": 8000} + >>> new_data = {"prediction": {"prediction_hours": 24}, "server": {"server_eos_port": 8000}} >>> config.merge_settings_from_dict(new_data) """ - # Create new settings instance with reset optional fields and merged data - settings = SettingsEOS.from_dict(data) - self.merge_settings(settings) + self._setup(**merge_models(self, data)) def reset_settings(self) -> None: - """Reset all available settings. + """Reset all changed settings to environment/config file defaults. This functions basically deletes the settings provided before. """ - ConfigEOS._settings = None + self._setup() + + def _create_initial_config_file(self) -> None: + if self.config_file_path is not None and not self.config_file_path.exists(): + self.config_file_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.config_file_path, "w") as f: + f.write(self.model_dump_json(indent=4)) def _update_data_folder_path(self) -> None: """Updates path to the data directory.""" # From Settings - if self.settings and (data_dir := self.settings.data_folder_path): + if data_dir := self.general.data_folder_path: try: data_dir.mkdir(parents=True, exist_ok=True) - self.data_folder_path = data_dir + self.general.data_folder_path = data_dir return - except: - pass + except Exception as e: + logger.warning(f"Could not setup data dir: {e}") # From EOS_DIR env - env_dir = os.getenv(self.EOS_DIR) - if env_dir is not None: + if env_dir := os.getenv(self.EOS_DIR): try: data_dir = Path(env_dir).resolve() data_dir.mkdir(parents=True, exist_ok=True) - self.data_folder_path = data_dir + self.general.data_folder_path = data_dir return - except: - pass - # From configuration file - if self._file_settings and (data_dir := self._file_settings.data_folder_path): - try: - data_dir.mkdir(parents=True, exist_ok=True) - self.data_folder_path = data_dir - return - except: - pass + except Exception as e: + logger.warning(f"Could not setup data dir: {e}") # From platform specific default path try: data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR)) if data_dir is not None: data_dir.mkdir(parents=True, exist_ok=True) - self.data_folder_path = data_dir + self.general.data_folder_path = data_dir return - except: - pass + except Exception as e: + logger.warning(f"Could not setup data dir: {e}") # Current working directory data_dir = Path.cwd() - self.data_folder_path = data_dir + self.general.data_folder_path = data_dir - def _get_config_file_path(self) -> tuple[Path, bool]: + @classmethod + def _get_config_file_path(cls) -> tuple[Path, bool]: """Finds the a valid configuration file or returns the desired path for a new config file. Returns: tuple[Path, bool]: The path to the configuration directory and if there is already a config file there """ config_dirs = [] - env_base_dir = os.getenv(self.EOS_DIR) - env_config_dir = os.getenv(self.EOS_CONFIG_DIR) + env_base_dir = os.getenv(cls.EOS_DIR) + env_config_dir = os.getenv(cls.EOS_CONFIG_DIR) env_dir = get_absolute_path(env_base_dir, env_config_dir) - logger.debug(f"Envionment config dir: '{env_dir}'") + logger.debug(f"Environment config dir: '{env_dir}'") if env_dir is not None: config_dirs.append(env_dir.resolve()) - config_dirs.append(Path(user_config_dir(self.APP_NAME))) + config_dirs.append(Path(user_config_dir(cls.APP_NAME))) config_dirs.append(Path.cwd()) for cdir in config_dirs: - cfile = cdir.joinpath(self.CONFIG_FILE_NAME) + cfile = cdir.joinpath(cls.CONFIG_FILE_NAME) if cfile.exists(): logger.debug(f"Found config file: '{cfile}'") return cfile, True - return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False - - def settings_from_config_file(self) -> tuple[SettingsEOS, Path]: - """Load settings from the configuration file. - - If the config file does not exist, it will be created. - - Returns: - tuple of settings and path - settings (SettingsEOS): The settings defined by the EOS configuration file. - path (pathlib.Path): The path of the configuration file. - - Raises: - ValueError: If the configuration file is invalid or incomplete. - """ - config_file, exists = self._get_config_file_path() - config_dir = config_file.parent - - # Create config directory and copy default config if file does not exist - if not exists: - config_dir.mkdir(parents=True, exist_ok=True) - try: - shutil.copy2(self.config_default_file_path, config_file) - except Exception as exc: - logger.warning(f"Could not copy default config: {exc}. Using default config...") - config_file = self.config_default_file_path - config_dir = config_file.parent - - # Load and validate the configuration file - with config_file.open("r", encoding=self.ENCODING) as f_in: - try: - json_str = f_in.read() - settings = SettingsEOS.model_validate_json(json_str) - except ValidationError as exc: - raise ValueError(f"Configuration '{config_file}' is incomplete or not valid: {exc}") - - return settings, config_file - - def from_config_file(self) -> tuple[SettingsEOS, Path]: - """Load the configuration file settings for EOS. - - Returns: - tuple of settings and path - settings (SettingsEOS): The settings defined by the EOS configuration file. - path (pathlib.Path): The path of the configuration file. - - Raises: - ValueError: If the configuration file is invalid or incomplete. - """ - # Load settings from config file - ConfigEOS._file_settings, config_file = self.settings_from_config_file() - - # Update configuration in memory - self.update() - - # Everything worked, remember the values - self._config_folder_path = config_file.parent - self._config_file_path = config_file - - return ConfigEOS._file_settings, config_file + return config_dirs[0].joinpath(cls.CONFIG_FILE_NAME), False def to_config_file(self) -> None: """Saves the current configuration to the configuration file. @@ -436,74 +409,21 @@ class ConfigEOS(SingletonMixin, SettingsEOS): if not self.config_file_path: raise ValueError("Configuration file path unknown.") with self.config_file_path.open("w", encoding=self.ENCODING) as f_out: - try: - json_str = super().to_json() - # Write to file - f_out.write(json_str) - # Also remember as actual settings - ConfigEOS._file_settings = SettingsEOS.model_validate_json(json_str) - except ValidationError as exc: - raise ValueError(f"Could not update '{self.config_file_path}': {exc}") - - def _config_value(self, key: str) -> Any: - """Retrieves the configuration value for a specific key, following a priority order. - - Values are fetched in the following order: - 1. Settings. - 2. Environment variables. - 3. EOS configuration file. - 4. Current configuration. - 5. Field default constants. - - Args: - key (str): The configuration key to retrieve. - - Returns: - Any: The configuration value, or None if not found. - """ - # Settings - if ConfigEOS._settings: - if (value := getattr(self.settings, key, None)) is not None: - return value - - # Environment variables - if (value := os.getenv(key)) is not None: - try: - return float(value) - except ValueError: - return value - - # EOS configuration file. - if self._file_settings: - if (value := getattr(self._file_settings, key, None)) is not None: - return value - - # Current configuration - key is valid as called by update(). - if (value := getattr(self, key, None)) is not None: - return value - - # Field default constants - if (value := ConfigEOS.model_fields[key].default) is not None: - return value - - logger.debug(f"Value for configuration key '{key}' not found or is {value}") - return None + json_str = super().model_dump_json() + f_out.write(json_str) def update(self) -> None: """Updates all configuration fields. This method updates all configuration fields using the following order for value retrieval: - 1. Settings. + 1. Current settings. 2. Environment variables. 3. EOS configuration file. - 4. Current configuration. - 5. Field default constants. + 4. Field default constants. The first non None value in priority order is taken. """ - self._update_data_folder_path() - for key in self.model_fields: - setattr(self, key, self._config_value(key)) + self._setup(**self.model_dump()) def get_config() -> ConfigEOS: diff --git a/src/akkudoktoreos/config/configabc.py b/src/akkudoktoreos/config/configabc.py index fe8bb9e..4c1f46b 100644 --- a/src/akkudoktoreos/config/configabc.py +++ b/src/akkudoktoreos/config/configabc.py @@ -4,10 +4,6 @@ from akkudoktoreos.core.pydantic import PydanticBaseModel class SettingsBaseModel(PydanticBaseModel): - """Base model class for all settings configurations. - - Note: - Settings property names shall be disjunctive to all existing settings' property names. - """ + """Base model class for all settings configurations.""" pass diff --git a/src/akkudoktoreos/core/coreabc.py b/src/akkudoktoreos/core/coreabc.py index 4b00783..7caf7aa 100644 --- a/src/akkudoktoreos/core/coreabc.py +++ b/src/akkudoktoreos/core/coreabc.py @@ -265,6 +265,12 @@ class SingletonMixin: class MySingletonModel(SingletonMixin, PydanticBaseModel): name: str + # implement __init__ to avoid re-initialization of parent class PydanticBaseModel: + def __init__(self, *args: Any, **kwargs: Any) -> None: + if hasattr(self, "_initialized"): + return + super().__init__(*args, **kwargs) + instance1 = MySingletonModel(name="Instance 1") instance2 = MySingletonModel(name="Instance 2") diff --git a/src/akkudoktoreos/core/dataabc.py b/src/akkudoktoreos/core/dataabc.py index a1d55b7..9facbd8 100644 --- a/src/akkudoktoreos/core/dataabc.py +++ b/src/akkudoktoreos/core/dataabc.py @@ -1110,7 +1110,7 @@ class DataProvider(SingletonMixin, DataSequence): To be implemented by derived classes. """ - return self.provider_id() == self.config.abstract_provider + raise NotImplementedError() @abstractmethod def _update_data(self, force_update: Optional[bool] = False) -> None: @@ -1121,6 +1121,11 @@ class DataProvider(SingletonMixin, DataSequence): """ pass + def __init__(self, *args: Any, **kwargs: Any) -> None: + if hasattr(self, "_initialized"): + return + super().__init__(*args, **kwargs) + def update_data( self, force_enable: Optional[bool] = False, @@ -1595,6 +1600,11 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping): ) return list(key_set) + def __init__(self, *args: Any, **kwargs: Any) -> None: + if hasattr(self, "_initialized"): + return + super().__init__(*args, **kwargs) + def __getitem__(self, key: str) -> pd.Series: """Retrieve a Pandas Series for a specified key from the data in each DataProvider. diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index 58bf364..830dc80 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -169,6 +169,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD") ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD") + def __init__(self, *args: Any, **kwargs: Any) -> None: + if hasattr(self, "_initialized"): + return + super().__init__(*args, **kwargs) + def set_parameters( self, parameters: EnergieManagementSystemParameters, @@ -193,9 +198,9 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda self.ev = ev self.home_appliance = home_appliance self.inverter = inverter - self.ac_charge_hours = np.full(self.config.prediction_hours, 0.0) - self.dc_charge_hours = np.full(self.config.prediction_hours, 1.0) - self.ev_charge_hours = np.full(self.config.prediction_hours, 0.0) + self.ac_charge_hours = np.full(self.config.prediction.prediction_hours, 0.0) + self.dc_charge_hours = np.full(self.config.prediction.prediction_hours, 1.0) + self.ev_charge_hours = np.full(self.config.prediction.prediction_hours, 0.0) def set_akku_discharge_hours(self, ds: np.ndarray) -> None: if self.battery is not None: @@ -246,11 +251,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda error_msg = "Start datetime unknown." logger.error(error_msg) raise ValueError(error_msg) - if self.config.prediction_hours is None: + if self.config.prediction.prediction_hours is None: error_msg = "Prediction hours unknown." logger.error(error_msg) raise ValueError(error_msg) - if self.config.optimisation_hours is None: + if self.config.prediction.optimisation_hours is None: error_msg = "Optimisation hours unknown." logger.error(error_msg) raise ValueError(error_msg) diff --git a/src/akkudoktoreos/core/pydantic.py b/src/akkudoktoreos/core/pydantic.py index 28a29c7..b1a004f 100644 --- a/src/akkudoktoreos/core/pydantic.py +++ b/src/akkudoktoreos/core/pydantic.py @@ -35,6 +35,21 @@ from pydantic import ( from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration +def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]: + def deep_update(source_dict: dict[str, Any], update_dict: dict[str, Any]) -> dict[str, Any]: + for key, value in source_dict.items(): + if isinstance(value, dict) and isinstance(update_dict.get(key), dict): + update_dict[key] = deep_update(update_dict[key], value) + else: + update_dict[key] = value + return update_dict + + source_dict = source.model_dump(exclude_unset=True) + merged_dict = deep_update(source_dict, update_dict) + + return merged_dict + + class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]): """Custom type adapter for Pendulum DateTime fields.""" diff --git a/src/akkudoktoreos/data/default.config.json b/src/akkudoktoreos/data/default.config.json index 088c605..2c63c08 100644 --- a/src/akkudoktoreos/data/default.config.json +++ b/src/akkudoktoreos/data/default.config.json @@ -1,113 +1,2 @@ { - "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_charges_kwh": 0.21, - "elecprice_provider": null, - "elecpriceimport_file_path": null, - "latitude": 52.5, - "load_import_file_path": null, - "load_name": null, - "load_provider": null, - "loadakkudoktor_year_energy": null, - "logging_level": "INFO", - "longitude": 13.4, - "optimization_ev_available_charge_rates_percent": null, - "optimization_hours": 48, - "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": 180, - "pvforecast0_surface_tilt": 0, - "pvforecast0_trackingtype": 0, - "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": 180, - "pvforecast1_surface_tilt": 0, - "pvforecast1_trackingtype": 0, - "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": 180, - "pvforecast2_surface_tilt": 0, - "pvforecast2_trackingtype": 0, - "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": 180, - "pvforecast3_surface_tilt": 0, - "pvforecast3_trackingtype": 0, - "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": 180, - "pvforecast4_surface_tilt": 0, - "pvforecast4_trackingtype": 0, - "pvforecast4_userhorizon": null, - "pvforecast_provider": null, - "pvforecastimport_file_path": null, - "server_eos_startup_eosdash": true, - "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 } diff --git a/src/akkudoktoreos/devices/battery.py b/src/akkudoktoreos/devices/battery.py index e011614..92468f2 100644 --- a/src/akkudoktoreos/devices/battery.py +++ b/src/akkudoktoreos/devices/battery.py @@ -1,11 +1,14 @@ from typing import Any, Optional import numpy as np -from pydantic import BaseModel, Field, field_validator +from pydantic import Field, field_validator from akkudoktoreos.core.logging import get_logger -from akkudoktoreos.core.pydantic import ParametersBaseModel -from akkudoktoreos.devices.devicesabc import DeviceBase +from akkudoktoreos.devices.devicesabc import ( + DeviceBase, + DeviceOptimizeResult, + DeviceParameters, +) from akkudoktoreos.utils.utils import NumpyEncoder logger = get_logger(__name__) @@ -25,9 +28,10 @@ def initial_soc_percentage_field(description: str) -> int: return Field(default=0, ge=0, le=100, description=description) -class BaseBatteryParameters(ParametersBaseModel): +class BaseBatteryParameters(DeviceParameters): """Base class for battery parameters with fields for capacity, efficiency, and state of charge.""" + device_id: str = Field(description="ID of battery") capacity_wh: int = Field( gt=0, description="An integer representing the capacity of the battery in watt-hours." ) @@ -68,15 +72,17 @@ class SolarPanelBatteryParameters(BaseBatteryParameters): class ElectricVehicleParameters(BaseBatteryParameters): """Parameters specific to an electric vehicle (EV).""" + device_id: str = Field(description="ID of electric vehicle") discharging_efficiency: float = 1.0 initial_soc_percentage: int = initial_soc_percentage_field( "An integer representing the current state of charge (SOC) of the battery in percentage." ) -class ElectricVehicleResult(BaseModel): +class ElectricVehicleResult(DeviceOptimizeResult): """Result class containing information related to the electric vehicle's charging and discharging behavior.""" + device_id: str = Field(description="ID of electric vehicle") charge_array: list[float] = Field( description="Hourly charging status (0 for no charging, 1 for charging)." ) @@ -84,7 +90,6 @@ class ElectricVehicleResult(BaseModel): description="Hourly discharging status (0 for no discharging, 1 for discharging)." ) discharging_efficiency: float = Field(description="The discharge efficiency as a float..") - hours: int = Field(description="Number of hours in the simulation.") capacity_wh: int = Field(description="Capacity of the EV’s battery in watt-hours.") charging_efficiency: float = Field(description="Charging efficiency as a float..") max_charge_power_w: int = Field(description="Maximum charging power in watts.") @@ -103,81 +108,30 @@ class ElectricVehicleResult(BaseModel): class Battery(DeviceBase): """Represents a battery device with methods to simulate energy charging and discharging.""" - def __init__( - self, - parameters: Optional[BaseBatteryParameters] = None, - hours: Optional[int] = 24, - provider_id: Optional[str] = None, - ): - # Initialize configuration and parameters - self.provider_id = provider_id - self.prefix = "" - if self.provider_id == "GenericBattery": - self.prefix = "battery" - elif self.provider_id == "GenericBEV": - self.prefix = "bev" + def __init__(self, parameters: Optional[BaseBatteryParameters] = None): + self.parameters: Optional[BaseBatteryParameters] = None + super().__init__(parameters) - self.parameters = parameters - if hours is None: - self.hours = self.total_hours # TODO where does that come from? - else: - self.hours = hours - - self.initialised = False - - # Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised. - if self.parameters is not None: - self.setup() - - def setup(self) -> None: + def _setup(self) -> None: """Sets up the battery parameters based on configuration or provided parameters.""" - if self.initialised: - return + assert self.parameters is not None + self.capacity_wh = self.parameters.capacity_wh + self.initial_soc_percentage = self.parameters.initial_soc_percentage + self.charging_efficiency = self.parameters.charging_efficiency + self.discharging_efficiency = self.parameters.discharging_efficiency - if self.provider_id: - # Setup from configuration - self.capacity_wh = getattr(self.config, f"{self.prefix}_capacity") - self.initial_soc_percentage = getattr(self.config, f"{self.prefix}_initial_soc") - self.hours = self.total_hours # TODO where does that come from? - self.charging_efficiency = getattr(self.config, f"{self.prefix}_charging_efficiency") - self.discharging_efficiency = getattr( - self.config, f"{self.prefix}_discharging_efficiency" - ) - self.max_charge_power_w = getattr(self.config, f"{self.prefix}_max_charging_power") - - if self.provider_id == "GenericBattery": - self.min_soc_percentage = getattr( - self.config, - f"{self.prefix}_soc_min", - ) - else: - self.min_soc_percentage = 0 - - self.max_soc_percentage = getattr( - self.config, - f"{self.prefix}_soc_max", - ) - elif self.parameters: - # Setup from parameters - self.capacity_wh = self.parameters.capacity_wh - self.initial_soc_percentage = self.parameters.initial_soc_percentage - self.charging_efficiency = self.parameters.charging_efficiency - self.discharging_efficiency = self.parameters.discharging_efficiency - self.max_charge_power_w = self.parameters.max_charge_power_w - # Only assign for storage battery - self.min_soc_percentage = ( - self.parameters.min_soc_percentage - if isinstance(self.parameters, SolarPanelBatteryParameters) - else 0 - ) - self.max_soc_percentage = self.parameters.max_soc_percentage - else: - error_msg = "Parameters and provider ID are missing. Cannot instantiate." - logger.error(error_msg) - raise ValueError(error_msg) + # Only assign for storage battery + self.min_soc_percentage = ( + self.parameters.min_soc_percentage + if isinstance(self.parameters, SolarPanelBatteryParameters) + else 0 + ) + self.max_soc_percentage = self.parameters.max_soc_percentage # Initialize state of charge - if self.max_charge_power_w is None: + if self.parameters.max_charge_power_w is not None: + self.max_charge_power_w = self.parameters.max_charge_power_w + else: self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh self.discharge_array = np.full(self.hours, 1) self.charge_array = np.full(self.hours, 1) @@ -185,11 +139,10 @@ class Battery(DeviceBase): self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh - self.initialised = True - def to_dict(self) -> dict[str, Any]: """Converts the object to a dictionary representation.""" return { + "device_id": self.device_id, "capacity_wh": self.capacity_wh, "initial_soc_percentage": self.initial_soc_percentage, "soc_wh": self.soc_wh, diff --git a/src/akkudoktoreos/devices/devices.py b/src/akkudoktoreos/devices/devices.py index 19b4f7b..4ade2a7 100644 --- a/src/akkudoktoreos/devices/devices.py +++ b/src/akkudoktoreos/devices/devices.py @@ -1,307 +1,189 @@ -from typing import Any, ClassVar, Dict, Optional, Union +from typing import Optional -import numpy as np -from numpydantic import NDArray, Shape -from pydantic import Field, computed_field - -from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.coreabc import SingletonMixin from akkudoktoreos.core.logging import get_logger from akkudoktoreos.devices.battery import Battery from akkudoktoreos.devices.devicesabc import DevicesBase from akkudoktoreos.devices.generic import HomeAppliance from akkudoktoreos.devices.inverter import Inverter -from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator -from akkudoktoreos.utils.datetimeutil import to_duration +from akkudoktoreos.devices.settings import DevicesCommonSettings logger = get_logger(__name__) -class DevicesCommonSettings(SettingsBaseModel): - """Base configuration for devices simulation settings.""" - - # Battery - # ------- - battery_provider: Optional[str] = Field( - default=None, description="Id of Battery simulation provider." - ) - battery_capacity: Optional[int] = Field(default=None, description="Battery capacity [Wh].") - battery_initial_soc: Optional[int] = Field( - default=None, description="Battery initial state of charge [%]." - ) - battery_soc_min: Optional[int] = Field( - default=None, description="Battery minimum state of charge [%]." - ) - battery_soc_max: Optional[int] = Field( - default=None, description="Battery maximum state of charge [%]." - ) - battery_charging_efficiency: Optional[float] = Field( - default=None, description="Battery charging efficiency [%]." - ) - battery_discharging_efficiency: Optional[float] = Field( - default=None, description="Battery discharging efficiency [%]." - ) - battery_max_charging_power: Optional[int] = Field( - default=None, description="Battery maximum charge power [W]." - ) - - # Battery Electric Vehicle - # ------------------------ - bev_provider: Optional[str] = Field( - default=None, description="Id of Battery Electric Vehicle simulation provider." - ) - bev_capacity: Optional[int] = Field( - default=None, description="Battery Electric Vehicle capacity [Wh]." - ) - bev_initial_soc: Optional[int] = Field( - default=None, description="Battery Electric Vehicle initial state of charge [%]." - ) - bev_soc_max: Optional[int] = Field( - default=None, description="Battery Electric Vehicle maximum state of charge [%]." - ) - bev_charging_efficiency: Optional[float] = Field( - default=None, description="Battery Electric Vehicle charging efficiency [%]." - ) - bev_discharging_efficiency: Optional[float] = Field( - default=None, description="Battery Electric Vehicle discharging efficiency [%]." - ) - bev_max_charging_power: Optional[int] = Field( - default=None, description="Battery Electric Vehicle maximum charge power [W]." - ) - - # Home Appliance - Dish Washer - # ---------------------------- - dishwasher_provider: Optional[str] = Field( - default=None, description="Id of Dish Washer simulation provider." - ) - dishwasher_consumption: Optional[int] = Field( - default=None, description="Dish Washer energy consumption [Wh]." - ) - dishwasher_duration: Optional[int] = Field( - default=None, description="Dish Washer usage duration [h]." - ) - - # PV Inverter - # ----------- - inverter_provider: Optional[str] = Field( - default=None, description="Id of PV Inverter simulation provider." - ) - inverter_power_max: Optional[float] = Field( - default=None, description="Inverter maximum power [W]." - ) - - class Devices(SingletonMixin, DevicesBase): - # Results of the devices simulation and - # insights into various parameters over the entire forecast period. - # ----------------------------------------------------------------- - last_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( - default=None, description="The load in watt-hours per hour." - ) - eauto_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( - default=None, description="The state of charge of the EV for each hour." - ) - einnahmen_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( - default=None, - description="The revenue from grid feed-in or other sources in euros per hour.", - ) - home_appliance_wh_per_hour: Optional[NDArray[Shape["*"], float]] = Field( - default=None, - description="The energy consumption of a household appliance in watt-hours per hour.", - ) - kosten_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( - default=None, description="The costs in euros per hour." - ) - grid_import_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( - default=None, description="The grid energy drawn in watt-hours per hour." - ) - grid_export_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( - default=None, description="The energy fed into the grid in watt-hours per hour." - ) - verluste_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( - default=None, description="The losses in watt-hours per hour." - ) - akku_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( - default=None, - description="The state of charge of the battery (not the EV) in percentage per hour.", - ) + def __init__(self, settings: Optional[DevicesCommonSettings] = None): + if hasattr(self, "_initialized"): + return + super().__init__() + if settings is None: + settings = self.config.devices + if settings is None: + return - # Computed fields - @computed_field # type: ignore[prop-decorator] - @property - def total_balance_euro(self) -> float: - """The total balance of revenues minus costs in euros.""" - return self.total_revenues_euro - self.total_costs_euro + # initialize devices + if settings.batteries is not None: + for battery_params in settings.batteries: + self.add_device(Battery(battery_params)) + if settings.inverters is not None: + for inverter_params in settings.inverters: + self.add_device(Inverter(inverter_params)) + if settings.home_appliances is not None: + for home_appliance_params in settings.home_appliances: + self.add_device(HomeAppliance(home_appliance_params)) - @computed_field # type: ignore[prop-decorator] - @property - def total_revenues_euro(self) -> float: - """The total revenues in euros.""" - if self.einnahmen_euro_pro_stunde is None: - return 0 - return np.nansum(self.einnahmen_euro_pro_stunde) + self.post_setup() - @computed_field # type: ignore[prop-decorator] - @property - def total_costs_euro(self) -> float: - """The total costs in euros.""" - if self.kosten_euro_pro_stunde is None: - return 0 - return np.nansum(self.kosten_euro_pro_stunde) + def post_setup(self) -> None: + for device in self.devices.values(): + device.post_setup() - @computed_field # type: ignore[prop-decorator] - @property - def total_losses_wh(self) -> float: - """The total losses in watt-hours over the entire period.""" - if self.verluste_wh_pro_stunde is None: - return 0 - return np.nansum(self.verluste_wh_pro_stunde) - # Devices - # TODO: Make devices class a container of device simulation providers. - # Device simulations to be used are then enabled in the configuration. - battery: ClassVar[Battery] = Battery(provider_id="GenericBattery") - ev: ClassVar[Battery] = Battery(provider_id="GenericBEV") - home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher") - inverter: ClassVar[Inverter] = Inverter( - self_consumption_predictor=SelfConsumptionProbabilityInterpolator, - battery=battery, - provider_id="GenericInverter", - ) - - def update_data(self) -> None: - """Update device simulation data.""" - # Assure devices are set up - self.battery.setup() - self.ev.setup() - self.home_appliance.setup() - self.inverter.setup() - - # Pre-allocate arrays for the results, optimized for speed - self.last_wh_pro_stunde = np.full((self.total_hours), np.nan) - self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan) - self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan) - self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan) - self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan) - self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan) - self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan) - self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan) - self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan) - - # Set initial state - simulation_step = to_duration("1 hour") - if self.battery: - self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage() - if self.ev: - self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage() - - # Get predictions for full device simulation time range - # gesamtlast[stunde] - load_total_mean = self.prediction.key_to_array( - "load_total_mean", - start_datetime=self.start_datetime, - end_datetime=self.end_datetime, - interval=simulation_step, - ) - # pv_prognose_wh[stunde] - pvforecast_ac_power = self.prediction.key_to_array( - "pvforecast_ac_power", - start_datetime=self.start_datetime, - end_datetime=self.end_datetime, - interval=simulation_step, - ) - # strompreis_euro_pro_wh[stunde] - elecprice_marketprice_wh = self.prediction.key_to_array( - "elecprice_marketprice_wh", - start_datetime=self.start_datetime, - end_datetime=self.end_datetime, - interval=simulation_step, - ) - # einspeiseverguetung_euro_pro_wh_arr[stunde] - # TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr - einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078) - - for stunde_since_now in range(0, self.total_hours): - hour = self.start_datetime.hour + stunde_since_now - - # Accumulate loads and PV generation - consumption = load_total_mean[stunde_since_now] - self.verluste_wh_pro_stunde[stunde_since_now] = 0.0 - - # Home appliances - if self.home_appliance: - ha_load = self.home_appliance.get_load_for_hour(hour) - consumption += ha_load - self.home_appliance_wh_per_hour[stunde_since_now] = ha_load - - # E-Auto handling - if self.ev: - if self.ev_charge_hours[hour] > 0: - geladene_menge_eauto, verluste_eauto = self.ev.charge_energy( - None, hour, relative_power=self.ev_charge_hours[hour] - ) - consumption += geladene_menge_eauto - self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto - self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage() - - # Process inverter logic - grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0) - if self.battery: - self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour) - if self.inverter: - generation = pvforecast_ac_power[hour] - grid_export, grid_import, losses, self_consumption = self.inverter.process_energy( - generation, consumption, hour - ) - - # AC PV Battery Charge - if self.battery and self.ac_charge_hours[hour] > 0.0: - self.battery.set_charge_allowed_for_hour(1, hour) - geladene_menge, verluste_wh = self.battery.charge_energy( - None, hour, relative_power=self.ac_charge_hours[hour] - ) - # print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage()) - consumption += geladene_menge - grid_import += geladene_menge - self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh - - self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export - self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import - self.verluste_wh_pro_stunde[stunde_since_now] += losses - self.last_wh_pro_stunde[stunde_since_now] = consumption - - # Financial calculations - self.kosten_euro_pro_stunde[stunde_since_now] = ( - grid_import * self.strompreis_euro_pro_wh[hour] - ) - self.einnahmen_euro_pro_stunde[stunde_since_now] = ( - grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour] - ) - - # battery SOC tracking - if self.battery: - self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage() - else: - self.akku_soc_pro_stunde[stunde_since_now] = 0.0 - - def report_dict(self) -> Dict[str, Any]: - """Provides devices simulation output as a dictionary.""" - out: Dict[str, Optional[Union[np.ndarray, float]]] = { - "Last_Wh_pro_Stunde": self.last_wh_pro_stunde, - "grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde, - "grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde, - "Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde, - "akku_soc_pro_stunde": self.akku_soc_pro_stunde, - "Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde, - "Gesamtbilanz_Euro": self.total_balance_euro, - "EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde, - "Gesamteinnahmen_Euro": self.total_revenues_euro, - "Gesamtkosten_Euro": self.total_costs_euro, - "Verluste_Pro_Stunde": self.verluste_wh_pro_stunde, - "Gesamt_Verluste": self.total_losses_wh, - "Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour, - } - return out +# # Devices +# # TODO: Make devices class a container of device simulation providers. +# # Device simulations to be used are then enabled in the configuration. +# battery: ClassVar[Battery] = Battery(provider_id="GenericBattery") +# ev: ClassVar[Battery] = Battery(provider_id="GenericBEV") +# home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher") +# inverter: ClassVar[Inverter] = Inverter( +# self_consumption_predictor=SelfConsumptionProbabilityInterpolator, +# battery=battery, +# provider_id="GenericInverter", +# ) +# +# def update_data(self) -> None: +# """Update device simulation data.""" +# # Assure devices are set up +# self.battery.setup() +# self.ev.setup() +# self.home_appliance.setup() +# self.inverter.setup() +# +# # Pre-allocate arrays for the results, optimized for speed +# self.last_wh_pro_stunde = np.full((self.total_hours), np.nan) +# self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan) +# self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan) +# self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan) +# self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan) +# self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan) +# self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan) +# self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan) +# self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan) +# +# # Set initial state +# simulation_step = to_duration("1 hour") +# if self.battery: +# self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage() +# if self.ev: +# self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage() +# +# # Get predictions for full device simulation time range +# # gesamtlast[stunde] +# load_total_mean = self.prediction.key_to_array( +# "load_total_mean", +# start_datetime=self.start_datetime, +# end_datetime=self.end_datetime, +# interval=simulation_step, +# ) +# # pv_prognose_wh[stunde] +# pvforecast_ac_power = self.prediction.key_to_array( +# "pvforecast_ac_power", +# start_datetime=self.start_datetime, +# end_datetime=self.end_datetime, +# interval=simulation_step, +# ) +# # strompreis_euro_pro_wh[stunde] +# elecprice_marketprice_wh = self.prediction.key_to_array( +# "elecprice_marketprice_wh", +# start_datetime=self.start_datetime, +# end_datetime=self.end_datetime, +# interval=simulation_step, +# ) +# # einspeiseverguetung_euro_pro_wh_arr[stunde] +# # TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr +# einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078) +# +# for stunde_since_now in range(0, self.total_hours): +# hour = self.start_datetime.hour + stunde_since_now +# +# # Accumulate loads and PV generation +# consumption = load_total_mean[stunde_since_now] +# self.verluste_wh_pro_stunde[stunde_since_now] = 0.0 +# +# # Home appliances +# if self.home_appliance: +# ha_load = self.home_appliance.get_load_for_hour(hour) +# consumption += ha_load +# self.home_appliance_wh_per_hour[stunde_since_now] = ha_load +# +# # E-Auto handling +# if self.ev: +# if self.ev_charge_hours[hour] > 0: +# geladene_menge_eauto, verluste_eauto = self.ev.charge_energy( +# None, hour, relative_power=self.ev_charge_hours[hour] +# ) +# consumption += geladene_menge_eauto +# self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto +# self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage() +# +# # Process inverter logic +# grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0) +# if self.battery: +# self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour) +# if self.inverter: +# generation = pvforecast_ac_power[hour] +# grid_export, grid_import, losses, self_consumption = self.inverter.process_energy( +# generation, consumption, hour +# ) +# +# # AC PV Battery Charge +# if self.battery and self.ac_charge_hours[hour] > 0.0: +# self.battery.set_charge_allowed_for_hour(1, hour) +# geladene_menge, verluste_wh = self.battery.charge_energy( +# None, hour, relative_power=self.ac_charge_hours[hour] +# ) +# # print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage()) +# consumption += geladene_menge +# grid_import += geladene_menge +# self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh +# +# self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export +# self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import +# self.verluste_wh_pro_stunde[stunde_since_now] += losses +# self.last_wh_pro_stunde[stunde_since_now] = consumption +# +# # Financial calculations +# self.kosten_euro_pro_stunde[stunde_since_now] = ( +# grid_import * self.strompreis_euro_pro_wh[hour] +# ) +# self.einnahmen_euro_pro_stunde[stunde_since_now] = ( +# grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour] +# ) +# +# # battery SOC tracking +# if self.battery: +# self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage() +# else: +# self.akku_soc_pro_stunde[stunde_since_now] = 0.0 +# +# def report_dict(self) -> Dict[str, Any]: +# """Provides devices simulation output as a dictionary.""" +# out: Dict[str, Optional[Union[np.ndarray, float]]] = { +# "Last_Wh_pro_Stunde": self.last_wh_pro_stunde, +# "grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde, +# "grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde, +# "Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde, +# "akku_soc_pro_stunde": self.akku_soc_pro_stunde, +# "Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde, +# "Gesamtbilanz_Euro": self.total_balance_euro, +# "EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde, +# "Gesamteinnahmen_Euro": self.total_revenues_euro, +# "Gesamtkosten_Euro": self.total_costs_euro, +# "Verluste_Pro_Stunde": self.verluste_wh_pro_stunde, +# "Gesamt_Verluste": self.total_losses_wh, +# "Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour, +# } +# return out # Initialize the Devices simulation, it is a singleton. diff --git a/src/akkudoktoreos/devices/devicesabc.py b/src/akkudoktoreos/devices/devicesabc.py index 0e64783..09e3b22 100644 --- a/src/akkudoktoreos/devices/devicesabc.py +++ b/src/akkudoktoreos/devices/devicesabc.py @@ -1,22 +1,46 @@ """Abstract and base classes for devices.""" -from typing import Optional +from enum import Enum +from typing import Optional, Type from pendulum import DateTime -from pydantic import ConfigDict, computed_field +from pydantic import Field, computed_field from akkudoktoreos.core.coreabc import ( ConfigMixin, + DevicesMixin, EnergyManagementSystemMixin, PredictionMixin, ) from akkudoktoreos.core.logging import get_logger -from akkudoktoreos.core.pydantic import PydanticBaseModel +from akkudoktoreos.core.pydantic import ParametersBaseModel from akkudoktoreos.utils.datetimeutil import to_duration logger = get_logger(__name__) +# class DeviceParameters(PydanticBaseModel): +class DeviceParameters(ParametersBaseModel): + device_id: str = Field(description="ID of device") + hours: Optional[int] = Field( + default=None, + gt=0, + description="Number of prediction hours. Defaults to global config prediction hours.", + ) + + +# class DeviceOptimizeResult(PydanticBaseModel): +class DeviceOptimizeResult(ParametersBaseModel): + device_id: str = Field(description="ID of device") + hours: int = Field(gt=0, description="Number of hours in the simulation.") + + +class DeviceState(Enum): + UNINITIALIZED = 0 + PREPARED = 1 + INITIALIZED = 2 + + class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin): """A mixin to manage start, end datetimes for devices data. @@ -35,9 +59,9 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin): Returns: Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing. """ - if self.ems.start_datetime and self.config.prediction_hours: + if self.ems.start_datetime and self.config.prediction.prediction_hours: end_datetime = self.ems.start_datetime + to_duration( - f"{self.config.prediction_hours} hours" + f"{self.config.prediction.prediction_hours} hours" ) dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours logger.debug( @@ -68,33 +92,92 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin): return int(duration.total_hours()) -class DeviceBase(DevicesStartEndMixin, PredictionMixin): +class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin): """Base class for device simulations. - Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute - `prediction`). + Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute + `prediction`) and EOS device registry (attribute `devices`). - Note: - Validation on assignment of the Pydantic model is disabled to speed up simulation runs. + Behavior: + - Several initialization phases (setup, post_setup): + - setup: Initialize class attributes from DeviceParameters (pydantic input validation) + - post_setup: Set connections between devices + - NotImplemented: + - hooks during optimization + + Notes: + - This class is base to concrete devices like battery, inverter, etc. that are used in optimization. + - Not a pydantic model for a low footprint during optimization. """ - # Disable validation on assignment to speed up simulation runs. - model_config = ConfigDict( - validate_assignment=False, - ) + def __init__(self, parameters: Optional[DeviceParameters] = None): + self.device_id: str = "" + self.parameters: Optional[DeviceParameters] = None + self.hours = -1 + if self.total_hours is not None: + self.hours = self.total_hours + + self.initialized = DeviceState.UNINITIALIZED + + if parameters is not None: + self.setup(parameters) + + def setup(self, parameters: DeviceParameters) -> None: + if self.initialized != DeviceState.UNINITIALIZED: + return + + self.parameters = parameters + self.device_id = self.parameters.device_id + + if self.parameters.hours is not None: + self.hours = self.parameters.hours + if self.hours < 0: + raise ValueError("hours is unset") + + self._setup() + + self.initialized = DeviceState.PREPARED + + def post_setup(self) -> None: + if self.initialized.value >= DeviceState.INITIALIZED.value: + return + + self._post_setup() + self.initialized = DeviceState.INITIALIZED + + def _setup(self) -> None: + """Implement custom setup in derived device classes.""" + pass + + def _post_setup(self) -> None: + """Implement custom setup in derived device classes that is run when all devices are initialized.""" + pass -class DevicesBase(DevicesStartEndMixin, PredictionMixin, PydanticBaseModel): +class DevicesBase(DevicesStartEndMixin, PredictionMixin): """Base class for handling device data. Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute `prediction`). - - Note: - Validation on assignment of the Pydantic model is disabled to speed up simulation runs. """ - # Disable validation on assignment to speed up simulation runs. - model_config = ConfigDict( - validate_assignment=False, - ) + def __init__(self) -> None: + super().__init__() + self.devices: dict[str, "DeviceBase"] = dict() + + def get_device_by_id(self, device_id: str) -> Optional["DeviceBase"]: + return self.devices.get(device_id) + + def add_device(self, device: Optional["DeviceBase"]) -> None: + if device is None: + return + assert device.device_id not in self.devices, f"{device.device_id} already registered" + self.devices[device.device_id] = device + + def remove_device(self, device: Type["DeviceBase"] | str) -> bool: + if isinstance(device, DeviceBase): + device = device.device_id + return self.devices.pop(device, None) is not None # type: ignore[arg-type] + + def reset(self) -> None: + self.devices = dict() diff --git a/src/akkudoktoreos/devices/generic.py b/src/akkudoktoreos/devices/generic.py index 1cd890f..526517c 100644 --- a/src/akkudoktoreos/devices/generic.py +++ b/src/akkudoktoreos/devices/generic.py @@ -4,13 +4,13 @@ import numpy as np from pydantic import Field from akkudoktoreos.core.logging import get_logger -from akkudoktoreos.core.pydantic import ParametersBaseModel -from akkudoktoreos.devices.devicesabc import DeviceBase +from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters logger = get_logger(__name__) -class HomeApplianceParameters(ParametersBaseModel): +class HomeApplianceParameters(DeviceParameters): + device_id: str = Field(description="ID of home appliance") consumption_wh: int = Field( gt=0, description="An integer representing the energy consumption of a household device in watt-hours.", @@ -25,46 +25,15 @@ class HomeAppliance(DeviceBase): def __init__( self, parameters: Optional[HomeApplianceParameters] = None, - hours: Optional[int] = 24, - provider_id: Optional[str] = None, ): - # Configuration initialisation - self.provider_id = provider_id - self.prefix = "" - if self.provider_id == "GenericDishWasher": - self.prefix = "dishwasher" - # Parameter initialisiation - self.parameters = parameters - if hours is None: - self.hours = self.total_hours - else: - self.hours = hours + self.parameters: Optional[HomeApplianceParameters] = None + super().__init__(parameters) - self.initialised = False - # Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised. - if self.parameters is not None: - self.setup() - - def setup(self) -> None: - if self.initialised: - return - if self.provider_id is not None: - # Setup by configuration - self.hours = self.total_hours - self.consumption_wh = getattr(self.config, f"{self.prefix}_consumption") - self.duration_h = getattr(self.config, f"{self.prefix}_duration") - elif self.parameters is not None: - # Setup by parameters - self.consumption_wh = ( - self.parameters.consumption_wh - ) # Total energy consumption of the device in kWh - self.duration_h = self.parameters.duration_h # Duration of use in hours - else: - error_msg = "Parameters and provider ID missing. Can't instantiate." - logger.error(error_msg) - raise ValueError(error_msg) + def _setup(self) -> None: + assert self.parameters is not None self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros - self.initialised = True + self.duration_h = self.parameters.duration_h + self.consumption_wh = self.parameters.consumption_wh def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> None: """Sets the start time of the device and generates the corresponding load curve. diff --git a/src/akkudoktoreos/devices/inverter.py b/src/akkudoktoreos/devices/inverter.py index 922df62..0814382 100644 --- a/src/akkudoktoreos/devices/inverter.py +++ b/src/akkudoktoreos/devices/inverter.py @@ -1,64 +1,44 @@ from typing import Optional from pydantic import Field -from scipy.interpolate import RegularGridInterpolator from akkudoktoreos.core.logging import get_logger -from akkudoktoreos.core.pydantic import ParametersBaseModel -from akkudoktoreos.devices.battery import Battery -from akkudoktoreos.devices.devicesabc import DeviceBase +from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters +from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator logger = get_logger(__name__) -class InverterParameters(ParametersBaseModel): +class InverterParameters(DeviceParameters): + device_id: str = Field(description="ID of inverter") max_power_wh: float = Field(gt=0) + battery: Optional[str] = Field(default=None, description="ID of battery") class Inverter(DeviceBase): def __init__( self, - self_consumption_predictor: RegularGridInterpolator, parameters: Optional[InverterParameters] = None, - battery: Optional[Battery] = None, - provider_id: Optional[str] = None, ): - # Configuration initialisation - self.provider_id = provider_id - self.prefix = "" - if self.provider_id == "GenericInverter": - self.prefix = "inverter" - # Parameter initialisiation - self.parameters = parameters - if battery is None: + self.parameters: Optional[InverterParameters] = None + super().__init__(parameters) + + def _setup(self) -> None: + assert self.parameters is not None + if self.parameters.battery is None: # For the moment raise exception # TODO: Make battery configurable by config error_msg = "Battery for PV inverter is mandatory." logger.error(error_msg) raise NotImplementedError(error_msg) - self.battery = battery # Connection to a battery object - self.self_consumption_predictor = self_consumption_predictor + self.self_consumption_predictor = get_eos_load_interpolator() + self.max_power_wh = ( + self.parameters.max_power_wh + ) # Maximum power that the inverter can handle - self.initialised = False - # Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised. - if self.parameters is not None: - self.setup() - - def setup(self) -> None: - if self.initialised: - return - if self.provider_id is not None: - # Setup by configuration - self.max_power_wh = getattr(self.config, f"{self.prefix}_power_max") - elif self.parameters is not None: - # Setup by parameters - self.max_power_wh = ( - self.parameters.max_power_wh # Maximum power that the inverter can handle - ) - else: - error_msg = "Parameters and provider ID missing. Can't instantiate." - logger.error(error_msg) - raise ValueError(error_msg) + def _post_setup(self) -> None: + assert self.parameters is not None + self.battery = self.devices.get_device_by_id(self.parameters.battery) def process_energy( self, generation: float, consumption: float, hour: int diff --git a/src/akkudoktoreos/devices/settings.py b/src/akkudoktoreos/devices/settings.py new file mode 100644 index 0000000..0dfc658 --- /dev/null +++ b/src/akkudoktoreos/devices/settings.py @@ -0,0 +1,25 @@ +from typing import Optional + +from pydantic import Field + +from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.devices.battery import BaseBatteryParameters +from akkudoktoreos.devices.generic import HomeApplianceParameters +from akkudoktoreos.devices.inverter import InverterParameters + +logger = get_logger(__name__) + + +class DevicesCommonSettings(SettingsBaseModel): + """Base configuration for devices simulation settings.""" + + batteries: Optional[list[BaseBatteryParameters]] = Field( + default=None, description="List of battery/ev devices" + ) + inverters: Optional[list[InverterParameters]] = Field( + default=None, description="List of inverters" + ) + home_appliances: Optional[list[HomeApplianceParameters]] = Field( + default=None, description="List of home appliances" + ) diff --git a/src/akkudoktoreos/measurement/measurement.py b/src/akkudoktoreos/measurement/measurement.py index 9a70f73..e183ef8 100644 --- a/src/akkudoktoreos/measurement/measurement.py +++ b/src/akkudoktoreos/measurement/measurement.py @@ -106,6 +106,11 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence): "measurement_load", ] + def __init__(self, *args: Any, **kwargs: Any) -> None: + if hasattr(self, "_initialized"): + return + super().__init__(*args, **kwargs) + def _interval_count( self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration ) -> int: @@ -143,11 +148,16 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence): if topic not in self.topics: return None - topic_keys = [key for key in self.config.config_keys if key.startswith(topic)] + topic_keys = [ + key for key in self.config.measurement.model_fields.keys() if key.startswith(topic) + ] key = None if topic == "measurement_load": for config_key in topic_keys: - if config_key.endswith("_name") and getattr(self.config, config_key) == name: + if ( + config_key.endswith("_name") + and getattr(self.config.measurement, config_key) == name + ): key = topic + config_key[len(topic) : len(topic) + 1] + "_mr" break diff --git a/src/akkudoktoreos/optimization/genetic.py b/src/akkudoktoreos/optimization/genetic.py index aedf828..dbc6de5 100644 --- a/src/akkudoktoreos/optimization/genetic.py +++ b/src/akkudoktoreos/optimization/genetic.py @@ -1,7 +1,6 @@ import logging import random import time -from pathlib import Path from typing import Any, Optional import numpy as np @@ -25,7 +24,6 @@ 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 from akkudoktoreos.utils.utils import NumpyEncoder logger = get_logger(__name__) @@ -112,8 +110,12 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi ): """Initialize the optimization problem with the required parameters.""" self.opti_param: dict[str, Any] = {} - self.fixed_eauto_hours = self.config.prediction_hours - self.config.optimization_hours - self.possible_charge_values = self.config.optimization_ev_available_charge_rates_percent + self.fixed_eauto_hours = ( + self.config.prediction.prediction_hours - self.config.optimization.optimization_hours + ) + self.possible_charge_values = ( + self.config.optimization.optimization_ev_available_charge_rates_percent + ) self.verbose = verbose self.fix_seed = fixed_seed self.optimize_ev = True @@ -180,25 +182,27 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi total_states = 3 * len_ac # 1. Mutating the charge_discharge part - charge_discharge_part = individual[: self.config.prediction_hours] + charge_discharge_part = individual[: self.config.prediction.prediction_hours] (charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part) # Instead of a fixed clamping to 0..8 or 0..6 dynamically: charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1) - individual[: self.config.prediction_hours] = charge_discharge_mutated + individual[: self.config.prediction.prediction_hours] = charge_discharge_mutated # 2. Mutating the EV charge part, if active if self.optimize_ev: ev_charge_part = individual[ - self.config.prediction_hours : self.config.prediction_hours * 2 + self.config.prediction.prediction_hours : self.config.prediction.prediction_hours + * 2 ] (ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part) - ev_charge_part_mutated[self.config.prediction_hours - self.fixed_eauto_hours :] = [ - 0 - ] * self.fixed_eauto_hours - individual[self.config.prediction_hours : self.config.prediction_hours * 2] = ( - ev_charge_part_mutated - ) + ev_charge_part_mutated[ + self.config.prediction.prediction_hours - self.fixed_eauto_hours : + ] = [0] * self.fixed_eauto_hours + individual[ + self.config.prediction.prediction_hours : self.config.prediction.prediction_hours + * 2 + ] = ev_charge_part_mutated # 3. Mutating the appliance start time, if applicable if self.opti_param["home_appliance"] > 0: @@ -212,13 +216,15 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi def create_individual(self) -> list[int]: # Start with discharge states for the individual individual_components = [ - self.toolbox.attr_discharge_state() for _ in range(self.config.prediction_hours) + self.toolbox.attr_discharge_state() + for _ in range(self.config.prediction.prediction_hours) ] # Add EV charge index values if optimize_ev is True if self.optimize_ev: individual_components += [ - self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction_hours) + self.toolbox.attr_ev_charge_index() + for _ in range(self.config.prediction.prediction_hours) ] # Add the start time of the household appliance if it's being optimized @@ -251,7 +257,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi individual.extend(eautocharge_hours_index.tolist()) elif self.optimize_ev: # Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu - individual.extend([0] * self.config.prediction_hours) + individual.extend([0] * self.config.prediction.prediction_hours) # Add dishwasher start time if applicable if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None: @@ -273,12 +279,17 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi 3. Dishwasher start time (integer if applicable). """ # Discharge hours as a NumPy array of ints - discharge_hours_bin = np.array(individual[: self.config.prediction_hours], dtype=int) + discharge_hours_bin = np.array( + individual[: self.config.prediction.prediction_hours], dtype=int + ) # EV charge hours as a NumPy array of ints (if optimize_ev is True) eautocharge_hours_index = ( np.array( - individual[self.config.prediction_hours : self.config.prediction_hours * 2], + individual[ + self.config.prediction.prediction_hours : self.config.prediction.prediction_hours + * 2 + ], dtype=int, ) if self.optimize_ev @@ -390,7 +401,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi ) self.ems.set_ev_charge_hours(eautocharge_hours_float) else: - self.ems.set_ev_charge_hours(np.full(self.config.prediction_hours, 0)) + self.ems.set_ev_charge_hours(np.full(self.config.prediction.prediction_hours, 0)) return self.ems.simulate(self.ems.start_datetime.hour) @@ -452,7 +463,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi # min_length = min(battery_soc_per_hour.size, discharge_hours_bin.size) # battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:] # discharge_hours_bin_tail = discharge_hours_bin[-min_length:] - # len_ac = len(self.config.optimization_ev_available_charge_rates_percent) + # len_ac = len(self.config.optimization.optimization_ev_available_charge_rates_percent) # # # Find hours where battery SoC is 0 # # zero_soc_mask = battery_soc_per_hour_tail == 0 @@ -501,7 +512,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi if parameters.eauto and self.ems.ev else 0 ) - * self.config.optimization_penalty, + * self.config.optimization.optimization_penalty, ) return (gesamtbilanz,) @@ -569,30 +580,26 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi start_hour = self.ems.start_datetime.hour einspeiseverguetung_euro_pro_wh = np.full( - self.config.prediction_hours, parameters.ems.einspeiseverguetung_euro_pro_wh + self.config.prediction.prediction_hours, parameters.ems.einspeiseverguetung_euro_pro_wh ) - # 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate - sc = SelfConsumptionProbabilityInterpolator( - Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl" - ) + # TODO: Refactor device setup phase out + self.devices.reset() # Initialize PV and EV batteries akku: Optional[Battery] = None if parameters.pv_akku: - akku = Battery( - parameters.pv_akku, - hours=self.config.prediction_hours, - ) - akku.set_charge_per_hour(np.full(self.config.prediction_hours, 1)) + akku = Battery(parameters.pv_akku) + self.devices.add_device(akku) + akku.set_charge_per_hour(np.full(self.config.prediction.prediction_hours, 1)) eauto: Optional[Battery] = None if parameters.eauto: eauto = Battery( parameters.eauto, - hours=self.config.prediction_hours, ) - eauto.set_charge_per_hour(np.full(self.config.prediction_hours, 1)) + self.devices.add_device(eauto) + eauto.set_charge_per_hour(np.full(self.config.prediction.prediction_hours, 1)) self.optimize_ev = ( parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0 ) @@ -603,20 +610,22 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi dishwasher = ( HomeAppliance( parameters=parameters.dishwasher, - hours=self.config.prediction_hours, ) if parameters.dishwasher is not None else None ) + self.devices.add_device(dishwasher) # Initialize the inverter and energy management system inverter: Optional[Inverter] = None if parameters.inverter: inverter = Inverter( - sc, parameters.inverter, - akku, ) + self.devices.add_device(inverter) + + self.devices.post_setup() + self.ems.set_parameters( parameters.ems, inverter=inverter, diff --git a/src/akkudoktoreos/optimization/optimization.py b/src/akkudoktoreos/optimization/optimization.py index d08c077..e78aac1 100644 --- a/src/akkudoktoreos/optimization/optimization.py +++ b/src/akkudoktoreos/optimization/optimization.py @@ -16,7 +16,7 @@ class OptimizationCommonSettings(SettingsBaseModel): """ optimization_hours: Optional[int] = Field( - default=24, ge=0, description="Number of hours into the future for optimizations." + default=48, ge=0, description="Number of hours into the future for optimizations." ) optimization_penalty: Optional[int] = Field( diff --git a/src/akkudoktoreos/prediction/elecprice.py b/src/akkudoktoreos/prediction/elecprice.py index bac466f..fd8079e 100644 --- a/src/akkudoktoreos/prediction/elecprice.py +++ b/src/akkudoktoreos/prediction/elecprice.py @@ -3,6 +3,7 @@ from typing import Optional from pydantic import Field from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings class ElecPriceCommonSettings(SettingsBaseModel): @@ -12,3 +13,5 @@ class ElecPriceCommonSettings(SettingsBaseModel): elecprice_charges_kwh: Optional[float] = Field( default=None, ge=0, description="Electricity price charges (€/kWh)." ) + + provider_settings: Optional[ElecPriceImportCommonSettings] = None diff --git a/src/akkudoktoreos/prediction/elecpriceabc.py b/src/akkudoktoreos/prediction/elecpriceabc.py index df7cbb0..a4b69cd 100644 --- a/src/akkudoktoreos/prediction/elecpriceabc.py +++ b/src/akkudoktoreos/prediction/elecpriceabc.py @@ -71,4 +71,4 @@ class ElecPriceProvider(PredictionProvider): return "ElecPriceProvider" def enabled(self) -> bool: - return self.provider_id() == self.config.elecprice_provider + return self.provider_id() == self.config.elecprice.elecprice_provider diff --git a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py index d9d7f2b..e14c30b 100644 --- a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py +++ b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py @@ -108,13 +108,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider): # Try to take data from 5 weeks back for prediction date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD") last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") - url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}" + url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.prediction.timezone}" response = requests.get(url) logger.debug(f"Response from {url}: {response}") response.raise_for_status() # Raise an error for bad responses akkudoktor_data = self._validate_data(response.content) # We are working on fresh data (no cache), report update time - self.update_datetime = to_datetime(in_timezone=self.config.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) return akkudoktor_data def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray: @@ -156,13 +156,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider): # in ascending order and have the same timestamps. # Get elecprice_charges_kwh in wh - charges_wh = (self.config.elecprice_charges_kwh or 0) / 1000 + charges_wh = (self.config.elecprice.elecprice_charges_kwh or 0) / 1000 highest_orig_datetime = None # newest datetime from the api after that we want to update. series_data = pd.Series(dtype=float) # Initialize an empty series for value in akkudoktor_data.values: - orig_datetime = to_datetime(value.start, in_timezone=self.config.timezone) + orig_datetime = to_datetime(value.start, in_timezone=self.config.prediction.timezone) if highest_orig_datetime is None or orig_datetime > highest_orig_datetime: highest_orig_datetime = orig_datetime @@ -184,14 +184,14 @@ class ElecPriceAkkudoktor(ElecPriceProvider): # some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours needed_prediction_hours = int( - self.config.prediction_hours + self.config.prediction.prediction_hours - ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600) ) if needed_prediction_hours <= 0: logger.warning( - f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}" - ) # this might keep data longer than self.start_datetime + self.config.prediction_hours in the records + f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}" + ) # this might keep data longer than self.start_datetime + self.config.prediction.prediction_hours in the records return if amount_datasets > 800: # we do the full ets with seasons of 1 week diff --git a/src/akkudoktoreos/prediction/elecpriceimport.py b/src/akkudoktoreos/prediction/elecpriceimport.py index 31c8a2f..fd745d4 100644 --- a/src/akkudoktoreos/prediction/elecpriceimport.py +++ b/src/akkudoktoreos/prediction/elecpriceimport.py @@ -62,7 +62,12 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider): return "ElecPriceImport" def _update_data(self, force_update: Optional[bool] = False) -> None: - if self.config.elecpriceimport_file_path is not None: - self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice") - if self.config.elecpriceimport_json is not None: - self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice") + if self.config.elecprice.provider_settings.elecpriceimport_file_path is not None: + self.import_from_file( + self.config.elecprice.provider_settings.elecpriceimport_file_path, + key_prefix="elecprice", + ) + if self.config.elecprice.provider_settings.elecpriceimport_json is not None: + self.import_from_json( + self.config.elecprice.provider_settings.elecpriceimport_json, key_prefix="elecprice" + ) diff --git a/src/akkudoktoreos/prediction/interpolator.py b/src/akkudoktoreos/prediction/interpolator.py index a881e68..6eb2f89 100644 --- a/src/akkudoktoreos/prediction/interpolator.py +++ b/src/akkudoktoreos/prediction/interpolator.py @@ -6,6 +6,8 @@ from pathlib import Path import numpy as np from scipy.interpolate import RegularGridInterpolator +from akkudoktoreos.core.coreabc import SingletonMixin + class SelfConsumptionProbabilityInterpolator: def __init__(self, filepath: str | Path): @@ -67,5 +69,17 @@ class SelfConsumptionProbabilityInterpolator: # return self_consumption_rate -# Test the function -# print(calculate_self_consumption(1000, 1200)) +class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin): + def __init__(self) -> None: + if hasattr(self, "_initialized"): + return + filename = Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl" + super().__init__(filename) + + +# Initialize the Energy Management System, it is a singleton. +eos_load_interpolator = EOSLoadInterpolator() + + +def get_eos_load_interpolator() -> EOSLoadInterpolator: + return eos_load_interpolator diff --git a/src/akkudoktoreos/prediction/load.py b/src/akkudoktoreos/prediction/load.py index 057c5de..fc64f8c 100644 --- a/src/akkudoktoreos/prediction/load.py +++ b/src/akkudoktoreos/prediction/load.py @@ -1,11 +1,13 @@ """Load forecast module for load predictions.""" -from typing import Optional +from typing import Optional, Union from pydantic import Field from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings +from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings logger = get_logger(__name__) @@ -16,3 +18,7 @@ class LoadCommonSettings(SettingsBaseModel): load_provider: Optional[str] = Field( default=None, description="Load provider id of provider to be used." ) + + provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = ( + None + ) diff --git a/src/akkudoktoreos/prediction/loadabc.py b/src/akkudoktoreos/prediction/loadabc.py index b7d5295..34302ba 100644 --- a/src/akkudoktoreos/prediction/loadabc.py +++ b/src/akkudoktoreos/prediction/loadabc.py @@ -58,4 +58,4 @@ class LoadProvider(PredictionProvider): return "LoadProvider" def enabled(self) -> bool: - return self.provider_id() == self.config.load_provider + return self.provider_id() == self.config.load.load_provider diff --git a/src/akkudoktoreos/prediction/loadakkudoktor.py b/src/akkudoktoreos/prediction/loadakkudoktor.py index d0db1d9..c6d57cb 100644 --- a/src/akkudoktoreos/prediction/loadakkudoktor.py +++ b/src/akkudoktoreos/prediction/loadakkudoktor.py @@ -91,7 +91,9 @@ class LoadAkkudoktor(LoadProvider): list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"])) ) # Calculate values in W by relative profile data and yearly consumption given in kWh - data_year_energy = profile_data * self.config.loadakkudoktor_year_energy * 1000 + data_year_energy = ( + profile_data * self.config.load.provider_settings.loadakkudoktor_year_energy * 1000 + ) except FileNotFoundError: error_msg = f"Error: File {load_file} not found." logger.error(error_msg) @@ -109,7 +111,7 @@ class LoadAkkudoktor(LoadProvider): # We provide prediction starting at start of day, to be compatible to old system. # End date for prediction is prediction hours from now. date = self.start_datetime.start_of("day") - end_date = self.start_datetime.add(hours=self.config.prediction_hours) + end_date = self.start_datetime.add(hours=self.config.prediction.prediction_hours) while compare_datetimes(date, end_date).lt: # Extract mean (index 0) and standard deviation (index 1) for the given day and hour # Day indexing starts at 0, -1 because of that @@ -127,4 +129,4 @@ class LoadAkkudoktor(LoadProvider): self.update_value(date, values) date += to_duration("1 hour") # We are working on fresh data (no cache), report update time - self.update_datetime = to_datetime(in_timezone=self.config.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) diff --git a/src/akkudoktoreos/prediction/loadimport.py b/src/akkudoktoreos/prediction/loadimport.py index 39e835b..836a0b8 100644 --- a/src/akkudoktoreos/prediction/loadimport.py +++ b/src/akkudoktoreos/prediction/loadimport.py @@ -58,7 +58,11 @@ class LoadImport(LoadProvider, PredictionImportProvider): return "LoadImport" def _update_data(self, force_update: Optional[bool] = False) -> None: - if self.config.load_import_file_path is not None: - self.import_from_file(self.config.load_import_file_path, key_prefix="load") - if self.config.load_import_json is not None: - self.import_from_json(self.config.load_import_json, key_prefix="load") + if self.config.load.provider_settings.load_import_file_path is not None: + self.import_from_file( + self.config.provider_settings.load_import_file_path, key_prefix="load" + ) + if self.config.load.provider_settings.load_import_json is not None: + self.import_from_json( + self.config.load.provider_settings.load_import_json, key_prefix="load" + ) diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index 2beb4de..37d7f45 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -80,13 +80,13 @@ class PredictionCommonSettings(SettingsBaseModel): description="Number of hours into the past for historical predictions data", ) latitude: Optional[float] = Field( - default=None, + default=52.52, ge=-90.0, le=90.0, description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)", ) longitude: Optional[float] = Field( - default=None, + default=13.405, ge=-180.0, le=180.0, description="Longitude in decimal degrees, within -180 to 180 (°)", diff --git a/src/akkudoktoreos/prediction/predictionabc.py b/src/akkudoktoreos/prediction/predictionabc.py index e0b375d..6c5d72c 100644 --- a/src/akkudoktoreos/prediction/predictionabc.py +++ b/src/akkudoktoreos/prediction/predictionabc.py @@ -121,9 +121,9 @@ class PredictionStartEndKeepMixin(PredictionBase): Returns: Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing. """ - if self.start_datetime and self.config.prediction_hours: + if self.start_datetime and self.config.prediction.prediction_hours: end_datetime = self.start_datetime + to_duration( - f"{self.config.prediction_hours} hours" + f"{self.config.prediction.prediction_hours} hours" ) dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}") @@ -147,10 +147,10 @@ class PredictionStartEndKeepMixin(PredictionBase): return None historic_hours = self.historic_hours_min() if ( - self.config.prediction_historic_hours - and self.config.prediction_historic_hours > historic_hours + self.config.prediction.prediction_historic_hours + and self.config.prediction.prediction_historic_hours > historic_hours ): - historic_hours = int(self.config.prediction_historic_hours) + historic_hours = int(self.config.prediction.prediction_historic_hours) return self.start_datetime - to_duration(f"{historic_hours} hours") @computed_field # type: ignore[prop-decorator] diff --git a/src/akkudoktoreos/prediction/pvforecast.py b/src/akkudoktoreos/prediction/pvforecast.py index cf261dd..cc676c2 100644 --- a/src/akkudoktoreos/prediction/pvforecast.py +++ b/src/akkudoktoreos/prediction/pvforecast.py @@ -6,6 +6,7 @@ from pydantic import Field, computed_field from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings logger = get_logger(__name__) @@ -260,7 +261,7 @@ class PVForecastCommonSettings(SettingsBaseModel): default=None, description="Nominal power of PV system in kW." ) pvforecast4_pvtechchoice: Optional[str] = Field( - "crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'." + default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'." ) pvforecast4_mountingplace: Optional[str] = Field( default="free", @@ -316,7 +317,7 @@ class PVForecastCommonSettings(SettingsBaseModel): default=None, description="Nominal power of PV system in kW." ) pvforecast5_pvtechchoice: Optional[str] = Field( - "crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'." + default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'." ) pvforecast5_mountingplace: Optional[str] = Field( default="free", @@ -359,6 +360,8 @@ class PVForecastCommonSettings(SettingsBaseModel): pvforecast_max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set + provider_settings: Optional[PVForecastImportCommonSettings] = None + # Computed fields @computed_field # type: ignore[prop-decorator] @property diff --git a/src/akkudoktoreos/prediction/pvforecastabc.py b/src/akkudoktoreos/prediction/pvforecastabc.py index d9958fe..5e114b6 100644 --- a/src/akkudoktoreos/prediction/pvforecastabc.py +++ b/src/akkudoktoreos/prediction/pvforecastabc.py @@ -54,6 +54,6 @@ class PVForecastProvider(PredictionProvider): def enabled(self) -> bool: logger.debug( - f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast_provider}" + f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast.pvforecast_provider}" ) - return self.provider_id() == self.config.pvforecast_provider + return self.provider_id() == self.config.pvforecast.pvforecast_provider diff --git a/src/akkudoktoreos/prediction/pvforecastakkudoktor.py b/src/akkudoktoreos/prediction/pvforecastakkudoktor.py index 7f8184e..1e7e548 100644 --- a/src/akkudoktoreos/prediction/pvforecastakkudoktor.py +++ b/src/akkudoktoreos/prediction/pvforecastakkudoktor.py @@ -203,19 +203,23 @@ class PVForecastAkkudoktor(PVForecastProvider): """Build akkudoktor.net API request URL.""" base_url = "https://api.akkudoktor.net/forecast" query_params = [ - f"lat={self.config.latitude}", - f"lon={self.config.longitude}", + f"lat={self.config.prediction.latitude}", + f"lon={self.config.prediction.longitude}", ] - for i in range(len(self.config.pvforecast_planes)): - query_params.append(f"power={int(self.config.pvforecast_planes_peakpower[i] * 1000)}") - query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}") - query_params.append(f"tilt={int(self.config.pvforecast_planes_tilt[i])}") + for i in range(len(self.config.pvforecast.pvforecast_planes)): query_params.append( - f"powerInverter={int(self.config.pvforecast_planes_inverter_paco[i])}" + f"power={int(self.config.pvforecast.pvforecast_planes_peakpower[i] * 1000)}" + ) + query_params.append( + f"azimuth={int(self.config.pvforecast.pvforecast_planes_azimuth[i])}" + ) + query_params.append(f"tilt={int(self.config.pvforecast.pvforecast_planes_tilt[i])}") + query_params.append( + f"powerInverter={int(self.config.pvforecast.pvforecast_planes_inverter_paco[i])}" ) horizon_values = ",".join( - str(int(h)) for h in self.config.pvforecast_planes_userhorizon[i] + str(int(h)) for h in self.config.pvforecast.pvforecast_planes_userhorizon[i] ) query_params.append(f"horizont={horizon_values}") @@ -226,7 +230,7 @@ class PVForecastAkkudoktor(PVForecastProvider): "cellCoEff=-0.36", "inverterEfficiency=0.8", "albedo=0.25", - f"timezone={self.config.timezone}", + f"timezone={self.config.prediction.timezone}", "hourly=relativehumidity_2m%2Cwindspeed_10m", ] ) @@ -255,7 +259,7 @@ class PVForecastAkkudoktor(PVForecastProvider): logger.debug(f"Response from {self._url()}: {response}") akkudoktor_data = self._validate_data(response.content) # We are working on fresh data (no cache), report update time - self.update_datetime = to_datetime(in_timezone=self.config.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) return akkudoktor_data def _update_data(self, force_update: Optional[bool] = False) -> None: @@ -265,7 +269,7 @@ class PVForecastAkkudoktor(PVForecastProvider): `PVForecastAkkudoktorDataRecord`. """ # Assure we have something to request PV power for. - if not self.config.pvforecast_planes: + if not self.config.pvforecast.pvforecast_planes: # No planes for PV error_msg = "Requested PV forecast, but no planes configured." logger.error(f"Configuration error: {error_msg}") @@ -275,17 +279,17 @@ class PVForecastAkkudoktor(PVForecastProvider): akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore # Timezone of the PV system - if self.config.timezone != akkudoktor_data.meta.timezone: - error_msg = f"Configured timezone '{self.config.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'." + if self.config.prediction.timezone != akkudoktor_data.meta.timezone: + error_msg = f"Configured timezone '{self.config.prediction.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'." logger.error(f"Akkudoktor schema change: {error_msg}") raise ValueError(error_msg) # Assumption that all lists are the same length and are ordered chronologically # in ascending order and have the same timestamps. - if len(akkudoktor_data.values[0]) < self.config.prediction_hours: + if len(akkudoktor_data.values[0]) < self.config.prediction.prediction_hours: # Expect one value set per prediction hour error_msg = ( - f"The forecast must cover at least {self.config.prediction_hours} hours, " + f"The forecast must cover at least {self.config.prediction.prediction_hours} hours, " f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data." ) logger.error(f"Akkudoktor schema change: {error_msg}") @@ -296,7 +300,7 @@ class PVForecastAkkudoktor(PVForecastProvider): # Iterate over forecast data points for forecast_values in zip(*akkudoktor_data.values): original_datetime = forecast_values[0].datetime - dt = to_datetime(original_datetime, in_timezone=self.config.timezone) + dt = to_datetime(original_datetime, in_timezone=self.config.prediction.timezone) # Skip outdated forecast data if compare_datetimes(dt, self.start_datetime.start_of("day")).lt: @@ -314,9 +318,9 @@ class PVForecastAkkudoktor(PVForecastProvider): self.update_value(dt, data) - if len(self) < self.config.prediction_hours: + if len(self) < self.config.prediction.prediction_hours: raise ValueError( - f"The forecast must cover at least {self.config.prediction_hours} hours, " + f"The forecast must cover at least {self.config.prediction.prediction_hours} hours, " f"but only {len(self)} hours starting from {self.start_datetime} " f"were predicted." ) @@ -365,31 +369,35 @@ if __name__ == "__main__": """ # Set up the configuration with necessary fields for URL generation settings_data = { - "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, + "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, + }, } # Initialize the forecast object with the generated configuration diff --git a/src/akkudoktoreos/prediction/pvforecastimport.py b/src/akkudoktoreos/prediction/pvforecastimport.py index 083f9d8..96d33d7 100644 --- a/src/akkudoktoreos/prediction/pvforecastimport.py +++ b/src/akkudoktoreos/prediction/pvforecastimport.py @@ -62,7 +62,13 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider): return "PVForecastImport" def _update_data(self, force_update: Optional[bool] = False) -> None: - if self.config.pvforecastimport_file_path is not None: - self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast") - if self.config.pvforecastimport_json is not None: - self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast") + if self.config.pvforecast.provider_settings.pvforecastimport_file_path is not None: + self.import_from_file( + self.config.pvforecast.provider_settings.pvforecastimport_file_path, + key_prefix="pvforecast", + ) + if self.config.pvforecast.provider_settings.pvforecastimport_json is not None: + self.import_from_json( + self.config.pvforecast.provider_settings.pvforecastimport_json, + key_prefix="pvforecast", + ) diff --git a/src/akkudoktoreos/prediction/weather.py b/src/akkudoktoreos/prediction/weather.py index 8ad502a..b6c3e34 100644 --- a/src/akkudoktoreos/prediction/weather.py +++ b/src/akkudoktoreos/prediction/weather.py @@ -5,9 +5,12 @@ from typing import Optional from pydantic import Field from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings class WeatherCommonSettings(SettingsBaseModel): weather_provider: Optional[str] = Field( default=None, description="Weather provider id of provider to be used." ) + + provider_settings: Optional[WeatherImportCommonSettings] = None diff --git a/src/akkudoktoreos/prediction/weatherabc.py b/src/akkudoktoreos/prediction/weatherabc.py index 49f1fb0..ba55a87 100644 --- a/src/akkudoktoreos/prediction/weatherabc.py +++ b/src/akkudoktoreos/prediction/weatherabc.py @@ -126,7 +126,7 @@ class WeatherProvider(PredictionProvider): return "WeatherProvider" def enabled(self) -> bool: - return self.provider_id() == self.config.weather_provider + return self.provider_id() == self.config.weather.weather_provider @classmethod def estimate_irradiance_from_cloud_cover( diff --git a/src/akkudoktoreos/prediction/weatherbrightsky.py b/src/akkudoktoreos/prediction/weatherbrightsky.py index 2318316..20a0dd1 100644 --- a/src/akkudoktoreos/prediction/weatherbrightsky.py +++ b/src/akkudoktoreos/prediction/weatherbrightsky.py @@ -99,7 +99,7 @@ class WeatherBrightSky(WeatherProvider): date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD") last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") response = requests.get( - f"{source}/weather?lat={self.config.latitude}&lon={self.config.longitude}&date={date}&last_date={last_date}&tz={self.config.timezone}" + f"{source}/weather?lat={self.config.prediction.latitude}&lon={self.config.prediction.longitude}&date={date}&last_date={last_date}&tz={self.config.prediction.timezone}" ) response.raise_for_status() # Raise an error for bad responses logger.debug(f"Response from {source}: {response}") @@ -109,7 +109,7 @@ class WeatherBrightSky(WeatherProvider): logger.error(error_msg) raise ValueError(error_msg) # We are working on fresh data (no cache), report update time - self.update_datetime = to_datetime(in_timezone=self.config.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) return brightsky_data def _description_to_series(self, description: str) -> pd.Series: @@ -200,7 +200,7 @@ class WeatherBrightSky(WeatherProvider): description = "Total Clouds (% Sky Obscured)" cloud_cover = self._description_to_series(description) ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover( - self.config.latitude, self.config.longitude, cloud_cover + self.config.prediction.latitude, self.config.prediction.longitude, cloud_cover ) description = "Global Horizontal Irradiance (W/m2)" diff --git a/src/akkudoktoreos/prediction/weatherclearoutside.py b/src/akkudoktoreos/prediction/weatherclearoutside.py index cd32382..5c9da31 100644 --- a/src/akkudoktoreos/prediction/weatherclearoutside.py +++ b/src/akkudoktoreos/prediction/weatherclearoutside.py @@ -91,13 +91,13 @@ class WeatherClearOutside(WeatherProvider): response: Weather forecast request reponse from ClearOutside. """ source = "https://clearoutside.com/forecast" - latitude = round(self.config.latitude, 2) - longitude = round(self.config.longitude, 2) + latitude = round(self.config.prediction.latitude, 2) + longitude = round(self.config.prediction.longitude, 2) response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true") response.raise_for_status() # Raise an error for bad responses logger.debug(f"Response from {source}: {response}") # We are working on fresh data (no cache), report update time - self.update_datetime = to_datetime(in_timezone=self.config.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) return response def _update_data(self, force_update: Optional[bool] = None) -> None: @@ -307,7 +307,7 @@ class WeatherClearOutside(WeatherProvider): data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"] ) ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover( - self.config.latitude, self.config.longitude, cloud_cover + self.config.prediction.latitude, self.config.prediction.longitude, cloud_cover ) # Add GHI, DNI, DHI to clearout data diff --git a/src/akkudoktoreos/prediction/weatherimport.py b/src/akkudoktoreos/prediction/weatherimport.py index d781e34..47079b5 100644 --- a/src/akkudoktoreos/prediction/weatherimport.py +++ b/src/akkudoktoreos/prediction/weatherimport.py @@ -59,7 +59,11 @@ class WeatherImport(WeatherProvider, PredictionImportProvider): return "WeatherImport" def _update_data(self, force_update: Optional[bool] = False) -> None: - if self.config.weatherimport_file_path is not None: - self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather") - if self.config.weatherimport_json is not None: - self.import_from_json(self.config.weatherimport_json, key_prefix="weather") + if self.config.weather.provider_settings.weatherimport_file_path is not None: + self.import_from_file( + self.config.weather.provider_settings.weatherimport_file_path, key_prefix="weather" + ) + if self.config.weather.provider_settings.weatherimport_json is not None: + self.import_from_json( + self.config.weather.provider_settings.weatherimport_json, key_prefix="weather" + ) diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index e18406c..f2eddd6 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -29,7 +29,10 @@ from akkudoktoreos.optimization.genetic import ( OptimizeResponse, optimization_problem, ) -from akkudoktoreos.prediction.prediction import get_prediction +from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings +from akkudoktoreos.prediction.load import LoadCommonSettings +from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings +from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_prediction from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration logger = get_logger(__name__) @@ -149,16 +152,16 @@ def start_eosdash() -> subprocess.Popen: if args is None: # No command line arguments - host = config_eos.server_eosdash_host - port = config_eos.server_eosdash_port - eos_host = config_eos.server_eos_host - eos_port = config_eos.server_eos_port + host = config_eos.server.server_eosdash_host + port = config_eos.server.server_eosdash_port + eos_host = config_eos.server.server_eos_host + eos_port = config_eos.server.server_eos_port log_level = "info" access_log = False reload = False else: host = args.host - port = config_eos.server_eosdash_port if config_eos.server_eosdash_port else (args.port + 1) + port = config_eos.server.server_eosdash_port if config_eos.server.server_eosdash_port else (args.port + 1) eos_host = args.host eos_port = args.port log_level = args.log_level @@ -201,7 +204,7 @@ def start_eosdash() -> subprocess.Popen: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Lifespan manager for the app.""" # On startup - if config_eos.server_eos_startup_eosdash: + if config_eos.server.server_eos_startup_eosdash: try: eosdash_process = start_eosdash() except Exception as e: @@ -228,7 +231,7 @@ app = FastAPI( # That's the problem -opt_class = optimization_problem(verbose=bool(config_eos.server_eos_verbose)) +opt_class = optimization_problem(verbose=bool(config_eos.server.server_eos_verbose)) server_dir = Path(__file__).parent.resolve() @@ -340,7 +343,7 @@ def fastapi_config_put( configuration (ConfigEOS): The current configuration after the write. """ try: - config_eos.merge_settings(settings, force=True) + config_eos.merge_settings(settings) except Exception as e: raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}") return config_eos @@ -610,7 +613,9 @@ def fastapi_strompreis() -> list[float]: '/v1/prediction/list?key=elecprice_marketprice_kwh' instead. """ settings = SettingsEOS( - elecprice_provider="ElecPriceAkkudoktor", + elecprice=ElecPriceCommonSettings( + elecprice_provider="ElecPriceAkkudoktor", + ) ) config_eos.merge_settings(settings=settings) ems_eos.set_start_datetime() # Set energy management start datetime to current hour. @@ -660,9 +665,15 @@ def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]: '/v1/measurement/value' """ settings = SettingsEOS( - prediction_hours=request.hours, - load_provider="LoadAkkudoktor", - loadakkudoktor_year_energy=request.year_energy, + prediction=PredictionCommonSettings( + prediction_hours=request.hours, + ), + load=LoadCommonSettings( + load_provider="LoadAkkudoktor", + provider_settings=LoadAkkudoktorCommonSettings( + loadakkudoktor_year_energy=request.year_energy, + ), + ), ) config_eos.merge_settings(settings=settings) ems_eos.set_start_datetime() # Set energy management start datetime to current hour. @@ -738,8 +749,12 @@ def fastapi_gesamtlast_simple(year_energy: float) -> list[float]: '/v1/prediction/list?key=load_mean' instead. """ settings = SettingsEOS( - load_provider="LoadAkkudoktor", - loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh + load=LoadCommonSettings( + load_provider="LoadAkkudoktor", + provider_settings=LoadAkkudoktorCommonSettings( + loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh + ), + ) ) config_eos.merge_settings(settings=settings) ems_eos.set_start_datetime() # Set energy management start datetime to current hour. @@ -844,7 +859,7 @@ def fastapi_optimize( @app.get("/visualization_results.pdf", response_class=PdfResponse) def get_pdf() -> PdfResponse: # Endpoint to serve the generated PDF with visualization results - output_path = config_eos.data_output_path + output_path = config_eos.config.data_output_path if output_path is None or not output_path.is_dir(): raise HTTPException(status_code=404, detail=f"Output path does not exist: {output_path}.") file_path = output_path / "visualization_results.pdf" @@ -882,9 +897,9 @@ async def proxy_put(request: Request, path: str) -> Response: async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse | HTMLResponse]: - if config_eos.server_eosdash_host and config_eos.server_eosdash_port: + if config_eos.server.server_eosdash_host and config_eos.server.server_eosdash_port: # Proxy to EOSdash server - url = f"http://{config_eos.server_eosdash_host}:{config_eos.server_eosdash_port}/{path}" + url = f"http://{config_eos.server.server_eosdash_host}:{config_eos.server.server_eosdash_port}/{path}" headers = dict(request.headers) data = await request.body() @@ -984,14 +999,14 @@ def main() -> None: parser.add_argument( "--host", type=str, - default=str(config_eos.server_eos_host), - help="Host for the EOS server (default: value from config_eos)", + default=str(config_eos.server.server_eos_host), + help="Host for the EOS server (default: value from config)", ) parser.add_argument( "--port", type=int, - default=config_eos.server_eos_port, - help="Port for the EOS server (default: value from config_eos)", + default=config_eos.server.server_eos_port, + help="Port for the EOS server (default: value from config)", ) # Optional arguments for log_level, access_log, and reload diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py index 91de731..ce012d4 100644 --- a/src/akkudoktoreos/server/eosdash.py +++ b/src/akkudoktoreos/server/eosdash.py @@ -110,13 +110,13 @@ def main() -> None: parser.add_argument( "--host", type=str, - default=str(config_eos.server_eosdash_host), + default=str(config_eos.server.server_eosdash_host), help="Host for the EOSdash server (default: value from config_eos)", ) parser.add_argument( "--port", type=int, - default=config_eos.server_eosdash_port, + default=config_eos.server.server_eosdash_port, help="Port for the EOSdash server (default: value from config_eos)", ) @@ -124,13 +124,13 @@ def main() -> None: parser.add_argument( "--eos-host", type=str, - default=str(config_eos.server_eos_host), + default=str(config_eos.server.server_eos_host), help="Host for the EOS server (default: value from config_eos)", ) parser.add_argument( "--eos-port", type=int, - default=config_eos.server_eos_port, + default=config_eos.server.server_eos_port, help="Port for the EOS server (default: value from config_eos)", ) diff --git a/src/akkudoktoreos/utils/cacheutil.py b/src/akkudoktoreos/utils/cacheutil.py index 24848af..bd37170 100644 --- a/src/akkudoktoreos/utils/cacheutil.py +++ b/src/akkudoktoreos/utils/cacheutil.py @@ -329,9 +329,9 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta): # File already available cache_file_obj = cache_item.cache_file else: - self.config.data_cache_path.mkdir(parents=True, exist_ok=True) + self.config.general.data_cache_path.mkdir(parents=True, exist_ok=True) cache_file_obj = tempfile.NamedTemporaryFile( - mode=mode, delete=delete, suffix=suffix, dir=self.config.data_cache_path + mode=mode, delete=delete, suffix=suffix, dir=self.config.general.data_cache_path ) self._store[cache_file_key] = CacheFileRecord( cache_file=cache_file_obj, diff --git a/src/akkudoktoreos/utils/utils.py b/src/akkudoktoreos/utils/utils.py index ffd81e8..a88954c 100644 --- a/src/akkudoktoreos/utils/utils.py +++ b/src/akkudoktoreos/utils/utils.py @@ -1,5 +1,5 @@ import json -from typing import Any +from typing import Any, Optional import numpy as np @@ -9,6 +9,14 @@ from akkudoktoreos.core.logging import get_logger logger = get_logger(__name__) +class classproperty(property): + def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any: + if owner_cls is None: + return self + assert self.fget is not None + return self.fget(owner_cls) + + class UtilsCommonSettings(SettingsBaseModel): pass diff --git a/src/akkudoktoreos/utils/visualize.py b/src/akkudoktoreos/utils/visualize.py index 3c63ab3..31ccc85 100644 --- a/src/akkudoktoreos/utils/visualize.py +++ b/src/akkudoktoreos/utils/visualize.py @@ -34,7 +34,7 @@ class VisualizationReport(ConfigMixin): self.pdf_pages = PdfPages(filename, metadata={}) # Initialize PdfPages without metadata self.version = version # overwrite version as test for constant output of pdf for test self.current_time = to_datetime( - as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.timezone + as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.prediction.timezone ) def add_chart_to_group(self, chart_func: Callable[[], None]) -> None: @@ -51,7 +51,7 @@ class VisualizationReport(ConfigMixin): def _initialize_pdf(self) -> None: """Create the output directory if it doesn't exist and initialize the PDF.""" - output_dir = self.config.data_output_path + output_dir = self.config.general.data_output_path # If self.filename is already a valid path, use it; otherwise, combine it with output_dir if os.path.isabs(self.filename): @@ -173,7 +173,7 @@ class VisualizationReport(ConfigMixin): plt.grid(True) # Add vertical line for the current date if within the axis range - current_time = pendulum.now(self.config.timezone) + current_time = pendulum.now(self.config.prediction.timezone) if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]: plt.axvline(current_time, color="r", linestyle="--", label="Now") plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom") @@ -419,7 +419,7 @@ def prepare_visualize( start_hour: Optional[int] = 0, ) -> None: report = VisualizationReport(filename) - next_full_hour_date = pendulum.now(report.config.timezone).start_of("hour").add(hours=1) + next_full_hour_date = pendulum.now(report.config.prediction.timezone).start_of("hour").add(hours=1) # Group 1: report.create_line_chart_date( next_full_hour_date, # start_date diff --git a/tests/conftest.py b/tests/conftest.py index 2e3536e..c2ff09f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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, diff --git a/tests/test_battery.py b/tests/test_battery.py index 3bd0bba..490f7f7 100644 --- a/tests/test_battery.py +++ b/tests/test_battery.py @@ -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 diff --git a/tests/test_class_ems.py b/tests/test_class_ems.py index 9951674..29fdf32 100644 --- a/tests/test_class_ems.py +++ b/tests/test_class_ems.py @@ -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 = [ diff --git a/tests/test_class_ems_2.py b/tests/test_class_ems_2.py index 385d0b3..cb11ae1 100644 --- a/tests/test_class_ems_2.py +++ b/tests/test_class_ems_2.py @@ -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) diff --git a/tests/test_class_optimize.py b/tests/test_class_optimize.py index 641d122..c0c002c 100644 --- a/tests/test_class_optimize.py +++ b/tests/test_class_optimize.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index 6c30775..247539d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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() diff --git a/tests/test_elecpriceakkudoktor.py b/tests/test_elecpriceakkudoktor.py index 1517fd2..13bd678 100644 --- a/tests/test_elecpriceakkudoktor.py +++ b/tests/test_elecpriceakkudoktor.py @@ -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", "") - elecprice_provider.config.update() - assert elecprice_provider.enabled() == False + monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "") + elecprice_provider.config.reset_settings() + assert not elecprice_provider.enabled() # ------------------------------------------------ diff --git a/tests/test_elecpriceimport.py b/tests/test_elecpriceimport.py index dacb7b4..29e5396 100644 --- a/tests/test_elecpriceimport.py +++ b/tests/test_elecpriceimport.py @@ -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": "", - "elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON), + "elecprice": { + "elecprice_provider": "", + "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 diff --git a/tests/test_inverter.py b/tests/test_inverter.py index 663502d..b4c8f43 100644 --- a/tests/test_inverter.py +++ b/tests/test_inverter.py @@ -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): diff --git a/tests/test_loadakkudoktor.py b/tests/test_loadakkudoktor.py index d4632fd..2b9b5ed 100644 --- a/tests/test_loadakkudoktor.py +++ b/tests/test_loadakkudoktor.py @@ -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() diff --git a/tests/test_measurement.py b/tests/test_measurement.py index de0e40e..673f1f8 100644 --- a/tests/test_measurement.py +++ b/tests/test_measurement.py @@ -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) diff --git a/tests/test_prediction.py b/tests/test_prediction.py index 27d0f1b..ffca2ac 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -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 diff --git a/tests/test_predictionabc.py b/tests/test_predictionabc.py index 5e6d2ff..2458a36 100644 --- a/tests/test_predictionabc.py +++ b/tests/test_predictionabc.py @@ -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 diff --git a/tests/test_pvforecastakkudoktor.py b/tests/test_pvforecastakkudoktor.py index 453e44c..ad4439f 100644 --- a/tests/test_pvforecastakkudoktor.py +++ b/tests/test_pvforecastakkudoktor.py @@ -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, + }, } diff --git a/tests/test_pvforecastimport.py b/tests/test_pvforecastimport.py index 863fef1..3702087 100644 --- a/tests/test_pvforecastimport.py +++ b/tests/test_pvforecastimport.py @@ -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": "", - "pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON), + "pvforecast": { + "pvforecast_provider": "", + "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 diff --git a/tests/test_server.py b/tests/test_server.py index 81a7bf0..90b0dd0 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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 diff --git a/tests/test_visualize.py b/tests/test_visualize.py index 9ca79c0..a0ca20f 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -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() diff --git a/tests/test_weatherbrightsky.py b/tests/test_weatherbrightsky.py index 555057b..8052248 100644 --- a/tests/test_weatherbrightsky.py +++ b/tests/test_weatherbrightsky.py @@ -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", "") - weather_provider.config.update() + monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "") + 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() # ------------------------------------------------ diff --git a/tests/test_weatherclearoutside.py b/tests/test_weatherclearoutside.py index 0a0aace..aa2e5fb 100644 --- a/tests/test_weatherclearoutside.py +++ b/tests/test_weatherclearoutside.py @@ -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": "", + "weather": { + "weather_provider": "", + } } 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 diff --git a/tests/test_weatherimport.py b/tests/test_weatherimport.py index d54288a..dd2ffe5 100644 --- a/tests/test_weatherimport.py +++ b/tests/test_weatherimport.py @@ -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": "", - "weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON), + "weather": { + "weather_provider": "", + "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 diff --git a/tests/testdata/EOS.config.json b/tests/testdata/EOS.config.json deleted file mode 100644 index 299c30a..0000000 --- a/tests/testdata/EOS.config.json +++ /dev/null @@ -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 -} diff --git a/tests/testdata/optimize_input_1.json b/tests/testdata/optimize_input_1.json index 88000e6..ba62866 100644 --- a/tests/testdata/optimize_input_1.json +++ b/tests/testdata/optimize_input_1.json @@ -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, diff --git a/tests/testdata/optimize_input_2.json b/tests/testdata/optimize_input_2.json index e550c6d..c3f1e0a 100644 --- a/tests/testdata/optimize_input_2.json +++ b/tests/testdata/optimize_input_2.json @@ -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 }, diff --git a/tests/testdata/optimize_result_1.json b/tests/testdata/optimize_result_1.json index efe3d4f..e381f98 100644 --- a/tests/testdata/optimize_result_1.json +++ b/tests/testdata/optimize_result_1.json @@ -557,6 +557,7 @@ ] }, "eauto_obj": { + "device_id": "ev1", "charge_array": [ 1.0, 1.0, diff --git a/tests/testdata/optimize_result_2.json b/tests/testdata/optimize_result_2.json index 21fbf1e..cb6962f 100644 --- a/tests/testdata/optimize_result_2.json +++ b/tests/testdata/optimize_result_2.json @@ -606,6 +606,7 @@ ] }, "eauto_obj": { + "device_id": "ev1", "charge_array": [ 1.0, 1.0, diff --git a/tests/testdata/optimize_result_2_full.json b/tests/testdata/optimize_result_2_full.json index 0be5df7..e499909 100644 --- a/tests/testdata/optimize_result_2_full.json +++ b/tests/testdata/optimize_result_2_full.json @@ -606,6 +606,7 @@ ] }, "eauto_obj": { + "device_id": "ev1", "charge_array": [ 1.0, 1.0,