mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-04-19 08:55:15 +00:00
Nested config, devices registry
* All config now nested. - Use default config from model field default values. If providers should be enabled by default, non-empty default config file could be provided again. - Environment variable support with EOS_ prefix and __ between levels, e.g. EOS_SERVER__EOS_SERVER_PORT=8503 where all values are case insensitive. For more information see: https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values - Use devices as registry for configured devices. DeviceBase as base class with for now just initializion support (in the future expand to operations during optimization). - Strip down ConfigEOS to the only configuration instance. Reload from file or reset to defaults is possible. * Fix multi-initialization of derived SingletonMixin classes.
This commit is contained in:
parent
f09658578a
commit
be26457563
@ -205,309 +205,29 @@ Returns:
|
|||||||
|
|
||||||
**Parameters**:
|
**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.
|
- `utils` (query, optional): No description provided.
|
||||||
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
**Responses**:
|
**Responses**:
|
||||||
|
|
||||||
|
@ -14,3 +14,4 @@ platformdirs==4.3.6
|
|||||||
pvlib==0.11.2
|
pvlib==0.11.2
|
||||||
pydantic==2.10.5
|
pydantic==2.10.5
|
||||||
statsmodels==0.14.4
|
statsmodels==0.14.4
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
@ -79,9 +79,11 @@ def generate_config_md() -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
str: The Markdown representation of the configuration spec.
|
str: The Markdown representation of the configuration spec.
|
||||||
"""
|
"""
|
||||||
|
# FIXME: Support for nested
|
||||||
configs = {}
|
configs = {}
|
||||||
config_keys = config_eos.config_keys
|
config_keys = config_eos.model_fields_set
|
||||||
config_keys_read_only = config_eos.config_keys_read_only
|
# config_keys_read_only = config_eos.config_keys_read_only
|
||||||
|
config_keys_read_only: list[str] = []
|
||||||
for config_key in config_keys:
|
for config_key in config_keys:
|
||||||
config = {}
|
config = {}
|
||||||
config["name"] = config_key
|
config["name"] = config_key
|
||||||
|
@ -30,42 +30,52 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
|
|||||||
"""
|
"""
|
||||||
# Make a config
|
# Make a config
|
||||||
settings = {
|
settings = {
|
||||||
# -- General --
|
"prediction": {
|
||||||
"prediction_hours": 48,
|
"prediction_hours": 48,
|
||||||
"prediction_historic_hours": 24,
|
"prediction_historic_hours": 24,
|
||||||
"latitude": 52.52,
|
"latitude": 52.52,
|
||||||
"longitude": 13.405,
|
"longitude": 13.405,
|
||||||
# -- Predictions --
|
},
|
||||||
# PV Forecast
|
# PV Forecast
|
||||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
"pvforecast": {
|
||||||
"pvforecast0_peakpower": 5.0,
|
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||||
"pvforecast0_surface_azimuth": -10,
|
"pvforecast0_peakpower": 5.0,
|
||||||
"pvforecast0_surface_tilt": 7,
|
"pvforecast0_surface_azimuth": -10,
|
||||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
"pvforecast0_surface_tilt": 7,
|
||||||
"pvforecast0_inverter_paco": 10000,
|
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||||
"pvforecast1_peakpower": 4.8,
|
"pvforecast0_inverter_paco": 10000,
|
||||||
"pvforecast1_surface_azimuth": -90,
|
"pvforecast1_peakpower": 4.8,
|
||||||
"pvforecast1_surface_tilt": 7,
|
"pvforecast1_surface_azimuth": -90,
|
||||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
"pvforecast1_surface_tilt": 7,
|
||||||
"pvforecast1_inverter_paco": 10000,
|
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||||
"pvforecast2_peakpower": 1.4,
|
"pvforecast1_inverter_paco": 10000,
|
||||||
"pvforecast2_surface_azimuth": -40,
|
"pvforecast2_peakpower": 1.4,
|
||||||
"pvforecast2_surface_tilt": 60,
|
"pvforecast2_surface_azimuth": -40,
|
||||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
"pvforecast2_surface_tilt": 60,
|
||||||
"pvforecast2_inverter_paco": 2000,
|
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||||
"pvforecast3_peakpower": 1.6,
|
"pvforecast2_inverter_paco": 2000,
|
||||||
"pvforecast3_surface_azimuth": 5,
|
"pvforecast3_peakpower": 1.6,
|
||||||
"pvforecast3_surface_tilt": 45,
|
"pvforecast3_surface_azimuth": 5,
|
||||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
"pvforecast3_surface_tilt": 45,
|
||||||
"pvforecast3_inverter_paco": 1400,
|
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||||
"pvforecast4_peakpower": None,
|
"pvforecast3_inverter_paco": 1400,
|
||||||
|
"pvforecast4_peakpower": None,
|
||||||
|
},
|
||||||
# Weather Forecast
|
# Weather Forecast
|
||||||
"weather_provider": "ClearOutside",
|
"weather": {
|
||||||
|
"weather_provider": "ClearOutside",
|
||||||
|
},
|
||||||
# Electricity Price Forecast
|
# Electricity Price Forecast
|
||||||
"elecprice_provider": "ElecPriceAkkudoktor",
|
"elecprice": {
|
||||||
|
"elecprice_provider": "ElecPriceAkkudoktor",
|
||||||
|
},
|
||||||
# Load Forecast
|
# Load Forecast
|
||||||
"load_provider": "LoadAkkudoktor",
|
"load": {
|
||||||
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
|
"load_provider": "LoadAkkudoktor",
|
||||||
|
"provider_settings": {
|
||||||
|
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
|
||||||
|
},
|
||||||
|
},
|
||||||
# -- Simulations --
|
# -- Simulations --
|
||||||
}
|
}
|
||||||
config_eos = get_config()
|
config_eos = get_config()
|
||||||
@ -129,11 +139,14 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
|
|||||||
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
|
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
|
||||||
},
|
},
|
||||||
"pv_akku": {
|
"pv_akku": {
|
||||||
|
"device_id": "battery1",
|
||||||
"capacity_wh": 26400,
|
"capacity_wh": 26400,
|
||||||
"initial_soc_percentage": 15,
|
"initial_soc_percentage": 15,
|
||||||
"min_soc_percentage": 15,
|
"min_soc_percentage": 15,
|
||||||
},
|
},
|
||||||
|
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery": "battery1"},
|
||||||
"eauto": {
|
"eauto": {
|
||||||
|
"device_id": "ev1",
|
||||||
"min_soc_percentage": 50,
|
"min_soc_percentage": 50,
|
||||||
"capacity_wh": 60000,
|
"capacity_wh": 60000,
|
||||||
"charging_efficiency": 0.95,
|
"charging_efficiency": 0.95,
|
||||||
@ -283,11 +296,14 @@ def prepare_optimization_parameters() -> OptimizationParameters:
|
|||||||
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
|
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
|
||||||
},
|
},
|
||||||
"pv_akku": {
|
"pv_akku": {
|
||||||
|
"device_id": "battery1",
|
||||||
"capacity_wh": 26400,
|
"capacity_wh": 26400,
|
||||||
"initial_soc_percentage": 15,
|
"initial_soc_percentage": 15,
|
||||||
"min_soc_percentage": 15,
|
"min_soc_percentage": 15,
|
||||||
},
|
},
|
||||||
|
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery": "battery1"},
|
||||||
"eauto": {
|
"eauto": {
|
||||||
|
"device_id": "ev1",
|
||||||
"min_soc_percentage": 50,
|
"min_soc_percentage": 50,
|
||||||
"capacity_wh": 60000,
|
"capacity_wh": 60000,
|
||||||
"charging_efficiency": 0.95,
|
"charging_efficiency": 0.95,
|
||||||
@ -330,7 +346,9 @@ def run_optimization(
|
|||||||
|
|
||||||
# Initialize the optimization problem using the default configuration
|
# Initialize the optimization problem using the default configuration
|
||||||
config_eos = get_config()
|
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)
|
opt_class = optimization_problem(verbose=verbose, fixed_seed=seed)
|
||||||
|
|
||||||
# Perform the optimisation based on the provided parameters and start hour
|
# Perform the optimisation based on the provided parameters and start hour
|
||||||
|
@ -16,32 +16,36 @@ prediction_eos = get_prediction()
|
|||||||
def config_pvforecast() -> dict:
|
def config_pvforecast() -> dict:
|
||||||
"""Configure settings for PV forecast."""
|
"""Configure settings for PV forecast."""
|
||||||
settings = {
|
settings = {
|
||||||
"prediction_hours": 48,
|
"prediction": {
|
||||||
"prediction_historic_hours": 24,
|
"prediction_hours": 48,
|
||||||
"latitude": 52.52,
|
"prediction_historic_hours": 24,
|
||||||
"longitude": 13.405,
|
"latitude": 52.52,
|
||||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
"longitude": 13.405,
|
||||||
"pvforecast0_peakpower": 5.0,
|
},
|
||||||
"pvforecast0_surface_azimuth": -10,
|
"pvforecast": {
|
||||||
"pvforecast0_surface_tilt": 7,
|
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
"pvforecast0_peakpower": 5.0,
|
||||||
"pvforecast0_inverter_paco": 10000,
|
"pvforecast0_surface_azimuth": -10,
|
||||||
"pvforecast1_peakpower": 4.8,
|
"pvforecast0_surface_tilt": 7,
|
||||||
"pvforecast1_surface_azimuth": -90,
|
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||||
"pvforecast1_surface_tilt": 7,
|
"pvforecast0_inverter_paco": 10000,
|
||||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
"pvforecast1_peakpower": 4.8,
|
||||||
"pvforecast1_inverter_paco": 10000,
|
"pvforecast1_surface_azimuth": -90,
|
||||||
"pvforecast2_peakpower": 1.4,
|
"pvforecast1_surface_tilt": 7,
|
||||||
"pvforecast2_surface_azimuth": -40,
|
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||||
"pvforecast2_surface_tilt": 60,
|
"pvforecast1_inverter_paco": 10000,
|
||||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
"pvforecast2_peakpower": 1.4,
|
||||||
"pvforecast2_inverter_paco": 2000,
|
"pvforecast2_surface_azimuth": -40,
|
||||||
"pvforecast3_peakpower": 1.6,
|
"pvforecast2_surface_tilt": 60,
|
||||||
"pvforecast3_surface_azimuth": 5,
|
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||||
"pvforecast3_surface_tilt": 45,
|
"pvforecast2_inverter_paco": 2000,
|
||||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
"pvforecast3_peakpower": 1.6,
|
||||||
"pvforecast3_inverter_paco": 1400,
|
"pvforecast3_surface_azimuth": 5,
|
||||||
"pvforecast4_peakpower": None,
|
"pvforecast3_surface_tilt": 45,
|
||||||
|
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||||
|
"pvforecast3_inverter_paco": 1400,
|
||||||
|
"pvforecast4_peakpower": None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@ -49,10 +53,13 @@ def config_pvforecast() -> dict:
|
|||||||
def config_weather() -> dict:
|
def config_weather() -> dict:
|
||||||
"""Configure settings for weather forecast."""
|
"""Configure settings for weather forecast."""
|
||||||
settings = {
|
settings = {
|
||||||
"prediction_hours": 48,
|
"prediction": {
|
||||||
"prediction_historic_hours": 24,
|
"prediction_hours": 48,
|
||||||
"latitude": 52.52,
|
"prediction_historic_hours": 24,
|
||||||
"longitude": 13.405,
|
"latitude": 52.52,
|
||||||
|
"longitude": 13.405,
|
||||||
|
},
|
||||||
|
"weather": dict(),
|
||||||
}
|
}
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@ -60,10 +67,13 @@ def config_weather() -> dict:
|
|||||||
def config_elecprice() -> dict:
|
def config_elecprice() -> dict:
|
||||||
"""Configure settings for electricity price forecast."""
|
"""Configure settings for electricity price forecast."""
|
||||||
settings = {
|
settings = {
|
||||||
"prediction_hours": 48,
|
"prediction": {
|
||||||
"prediction_historic_hours": 24,
|
"prediction_hours": 48,
|
||||||
"latitude": 52.52,
|
"prediction_historic_hours": 24,
|
||||||
"longitude": 13.405,
|
"latitude": 52.52,
|
||||||
|
"longitude": 13.405,
|
||||||
|
},
|
||||||
|
"elecprice": dict(),
|
||||||
}
|
}
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@ -71,10 +81,12 @@ def config_elecprice() -> dict:
|
|||||||
def config_load() -> dict:
|
def config_load() -> dict:
|
||||||
"""Configure settings for load forecast."""
|
"""Configure settings for load forecast."""
|
||||||
settings = {
|
settings = {
|
||||||
"prediction_hours": 48,
|
"prediction": {
|
||||||
"prediction_historic_hours": 24,
|
"prediction_hours": 48,
|
||||||
"latitude": 52.52,
|
"prediction_historic_hours": 24,
|
||||||
"longitude": 13.405,
|
"latitude": 52.52,
|
||||||
|
"longitude": 13.405,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@ -96,17 +108,17 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
|
|||||||
print(f"\nProvider ID: {provider_id}")
|
print(f"\nProvider ID: {provider_id}")
|
||||||
if provider_id in ("PVForecastAkkudoktor",):
|
if provider_id in ("PVForecastAkkudoktor",):
|
||||||
settings = config_pvforecast()
|
settings = config_pvforecast()
|
||||||
settings["pvforecast_provider"] = provider_id
|
settings["pvforecast"]["pvforecast_provider"] = provider_id
|
||||||
elif provider_id in ("BrightSky", "ClearOutside"):
|
elif provider_id in ("BrightSky", "ClearOutside"):
|
||||||
settings = config_weather()
|
settings = config_weather()
|
||||||
settings["weather_provider"] = provider_id
|
settings["weather"]["weather_provider"] = provider_id
|
||||||
elif provider_id in ("ElecPriceAkkudoktor",):
|
elif provider_id in ("ElecPriceAkkudoktor",):
|
||||||
settings = config_elecprice()
|
settings = config_elecprice()
|
||||||
settings["elecprice_provider"] = provider_id
|
settings["elecprice"]["elecprice_provider"] = provider_id
|
||||||
elif provider_id in ("LoadAkkudoktor",):
|
elif provider_id in ("LoadAkkudoktor",):
|
||||||
settings = config_elecprice()
|
settings = config_elecprice()
|
||||||
settings["loadakkudoktor_year_energy"] = 1000
|
settings["load"]["loadakkudoktor_year_energy"] = 1000
|
||||||
settings["load_provider"] = provider_id
|
settings["load"]["load_provider"] = provider_id
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown provider '{provider_id}'.")
|
raise ValueError(f"Unknown provider '{provider_id}'.")
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
|
@ -12,31 +12,34 @@ Key features:
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
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 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
|
# settings
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.logsettings import LoggingCommonSettings
|
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.measurement.measurement import MeasurementCommonSettings
|
||||||
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
|
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
|
||||||
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
|
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
|
||||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
|
||||||
from akkudoktoreos.prediction.load import LoadCommonSettings
|
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.prediction import PredictionCommonSettings
|
||||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
|
||||||
from akkudoktoreos.prediction.weather import WeatherCommonSettings
|
from akkudoktoreos.prediction.weather import WeatherCommonSettings
|
||||||
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
|
|
||||||
from akkudoktoreos.server.server import ServerCommonSettings
|
from akkudoktoreos.server.server import ServerCommonSettings
|
||||||
from akkudoktoreos.utils.utils import UtilsCommonSettings
|
from akkudoktoreos.utils.utils import UtilsCommonSettings, classproperty
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@ -67,11 +70,11 @@ class ConfigCommonSettings(SettingsBaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
data_output_subpath: Optional[Path] = Field(
|
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(
|
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
|
# Computed fields
|
||||||
@ -89,31 +92,51 @@ class ConfigCommonSettings(SettingsBaseModel):
|
|||||||
return get_absolute_path(self.data_folder_path, self.data_cache_subpath)
|
return get_absolute_path(self.data_folder_path, self.data_cache_subpath)
|
||||||
|
|
||||||
|
|
||||||
class SettingsEOS(
|
class SettingsEOS(BaseSettings):
|
||||||
ConfigCommonSettings,
|
"""Settings for all EOS.
|
||||||
LoggingCommonSettings,
|
|
||||||
DevicesCommonSettings,
|
|
||||||
MeasurementCommonSettings,
|
|
||||||
OptimizationCommonSettings,
|
|
||||||
PredictionCommonSettings,
|
|
||||||
ElecPriceCommonSettings,
|
|
||||||
ElecPriceImportCommonSettings,
|
|
||||||
LoadCommonSettings,
|
|
||||||
LoadAkkudoktorCommonSettings,
|
|
||||||
LoadImportCommonSettings,
|
|
||||||
PVForecastCommonSettings,
|
|
||||||
PVForecastImportCommonSettings,
|
|
||||||
WeatherCommonSettings,
|
|
||||||
WeatherImportCommonSettings,
|
|
||||||
ServerCommonSettings,
|
|
||||||
UtilsCommonSettings,
|
|
||||||
):
|
|
||||||
"""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.
|
"""Singleton configuration handler for the EOS application.
|
||||||
|
|
||||||
ConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic
|
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.
|
in one part of the application reflects across all references to this class.
|
||||||
|
|
||||||
Attributes:
|
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_folder_path (Optional[Path]): Path to the configuration directory.
|
||||||
config_file_path (Optional[Path]): Path to the configuration file.
|
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):
|
To initialize and access configuration attributes (only one instance is created):
|
||||||
```python
|
```python
|
||||||
config_eos = ConfigEOS() # Always returns the same instance
|
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"
|
ENCODING: ClassVar[str] = "UTF-8"
|
||||||
CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json"
|
CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json"
|
||||||
|
|
||||||
_settings: ClassVar[Optional[SettingsEOS]] = None
|
_config_folder_path: ClassVar[Optional[Path]] = None
|
||||||
_file_settings: ClassVar[Optional[SettingsEOS]] = None
|
_config_file_path: ClassVar[Optional[Path]] = None
|
||||||
|
|
||||||
_config_folder_path: Optional[Path] = None
|
@classmethod
|
||||||
_config_file_path: Optional[Path] = None
|
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
|
@property
|
||||||
def config_folder_path(self) -> Optional[Path]:
|
def config_folder_path(self) -> Optional[Path]:
|
||||||
"""Path to EOS configuration directory."""
|
"""Path to EOS configuration directory."""
|
||||||
return self._config_folder_path
|
return self._config_folder_path
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
|
||||||
@property
|
@property
|
||||||
def config_file_path(self) -> Optional[Path]:
|
def config_file_path(self) -> Optional[Path]:
|
||||||
"""Path to EOS configuration file."""
|
"""Path to EOS configuration file."""
|
||||||
return self._config_file_path
|
return self._config_file_path
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@classmethod
|
||||||
@property
|
@classproperty
|
||||||
def config_default_file_path(self) -> Path:
|
def config_default_file_path(cls) -> Path:
|
||||||
"""Compute the default config file 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]
|
@classmethod
|
||||||
@property
|
@classproperty
|
||||||
def package_root_path(self) -> Path:
|
def package_root_path(cls) -> Path:
|
||||||
"""Compute the package root path."""
|
"""Compute the package root path."""
|
||||||
return Path(__file__).parent.parent.resolve()
|
return Path(__file__).parent.parent.resolve()
|
||||||
|
|
||||||
# Computed fields
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
@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:
|
|
||||||
"""Initializes the singleton ConfigEOS instance.
|
"""Initializes the singleton ConfigEOS instance.
|
||||||
|
|
||||||
Configuration data is loaded from a configuration file or a default one is created if none
|
Configuration data is loaded from a configuration file or a default one is created if none
|
||||||
exists.
|
exists.
|
||||||
"""
|
"""
|
||||||
super().__init__()
|
if hasattr(self, "_initialized"):
|
||||||
self.from_config_file()
|
return
|
||||||
self.update()
|
super().__init__(*args, **kwargs)
|
||||||
|
self._create_initial_config_file()
|
||||||
|
self._update_data_folder_path()
|
||||||
|
|
||||||
@property
|
def _setup(self, *args: Any, **kwargs: Any) -> None:
|
||||||
def settings(self) -> Optional[SettingsEOS]:
|
"""Re-initialize global settings."""
|
||||||
"""Returns global settings for EOS.
|
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.
|
def merge_settings(self, settings: SettingsEOS) -> None:
|
||||||
|
|
||||||
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:
|
|
||||||
"""Merges the provided settings into the global settings for EOS, with optional overwrite.
|
"""Merges the provided settings into the global settings for EOS, with optional overwrite.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
settings (SettingsEOS): The settings to apply globally.
|
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:
|
Raises:
|
||||||
ValueError: If settings are already set and `force` is not True or
|
ValueError: If the `settings` is not a `SettingsEOS` instance.
|
||||||
if the `settings` is not a `SettingsEOS` instance.
|
|
||||||
"""
|
"""
|
||||||
if not isinstance(settings, SettingsEOS):
|
if not isinstance(settings, SettingsEOS):
|
||||||
raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.")
|
raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.")
|
||||||
|
|
||||||
if ConfigEOS._settings is None or force:
|
self.merge_settings_from_dict(settings.model_dump())
|
||||||
ConfigEOS._settings = settings
|
|
||||||
else:
|
|
||||||
self._merge_and_update_settings(settings)
|
|
||||||
|
|
||||||
# Update configuration after merging
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def merge_settings_from_dict(self, data: dict) -> None:
|
def merge_settings_from_dict(self, data: dict) -> None:
|
||||||
"""Merges the provided dictionary data into the current instance.
|
"""Merges the provided dictionary data into the current instance.
|
||||||
@ -289,141 +325,78 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> config = get_config()
|
>>> 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)
|
>>> config.merge_settings_from_dict(new_data)
|
||||||
"""
|
"""
|
||||||
# Create new settings instance with reset optional fields and merged data
|
self._setup(**merge_models(self, data))
|
||||||
settings = SettingsEOS.from_dict(data)
|
|
||||||
self.merge_settings(settings)
|
|
||||||
|
|
||||||
def reset_settings(self) -> None:
|
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.
|
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:
|
def _update_data_folder_path(self) -> None:
|
||||||
"""Updates path to the data directory."""
|
"""Updates path to the data directory."""
|
||||||
# From Settings
|
# From Settings
|
||||||
if self.settings and (data_dir := self.settings.data_folder_path):
|
if data_dir := self.general.data_folder_path:
|
||||||
try:
|
try:
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.data_folder_path = data_dir
|
self.general.data_folder_path = data_dir
|
||||||
return
|
return
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.warning(f"Could not setup data dir: {e}")
|
||||||
# From EOS_DIR env
|
# From EOS_DIR env
|
||||||
env_dir = os.getenv(self.EOS_DIR)
|
if env_dir := os.getenv(self.EOS_DIR):
|
||||||
if env_dir is not None:
|
|
||||||
try:
|
try:
|
||||||
data_dir = Path(env_dir).resolve()
|
data_dir = Path(env_dir).resolve()
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.data_folder_path = data_dir
|
self.general.data_folder_path = data_dir
|
||||||
return
|
return
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.warning(f"Could not setup data dir: {e}")
|
||||||
# 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
|
|
||||||
# From platform specific default path
|
# From platform specific default path
|
||||||
try:
|
try:
|
||||||
data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR))
|
data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR))
|
||||||
if data_dir is not None:
|
if data_dir is not None:
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.data_folder_path = data_dir
|
self.general.data_folder_path = data_dir
|
||||||
return
|
return
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.warning(f"Could not setup data dir: {e}")
|
||||||
# Current working directory
|
# Current working directory
|
||||||
data_dir = Path.cwd()
|
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.
|
"""Finds the a valid configuration file or returns the desired path for a new config file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[Path, bool]: The path to the configuration directory and if there is already a config file there
|
tuple[Path, bool]: The path to the configuration directory and if there is already a config file there
|
||||||
"""
|
"""
|
||||||
config_dirs = []
|
config_dirs = []
|
||||||
env_base_dir = os.getenv(self.EOS_DIR)
|
env_base_dir = os.getenv(cls.EOS_DIR)
|
||||||
env_config_dir = os.getenv(self.EOS_CONFIG_DIR)
|
env_config_dir = os.getenv(cls.EOS_CONFIG_DIR)
|
||||||
env_dir = get_absolute_path(env_base_dir, env_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:
|
if env_dir is not None:
|
||||||
config_dirs.append(env_dir.resolve())
|
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())
|
config_dirs.append(Path.cwd())
|
||||||
for cdir in config_dirs:
|
for cdir in config_dirs:
|
||||||
cfile = cdir.joinpath(self.CONFIG_FILE_NAME)
|
cfile = cdir.joinpath(cls.CONFIG_FILE_NAME)
|
||||||
if cfile.exists():
|
if cfile.exists():
|
||||||
logger.debug(f"Found config file: '{cfile}'")
|
logger.debug(f"Found config file: '{cfile}'")
|
||||||
return cfile, True
|
return cfile, True
|
||||||
return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False
|
return config_dirs[0].joinpath(cls.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
|
|
||||||
|
|
||||||
def to_config_file(self) -> None:
|
def to_config_file(self) -> None:
|
||||||
"""Saves the current configuration to the configuration file.
|
"""Saves the current configuration to the configuration file.
|
||||||
@ -436,74 +409,21 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
|
|||||||
if not self.config_file_path:
|
if not self.config_file_path:
|
||||||
raise ValueError("Configuration file path unknown.")
|
raise ValueError("Configuration file path unknown.")
|
||||||
with self.config_file_path.open("w", encoding=self.ENCODING) as f_out:
|
with self.config_file_path.open("w", encoding=self.ENCODING) as f_out:
|
||||||
try:
|
json_str = super().model_dump_json()
|
||||||
json_str = super().to_json()
|
f_out.write(json_str)
|
||||||
# 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
|
|
||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""Updates all configuration fields.
|
"""Updates all configuration fields.
|
||||||
|
|
||||||
This method updates all configuration fields using the following order for value retrieval:
|
This method updates all configuration fields using the following order for value retrieval:
|
||||||
1. Settings.
|
1. Current settings.
|
||||||
2. Environment variables.
|
2. Environment variables.
|
||||||
3. EOS configuration file.
|
3. EOS configuration file.
|
||||||
4. Current configuration.
|
4. Field default constants.
|
||||||
5. Field default constants.
|
|
||||||
|
|
||||||
The first non None value in priority order is taken.
|
The first non None value in priority order is taken.
|
||||||
"""
|
"""
|
||||||
self._update_data_folder_path()
|
self._setup(**self.model_dump())
|
||||||
for key in self.model_fields:
|
|
||||||
setattr(self, key, self._config_value(key))
|
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> ConfigEOS:
|
def get_config() -> ConfigEOS:
|
||||||
|
@ -4,10 +4,6 @@ from akkudoktoreos.core.pydantic import PydanticBaseModel
|
|||||||
|
|
||||||
|
|
||||||
class SettingsBaseModel(PydanticBaseModel):
|
class SettingsBaseModel(PydanticBaseModel):
|
||||||
"""Base model class for all settings configurations.
|
"""Base model class for all settings configurations."""
|
||||||
|
|
||||||
Note:
|
|
||||||
Settings property names shall be disjunctive to all existing settings' property names.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
@ -265,6 +265,12 @@ class SingletonMixin:
|
|||||||
class MySingletonModel(SingletonMixin, PydanticBaseModel):
|
class MySingletonModel(SingletonMixin, PydanticBaseModel):
|
||||||
name: str
|
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")
|
instance1 = MySingletonModel(name="Instance 1")
|
||||||
instance2 = MySingletonModel(name="Instance 2")
|
instance2 = MySingletonModel(name="Instance 2")
|
||||||
|
|
||||||
|
@ -1110,7 +1110,7 @@ class DataProvider(SingletonMixin, DataSequence):
|
|||||||
|
|
||||||
To be implemented by derived classes.
|
To be implemented by derived classes.
|
||||||
"""
|
"""
|
||||||
return self.provider_id() == self.config.abstract_provider
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
@ -1121,6 +1121,11 @@ class DataProvider(SingletonMixin, DataSequence):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
if hasattr(self, "_initialized"):
|
||||||
|
return
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def update_data(
|
def update_data(
|
||||||
self,
|
self,
|
||||||
force_enable: Optional[bool] = False,
|
force_enable: Optional[bool] = False,
|
||||||
@ -1595,6 +1600,11 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
|
|||||||
)
|
)
|
||||||
return list(key_set)
|
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:
|
def __getitem__(self, key: str) -> pd.Series:
|
||||||
"""Retrieve a Pandas Series for a specified key from the data in each DataProvider.
|
"""Retrieve a Pandas Series for a specified key from the data in each DataProvider.
|
||||||
|
|
||||||
|
@ -169,6 +169,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
|
|||||||
dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
|
dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
|
||||||
ev_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(
|
def set_parameters(
|
||||||
self,
|
self,
|
||||||
parameters: EnergieManagementSystemParameters,
|
parameters: EnergieManagementSystemParameters,
|
||||||
@ -193,9 +198,9 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
|
|||||||
self.ev = ev
|
self.ev = ev
|
||||||
self.home_appliance = home_appliance
|
self.home_appliance = home_appliance
|
||||||
self.inverter = inverter
|
self.inverter = inverter
|
||||||
self.ac_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_hours, 1.0)
|
self.dc_charge_hours = np.full(self.config.prediction.prediction_hours, 1.0)
|
||||||
self.ev_charge_hours = np.full(self.config.prediction_hours, 0.0)
|
self.ev_charge_hours = np.full(self.config.prediction.prediction_hours, 0.0)
|
||||||
|
|
||||||
def set_akku_discharge_hours(self, ds: np.ndarray) -> None:
|
def set_akku_discharge_hours(self, ds: np.ndarray) -> None:
|
||||||
if self.battery is not None:
|
if self.battery is not None:
|
||||||
@ -246,11 +251,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
|
|||||||
error_msg = "Start datetime unknown."
|
error_msg = "Start datetime unknown."
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise ValueError(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."
|
error_msg = "Prediction hours unknown."
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise ValueError(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."
|
error_msg = "Optimisation hours unknown."
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
@ -35,6 +35,21 @@ from pydantic import (
|
|||||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
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]):
|
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
|
||||||
"""Custom type adapter for Pendulum DateTime fields."""
|
"""Custom type adapter for Pendulum DateTime fields."""
|
||||||
|
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import numpy as np
|
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.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
from akkudoktoreos.devices.devicesabc import (
|
||||||
from akkudoktoreos.devices.devicesabc import DeviceBase
|
DeviceBase,
|
||||||
|
DeviceOptimizeResult,
|
||||||
|
DeviceParameters,
|
||||||
|
)
|
||||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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)
|
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."""
|
"""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(
|
capacity_wh: int = Field(
|
||||||
gt=0, description="An integer representing the capacity of the battery in watt-hours."
|
gt=0, description="An integer representing the capacity of the battery in watt-hours."
|
||||||
)
|
)
|
||||||
@ -68,15 +72,17 @@ class SolarPanelBatteryParameters(BaseBatteryParameters):
|
|||||||
class ElectricVehicleParameters(BaseBatteryParameters):
|
class ElectricVehicleParameters(BaseBatteryParameters):
|
||||||
"""Parameters specific to an electric vehicle (EV)."""
|
"""Parameters specific to an electric vehicle (EV)."""
|
||||||
|
|
||||||
|
device_id: str = Field(description="ID of electric vehicle")
|
||||||
discharging_efficiency: float = 1.0
|
discharging_efficiency: float = 1.0
|
||||||
initial_soc_percentage: int = initial_soc_percentage_field(
|
initial_soc_percentage: int = initial_soc_percentage_field(
|
||||||
"An integer representing the current state of charge (SOC) of the battery in percentage."
|
"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."""
|
"""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(
|
charge_array: list[float] = Field(
|
||||||
description="Hourly charging status (0 for no charging, 1 for charging)."
|
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)."
|
description="Hourly discharging status (0 for no discharging, 1 for discharging)."
|
||||||
)
|
)
|
||||||
discharging_efficiency: float = Field(description="The discharge efficiency as a float..")
|
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.")
|
capacity_wh: int = Field(description="Capacity of the EV’s battery in watt-hours.")
|
||||||
charging_efficiency: float = Field(description="Charging efficiency as a float..")
|
charging_efficiency: float = Field(description="Charging efficiency as a float..")
|
||||||
max_charge_power_w: int = Field(description="Maximum charging power in watts.")
|
max_charge_power_w: int = Field(description="Maximum charging power in watts.")
|
||||||
@ -103,81 +108,30 @@ class ElectricVehicleResult(BaseModel):
|
|||||||
class Battery(DeviceBase):
|
class Battery(DeviceBase):
|
||||||
"""Represents a battery device with methods to simulate energy charging and discharging."""
|
"""Represents a battery device with methods to simulate energy charging and discharging."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, parameters: Optional[BaseBatteryParameters] = None):
|
||||||
self,
|
self.parameters: Optional[BaseBatteryParameters] = None
|
||||||
parameters: Optional[BaseBatteryParameters] = None,
|
super().__init__(parameters)
|
||||||
hours: Optional[int] = 24,
|
|
||||||
provider_id: Optional[str] = None,
|
|
||||||
):
|
|
||||||
# Initialize configuration and parameters
|
|
||||||
self.provider_id = provider_id
|
|
||||||
self.prefix = "<invalid>"
|
|
||||||
if self.provider_id == "GenericBattery":
|
|
||||||
self.prefix = "battery"
|
|
||||||
elif self.provider_id == "GenericBEV":
|
|
||||||
self.prefix = "bev"
|
|
||||||
|
|
||||||
self.parameters = parameters
|
def _setup(self) -> None:
|
||||||
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:
|
|
||||||
"""Sets up the battery parameters based on configuration or provided parameters."""
|
"""Sets up the battery parameters based on configuration or provided parameters."""
|
||||||
if self.initialised:
|
assert self.parameters is not None
|
||||||
return
|
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:
|
# Only assign for storage battery
|
||||||
# Setup from configuration
|
self.min_soc_percentage = (
|
||||||
self.capacity_wh = getattr(self.config, f"{self.prefix}_capacity")
|
self.parameters.min_soc_percentage
|
||||||
self.initial_soc_percentage = getattr(self.config, f"{self.prefix}_initial_soc")
|
if isinstance(self.parameters, SolarPanelBatteryParameters)
|
||||||
self.hours = self.total_hours # TODO where does that come from?
|
else 0
|
||||||
self.charging_efficiency = getattr(self.config, f"{self.prefix}_charging_efficiency")
|
)
|
||||||
self.discharging_efficiency = getattr(
|
self.max_soc_percentage = self.parameters.max_soc_percentage
|
||||||
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)
|
|
||||||
|
|
||||||
# Initialize state of charge
|
# 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.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh
|
||||||
self.discharge_array = np.full(self.hours, 1)
|
self.discharge_array = np.full(self.hours, 1)
|
||||||
self.charge_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.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh
|
||||||
self.max_soc_wh = (self.max_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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Converts the object to a dictionary representation."""
|
"""Converts the object to a dictionary representation."""
|
||||||
return {
|
return {
|
||||||
|
"device_id": self.device_id,
|
||||||
"capacity_wh": self.capacity_wh,
|
"capacity_wh": self.capacity_wh,
|
||||||
"initial_soc_percentage": self.initial_soc_percentage,
|
"initial_soc_percentage": self.initial_soc_percentage,
|
||||||
"soc_wh": self.soc_wh,
|
"soc_wh": self.soc_wh,
|
||||||
|
@ -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.coreabc import SingletonMixin
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.devices.battery import Battery
|
from akkudoktoreos.devices.battery import Battery
|
||||||
from akkudoktoreos.devices.devicesabc import DevicesBase
|
from akkudoktoreos.devices.devicesabc import DevicesBase
|
||||||
from akkudoktoreos.devices.generic import HomeAppliance
|
from akkudoktoreos.devices.generic import HomeAppliance
|
||||||
from akkudoktoreos.devices.inverter import Inverter
|
from akkudoktoreos.devices.inverter import Inverter
|
||||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
from akkudoktoreos.devices.settings import DevicesCommonSettings
|
||||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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):
|
class Devices(SingletonMixin, DevicesBase):
|
||||||
# Results of the devices simulation and
|
def __init__(self, settings: Optional[DevicesCommonSettings] = None):
|
||||||
# insights into various parameters over the entire forecast period.
|
if hasattr(self, "_initialized"):
|
||||||
# -----------------------------------------------------------------
|
return
|
||||||
last_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
super().__init__()
|
||||||
default=None, description="The load in watt-hours per hour."
|
if settings is None:
|
||||||
)
|
settings = self.config.devices
|
||||||
eauto_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
if settings is None:
|
||||||
default=None, description="The state of charge of the EV for each hour."
|
return
|
||||||
)
|
|
||||||
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.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Computed fields
|
# initialize devices
|
||||||
@computed_field # type: ignore[prop-decorator]
|
if settings.batteries is not None:
|
||||||
@property
|
for battery_params in settings.batteries:
|
||||||
def total_balance_euro(self) -> float:
|
self.add_device(Battery(battery_params))
|
||||||
"""The total balance of revenues minus costs in euros."""
|
if settings.inverters is not None:
|
||||||
return self.total_revenues_euro - self.total_costs_euro
|
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]
|
self.post_setup()
|
||||||
@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)
|
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
def post_setup(self) -> None:
|
||||||
@property
|
for device in self.devices.values():
|
||||||
def total_costs_euro(self) -> float:
|
device.post_setup()
|
||||||
"""The total costs in euros."""
|
|
||||||
if self.kosten_euro_pro_stunde is None:
|
|
||||||
return 0
|
|
||||||
return np.nansum(self.kosten_euro_pro_stunde)
|
|
||||||
|
|
||||||
@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
|
# # Devices
|
||||||
# TODO: Make devices class a container of device simulation providers.
|
# # TODO: Make devices class a container of device simulation providers.
|
||||||
# Device simulations to be used are then enabled in the configuration.
|
# # Device simulations to be used are then enabled in the configuration.
|
||||||
battery: ClassVar[Battery] = Battery(provider_id="GenericBattery")
|
# battery: ClassVar[Battery] = Battery(provider_id="GenericBattery")
|
||||||
ev: ClassVar[Battery] = Battery(provider_id="GenericBEV")
|
# ev: ClassVar[Battery] = Battery(provider_id="GenericBEV")
|
||||||
home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher")
|
# home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher")
|
||||||
inverter: ClassVar[Inverter] = Inverter(
|
# inverter: ClassVar[Inverter] = Inverter(
|
||||||
self_consumption_predictor=SelfConsumptionProbabilityInterpolator,
|
# self_consumption_predictor=SelfConsumptionProbabilityInterpolator,
|
||||||
battery=battery,
|
# battery=battery,
|
||||||
provider_id="GenericInverter",
|
# provider_id="GenericInverter",
|
||||||
)
|
# )
|
||||||
|
#
|
||||||
def update_data(self) -> None:
|
# def update_data(self) -> None:
|
||||||
"""Update device simulation data."""
|
# """Update device simulation data."""
|
||||||
# Assure devices are set up
|
# # Assure devices are set up
|
||||||
self.battery.setup()
|
# self.battery.setup()
|
||||||
self.ev.setup()
|
# self.ev.setup()
|
||||||
self.home_appliance.setup()
|
# self.home_appliance.setup()
|
||||||
self.inverter.setup()
|
# self.inverter.setup()
|
||||||
|
#
|
||||||
# Pre-allocate arrays for the results, optimized for speed
|
# # Pre-allocate arrays for the results, optimized for speed
|
||||||
self.last_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
# 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_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.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.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan)
|
||||||
self.einnahmen_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.akku_soc_pro_stunde = np.full((self.total_hours), np.nan)
|
||||||
self.eauto_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.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
||||||
self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan)
|
# self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan)
|
||||||
|
#
|
||||||
# Set initial state
|
# # Set initial state
|
||||||
simulation_step = to_duration("1 hour")
|
# simulation_step = to_duration("1 hour")
|
||||||
if self.battery:
|
# if self.battery:
|
||||||
self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage()
|
# self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage()
|
||||||
if self.ev:
|
# if self.ev:
|
||||||
self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage()
|
# self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage()
|
||||||
|
#
|
||||||
# Get predictions for full device simulation time range
|
# # Get predictions for full device simulation time range
|
||||||
# gesamtlast[stunde]
|
# # gesamtlast[stunde]
|
||||||
load_total_mean = self.prediction.key_to_array(
|
# load_total_mean = self.prediction.key_to_array(
|
||||||
"load_total_mean",
|
# "load_total_mean",
|
||||||
start_datetime=self.start_datetime,
|
# start_datetime=self.start_datetime,
|
||||||
end_datetime=self.end_datetime,
|
# end_datetime=self.end_datetime,
|
||||||
interval=simulation_step,
|
# interval=simulation_step,
|
||||||
)
|
# )
|
||||||
# pv_prognose_wh[stunde]
|
# # pv_prognose_wh[stunde]
|
||||||
pvforecast_ac_power = self.prediction.key_to_array(
|
# pvforecast_ac_power = self.prediction.key_to_array(
|
||||||
"pvforecast_ac_power",
|
# "pvforecast_ac_power",
|
||||||
start_datetime=self.start_datetime,
|
# start_datetime=self.start_datetime,
|
||||||
end_datetime=self.end_datetime,
|
# end_datetime=self.end_datetime,
|
||||||
interval=simulation_step,
|
# interval=simulation_step,
|
||||||
)
|
# )
|
||||||
# strompreis_euro_pro_wh[stunde]
|
# # strompreis_euro_pro_wh[stunde]
|
||||||
elecprice_marketprice_wh = self.prediction.key_to_array(
|
# elecprice_marketprice_wh = self.prediction.key_to_array(
|
||||||
"elecprice_marketprice_wh",
|
# "elecprice_marketprice_wh",
|
||||||
start_datetime=self.start_datetime,
|
# start_datetime=self.start_datetime,
|
||||||
end_datetime=self.end_datetime,
|
# end_datetime=self.end_datetime,
|
||||||
interval=simulation_step,
|
# interval=simulation_step,
|
||||||
)
|
# )
|
||||||
# einspeiseverguetung_euro_pro_wh_arr[stunde]
|
# # einspeiseverguetung_euro_pro_wh_arr[stunde]
|
||||||
# TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr
|
# # TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr
|
||||||
einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078)
|
# einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078)
|
||||||
|
#
|
||||||
for stunde_since_now in range(0, self.total_hours):
|
# for stunde_since_now in range(0, self.total_hours):
|
||||||
hour = self.start_datetime.hour + stunde_since_now
|
# hour = self.start_datetime.hour + stunde_since_now
|
||||||
|
#
|
||||||
# Accumulate loads and PV generation
|
# # Accumulate loads and PV generation
|
||||||
consumption = load_total_mean[stunde_since_now]
|
# consumption = load_total_mean[stunde_since_now]
|
||||||
self.verluste_wh_pro_stunde[stunde_since_now] = 0.0
|
# self.verluste_wh_pro_stunde[stunde_since_now] = 0.0
|
||||||
|
#
|
||||||
# Home appliances
|
# # Home appliances
|
||||||
if self.home_appliance:
|
# if self.home_appliance:
|
||||||
ha_load = self.home_appliance.get_load_for_hour(hour)
|
# ha_load = self.home_appliance.get_load_for_hour(hour)
|
||||||
consumption += ha_load
|
# consumption += ha_load
|
||||||
self.home_appliance_wh_per_hour[stunde_since_now] = ha_load
|
# self.home_appliance_wh_per_hour[stunde_since_now] = ha_load
|
||||||
|
#
|
||||||
# E-Auto handling
|
# # E-Auto handling
|
||||||
if self.ev:
|
# if self.ev:
|
||||||
if self.ev_charge_hours[hour] > 0:
|
# if self.ev_charge_hours[hour] > 0:
|
||||||
geladene_menge_eauto, verluste_eauto = self.ev.charge_energy(
|
# geladene_menge_eauto, verluste_eauto = self.ev.charge_energy(
|
||||||
None, hour, relative_power=self.ev_charge_hours[hour]
|
# None, hour, relative_power=self.ev_charge_hours[hour]
|
||||||
)
|
# )
|
||||||
consumption += geladene_menge_eauto
|
# consumption += geladene_menge_eauto
|
||||||
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto
|
# self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto
|
||||||
self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage()
|
# self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage()
|
||||||
|
#
|
||||||
# Process inverter logic
|
# # Process inverter logic
|
||||||
grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0)
|
# grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0)
|
||||||
if self.battery:
|
# if self.battery:
|
||||||
self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour)
|
# self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour)
|
||||||
if self.inverter:
|
# if self.inverter:
|
||||||
generation = pvforecast_ac_power[hour]
|
# generation = pvforecast_ac_power[hour]
|
||||||
grid_export, grid_import, losses, self_consumption = self.inverter.process_energy(
|
# grid_export, grid_import, losses, self_consumption = self.inverter.process_energy(
|
||||||
generation, consumption, hour
|
# generation, consumption, hour
|
||||||
)
|
# )
|
||||||
|
#
|
||||||
# AC PV Battery Charge
|
# # AC PV Battery Charge
|
||||||
if self.battery and self.ac_charge_hours[hour] > 0.0:
|
# if self.battery and self.ac_charge_hours[hour] > 0.0:
|
||||||
self.battery.set_charge_allowed_for_hour(1, hour)
|
# self.battery.set_charge_allowed_for_hour(1, hour)
|
||||||
geladene_menge, verluste_wh = self.battery.charge_energy(
|
# geladene_menge, verluste_wh = self.battery.charge_energy(
|
||||||
None, hour, relative_power=self.ac_charge_hours[hour]
|
# None, hour, relative_power=self.ac_charge_hours[hour]
|
||||||
)
|
# )
|
||||||
# print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage())
|
# # print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage())
|
||||||
consumption += geladene_menge
|
# consumption += geladene_menge
|
||||||
grid_import += geladene_menge
|
# grid_import += geladene_menge
|
||||||
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh
|
# self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh
|
||||||
|
#
|
||||||
self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export
|
# self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export
|
||||||
self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import
|
# self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import
|
||||||
self.verluste_wh_pro_stunde[stunde_since_now] += losses
|
# self.verluste_wh_pro_stunde[stunde_since_now] += losses
|
||||||
self.last_wh_pro_stunde[stunde_since_now] = consumption
|
# self.last_wh_pro_stunde[stunde_since_now] = consumption
|
||||||
|
#
|
||||||
# Financial calculations
|
# # Financial calculations
|
||||||
self.kosten_euro_pro_stunde[stunde_since_now] = (
|
# self.kosten_euro_pro_stunde[stunde_since_now] = (
|
||||||
grid_import * self.strompreis_euro_pro_wh[hour]
|
# grid_import * self.strompreis_euro_pro_wh[hour]
|
||||||
)
|
# )
|
||||||
self.einnahmen_euro_pro_stunde[stunde_since_now] = (
|
# self.einnahmen_euro_pro_stunde[stunde_since_now] = (
|
||||||
grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour]
|
# grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour]
|
||||||
)
|
# )
|
||||||
|
#
|
||||||
# battery SOC tracking
|
# # battery SOC tracking
|
||||||
if self.battery:
|
# if self.battery:
|
||||||
self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage()
|
# self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage()
|
||||||
else:
|
# else:
|
||||||
self.akku_soc_pro_stunde[stunde_since_now] = 0.0
|
# self.akku_soc_pro_stunde[stunde_since_now] = 0.0
|
||||||
|
#
|
||||||
def report_dict(self) -> Dict[str, Any]:
|
# def report_dict(self) -> Dict[str, Any]:
|
||||||
"""Provides devices simulation output as a dictionary."""
|
# """Provides devices simulation output as a dictionary."""
|
||||||
out: Dict[str, Optional[Union[np.ndarray, float]]] = {
|
# out: Dict[str, Optional[Union[np.ndarray, float]]] = {
|
||||||
"Last_Wh_pro_Stunde": self.last_wh_pro_stunde,
|
# "Last_Wh_pro_Stunde": self.last_wh_pro_stunde,
|
||||||
"grid_export_Wh_pro_Stunde": self.grid_export_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,
|
# "grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde,
|
||||||
"Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde,
|
# "Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde,
|
||||||
"akku_soc_pro_stunde": self.akku_soc_pro_stunde,
|
# "akku_soc_pro_stunde": self.akku_soc_pro_stunde,
|
||||||
"Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde,
|
# "Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde,
|
||||||
"Gesamtbilanz_Euro": self.total_balance_euro,
|
# "Gesamtbilanz_Euro": self.total_balance_euro,
|
||||||
"EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde,
|
# "EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde,
|
||||||
"Gesamteinnahmen_Euro": self.total_revenues_euro,
|
# "Gesamteinnahmen_Euro": self.total_revenues_euro,
|
||||||
"Gesamtkosten_Euro": self.total_costs_euro,
|
# "Gesamtkosten_Euro": self.total_costs_euro,
|
||||||
"Verluste_Pro_Stunde": self.verluste_wh_pro_stunde,
|
# "Verluste_Pro_Stunde": self.verluste_wh_pro_stunde,
|
||||||
"Gesamt_Verluste": self.total_losses_wh,
|
# "Gesamt_Verluste": self.total_losses_wh,
|
||||||
"Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour,
|
# "Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour,
|
||||||
}
|
# }
|
||||||
return out
|
# return out
|
||||||
|
|
||||||
|
|
||||||
# Initialize the Devices simulation, it is a singleton.
|
# Initialize the Devices simulation, it is a singleton.
|
||||||
|
@ -1,22 +1,46 @@
|
|||||||
"""Abstract and base classes for devices."""
|
"""Abstract and base classes for devices."""
|
||||||
|
|
||||||
from typing import Optional
|
from enum import Enum
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
from pendulum import DateTime
|
from pendulum import DateTime
|
||||||
from pydantic import ConfigDict, computed_field
|
from pydantic import Field, computed_field
|
||||||
|
|
||||||
from akkudoktoreos.core.coreabc import (
|
from akkudoktoreos.core.coreabc import (
|
||||||
ConfigMixin,
|
ConfigMixin,
|
||||||
|
DevicesMixin,
|
||||||
EnergyManagementSystemMixin,
|
EnergyManagementSystemMixin,
|
||||||
PredictionMixin,
|
PredictionMixin,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.core.logging import get_logger
|
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
|
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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):
|
class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
|
||||||
"""A mixin to manage start, end datetimes for devices data.
|
"""A mixin to manage start, end datetimes for devices data.
|
||||||
|
|
||||||
@ -35,9 +59,9 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
|
|||||||
Returns:
|
Returns:
|
||||||
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
|
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(
|
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
|
dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@ -68,33 +92,92 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
|
|||||||
return int(duration.total_hours())
|
return int(duration.total_hours())
|
||||||
|
|
||||||
|
|
||||||
class DeviceBase(DevicesStartEndMixin, PredictionMixin):
|
class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin):
|
||||||
"""Base class for device simulations.
|
"""Base class for device simulations.
|
||||||
|
|
||||||
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
|
Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute
|
||||||
`prediction`).
|
`prediction`) and EOS device registry (attribute `devices`).
|
||||||
|
|
||||||
Note:
|
Behavior:
|
||||||
Validation on assignment of the Pydantic model is disabled to speed up simulation runs.
|
- 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.
|
def __init__(self, parameters: Optional[DeviceParameters] = None):
|
||||||
model_config = ConfigDict(
|
self.device_id: str = "<invalid>"
|
||||||
validate_assignment=False,
|
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.
|
"""Base class for handling device data.
|
||||||
|
|
||||||
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
|
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
|
||||||
`prediction`).
|
`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.
|
def __init__(self) -> None:
|
||||||
model_config = ConfigDict(
|
super().__init__()
|
||||||
validate_assignment=False,
|
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()
|
||||||
|
@ -4,13 +4,13 @@ import numpy as np
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
|
||||||
from akkudoktoreos.devices.devicesabc import DeviceBase
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HomeApplianceParameters(ParametersBaseModel):
|
class HomeApplianceParameters(DeviceParameters):
|
||||||
|
device_id: str = Field(description="ID of home appliance")
|
||||||
consumption_wh: int = Field(
|
consumption_wh: int = Field(
|
||||||
gt=0,
|
gt=0,
|
||||||
description="An integer representing the energy consumption of a household device in watt-hours.",
|
description="An integer representing the energy consumption of a household device in watt-hours.",
|
||||||
@ -25,46 +25,15 @@ class HomeAppliance(DeviceBase):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
parameters: Optional[HomeApplianceParameters] = None,
|
parameters: Optional[HomeApplianceParameters] = None,
|
||||||
hours: Optional[int] = 24,
|
|
||||||
provider_id: Optional[str] = None,
|
|
||||||
):
|
):
|
||||||
# Configuration initialisation
|
self.parameters: Optional[HomeApplianceParameters] = None
|
||||||
self.provider_id = provider_id
|
super().__init__(parameters)
|
||||||
self.prefix = "<invalid>"
|
|
||||||
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.initialised = False
|
def _setup(self) -> None:
|
||||||
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
|
assert self.parameters is not None
|
||||||
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)
|
|
||||||
self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros
|
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:
|
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.
|
"""Sets the start time of the device and generates the corresponding load curve.
|
||||||
|
@ -1,64 +1,44 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from scipy.interpolate import RegularGridInterpolator
|
|
||||||
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
|
||||||
from akkudoktoreos.devices.battery import Battery
|
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
|
||||||
from akkudoktoreos.devices.devicesabc import DeviceBase
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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)
|
max_power_wh: float = Field(gt=0)
|
||||||
|
battery: Optional[str] = Field(default=None, description="ID of battery")
|
||||||
|
|
||||||
|
|
||||||
class Inverter(DeviceBase):
|
class Inverter(DeviceBase):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
self_consumption_predictor: RegularGridInterpolator,
|
|
||||||
parameters: Optional[InverterParameters] = None,
|
parameters: Optional[InverterParameters] = None,
|
||||||
battery: Optional[Battery] = None,
|
|
||||||
provider_id: Optional[str] = None,
|
|
||||||
):
|
):
|
||||||
# Configuration initialisation
|
self.parameters: Optional[InverterParameters] = None
|
||||||
self.provider_id = provider_id
|
super().__init__(parameters)
|
||||||
self.prefix = "<invalid>"
|
|
||||||
if self.provider_id == "GenericInverter":
|
def _setup(self) -> None:
|
||||||
self.prefix = "inverter"
|
assert self.parameters is not None
|
||||||
# Parameter initialisiation
|
if self.parameters.battery is None:
|
||||||
self.parameters = parameters
|
|
||||||
if battery is None:
|
|
||||||
# For the moment raise exception
|
# For the moment raise exception
|
||||||
# TODO: Make battery configurable by config
|
# TODO: Make battery configurable by config
|
||||||
error_msg = "Battery for PV inverter is mandatory."
|
error_msg = "Battery for PV inverter is mandatory."
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise NotImplementedError(error_msg)
|
raise NotImplementedError(error_msg)
|
||||||
self.battery = battery # Connection to a battery object
|
self.self_consumption_predictor = get_eos_load_interpolator()
|
||||||
self.self_consumption_predictor = self_consumption_predictor
|
self.max_power_wh = (
|
||||||
|
self.parameters.max_power_wh
|
||||||
|
) # Maximum power that the inverter can handle
|
||||||
|
|
||||||
self.initialised = False
|
def _post_setup(self) -> None:
|
||||||
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
|
assert self.parameters is not None
|
||||||
if self.parameters is not None:
|
self.battery = self.devices.get_device_by_id(self.parameters.battery)
|
||||||
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 process_energy(
|
def process_energy(
|
||||||
self, generation: float, consumption: float, hour: int
|
self, generation: float, consumption: float, hour: int
|
||||||
|
25
src/akkudoktoreos/devices/settings.py
Normal file
25
src/akkudoktoreos/devices/settings.py
Normal file
@ -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"
|
||||||
|
)
|
@ -106,6 +106,11 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
|||||||
"measurement_load",
|
"measurement_load",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
if hasattr(self, "_initialized"):
|
||||||
|
return
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def _interval_count(
|
def _interval_count(
|
||||||
self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration
|
self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration
|
||||||
) -> int:
|
) -> int:
|
||||||
@ -143,11 +148,16 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
|||||||
if topic not in self.topics:
|
if topic not in self.topics:
|
||||||
return None
|
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
|
key = None
|
||||||
if topic == "measurement_load":
|
if topic == "measurement_load":
|
||||||
for config_key in topic_keys:
|
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"
|
key = topic + config_key[len(topic) : len(topic) + 1] + "_mr"
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@ -25,7 +24,6 @@ from akkudoktoreos.devices.battery import (
|
|||||||
)
|
)
|
||||||
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
||||||
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
||||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
|
||||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@ -112,8 +110,12 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
|||||||
):
|
):
|
||||||
"""Initialize the optimization problem with the required parameters."""
|
"""Initialize the optimization problem with the required parameters."""
|
||||||
self.opti_param: dict[str, Any] = {}
|
self.opti_param: dict[str, Any] = {}
|
||||||
self.fixed_eauto_hours = self.config.prediction_hours - self.config.optimization_hours
|
self.fixed_eauto_hours = (
|
||||||
self.possible_charge_values = self.config.optimization_ev_available_charge_rates_percent
|
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.verbose = verbose
|
||||||
self.fix_seed = fixed_seed
|
self.fix_seed = fixed_seed
|
||||||
self.optimize_ev = True
|
self.optimize_ev = True
|
||||||
@ -180,25 +182,27 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
|||||||
total_states = 3 * len_ac
|
total_states = 3 * len_ac
|
||||||
|
|
||||||
# 1. Mutating the charge_discharge part
|
# 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)
|
(charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part)
|
||||||
|
|
||||||
# Instead of a fixed clamping to 0..8 or 0..6 dynamically:
|
# Instead of a fixed clamping to 0..8 or 0..6 dynamically:
|
||||||
charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1)
|
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
|
# 2. Mutating the EV charge part, if active
|
||||||
if self.optimize_ev:
|
if self.optimize_ev:
|
||||||
ev_charge_part = individual[
|
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.toolbox.mutate_ev_charge_index(ev_charge_part)
|
||||||
ev_charge_part_mutated[self.config.prediction_hours - self.fixed_eauto_hours :] = [
|
ev_charge_part_mutated[
|
||||||
0
|
self.config.prediction.prediction_hours - self.fixed_eauto_hours :
|
||||||
] * self.fixed_eauto_hours
|
] = [0] * self.fixed_eauto_hours
|
||||||
individual[self.config.prediction_hours : self.config.prediction_hours * 2] = (
|
individual[
|
||||||
ev_charge_part_mutated
|
self.config.prediction.prediction_hours : self.config.prediction.prediction_hours
|
||||||
)
|
* 2
|
||||||
|
] = ev_charge_part_mutated
|
||||||
|
|
||||||
# 3. Mutating the appliance start time, if applicable
|
# 3. Mutating the appliance start time, if applicable
|
||||||
if self.opti_param["home_appliance"] > 0:
|
if self.opti_param["home_appliance"] > 0:
|
||||||
@ -212,13 +216,15 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
|||||||
def create_individual(self) -> list[int]:
|
def create_individual(self) -> list[int]:
|
||||||
# Start with discharge states for the individual
|
# Start with discharge states for the individual
|
||||||
individual_components = [
|
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
|
# Add EV charge index values if optimize_ev is True
|
||||||
if self.optimize_ev:
|
if self.optimize_ev:
|
||||||
individual_components += [
|
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
|
# 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())
|
individual.extend(eautocharge_hours_index.tolist())
|
||||||
elif self.optimize_ev:
|
elif self.optimize_ev:
|
||||||
# Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu
|
# 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
|
# Add dishwasher start time if applicable
|
||||||
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None:
|
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).
|
3. Dishwasher start time (integer if applicable).
|
||||||
"""
|
"""
|
||||||
# Discharge hours as a NumPy array of ints
|
# 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)
|
# EV charge hours as a NumPy array of ints (if optimize_ev is True)
|
||||||
eautocharge_hours_index = (
|
eautocharge_hours_index = (
|
||||||
np.array(
|
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,
|
dtype=int,
|
||||||
)
|
)
|
||||||
if self.optimize_ev
|
if self.optimize_ev
|
||||||
@ -390,7 +401,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
|||||||
)
|
)
|
||||||
self.ems.set_ev_charge_hours(eautocharge_hours_float)
|
self.ems.set_ev_charge_hours(eautocharge_hours_float)
|
||||||
else:
|
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)
|
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)
|
# min_length = min(battery_soc_per_hour.size, discharge_hours_bin.size)
|
||||||
# battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:]
|
# battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:]
|
||||||
# discharge_hours_bin_tail = discharge_hours_bin[-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
|
# # # Find hours where battery SoC is 0
|
||||||
# # zero_soc_mask = battery_soc_per_hour_tail == 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
|
if parameters.eauto and self.ems.ev
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
* self.config.optimization_penalty,
|
* self.config.optimization.optimization_penalty,
|
||||||
)
|
)
|
||||||
|
|
||||||
return (gesamtbilanz,)
|
return (gesamtbilanz,)
|
||||||
@ -569,30 +580,26 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
|||||||
start_hour = self.ems.start_datetime.hour
|
start_hour = self.ems.start_datetime.hour
|
||||||
|
|
||||||
einspeiseverguetung_euro_pro_wh = np.full(
|
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
|
# TODO: Refactor device setup phase out
|
||||||
sc = SelfConsumptionProbabilityInterpolator(
|
self.devices.reset()
|
||||||
Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize PV and EV batteries
|
# Initialize PV and EV batteries
|
||||||
akku: Optional[Battery] = None
|
akku: Optional[Battery] = None
|
||||||
if parameters.pv_akku:
|
if parameters.pv_akku:
|
||||||
akku = Battery(
|
akku = Battery(parameters.pv_akku)
|
||||||
parameters.pv_akku,
|
self.devices.add_device(akku)
|
||||||
hours=self.config.prediction_hours,
|
akku.set_charge_per_hour(np.full(self.config.prediction.prediction_hours, 1))
|
||||||
)
|
|
||||||
akku.set_charge_per_hour(np.full(self.config.prediction_hours, 1))
|
|
||||||
|
|
||||||
eauto: Optional[Battery] = None
|
eauto: Optional[Battery] = None
|
||||||
if parameters.eauto:
|
if parameters.eauto:
|
||||||
eauto = Battery(
|
eauto = Battery(
|
||||||
parameters.eauto,
|
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 = (
|
self.optimize_ev = (
|
||||||
parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0
|
parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0
|
||||||
)
|
)
|
||||||
@ -603,20 +610,22 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
|||||||
dishwasher = (
|
dishwasher = (
|
||||||
HomeAppliance(
|
HomeAppliance(
|
||||||
parameters=parameters.dishwasher,
|
parameters=parameters.dishwasher,
|
||||||
hours=self.config.prediction_hours,
|
|
||||||
)
|
)
|
||||||
if parameters.dishwasher is not None
|
if parameters.dishwasher is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
self.devices.add_device(dishwasher)
|
||||||
|
|
||||||
# Initialize the inverter and energy management system
|
# Initialize the inverter and energy management system
|
||||||
inverter: Optional[Inverter] = None
|
inverter: Optional[Inverter] = None
|
||||||
if parameters.inverter:
|
if parameters.inverter:
|
||||||
inverter = Inverter(
|
inverter = Inverter(
|
||||||
sc,
|
|
||||||
parameters.inverter,
|
parameters.inverter,
|
||||||
akku,
|
|
||||||
)
|
)
|
||||||
|
self.devices.add_device(inverter)
|
||||||
|
|
||||||
|
self.devices.post_setup()
|
||||||
|
|
||||||
self.ems.set_parameters(
|
self.ems.set_parameters(
|
||||||
parameters.ems,
|
parameters.ems,
|
||||||
inverter=inverter,
|
inverter=inverter,
|
||||||
|
@ -16,7 +16,7 @@ class OptimizationCommonSettings(SettingsBaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
optimization_hours: Optional[int] = Field(
|
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(
|
optimization_penalty: Optional[int] = Field(
|
||||||
|
@ -3,6 +3,7 @@ from typing import Optional
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
|
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
||||||
|
|
||||||
|
|
||||||
class ElecPriceCommonSettings(SettingsBaseModel):
|
class ElecPriceCommonSettings(SettingsBaseModel):
|
||||||
@ -12,3 +13,5 @@ class ElecPriceCommonSettings(SettingsBaseModel):
|
|||||||
elecprice_charges_kwh: Optional[float] = Field(
|
elecprice_charges_kwh: Optional[float] = Field(
|
||||||
default=None, ge=0, description="Electricity price charges (€/kWh)."
|
default=None, ge=0, description="Electricity price charges (€/kWh)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
provider_settings: Optional[ElecPriceImportCommonSettings] = None
|
||||||
|
@ -71,4 +71,4 @@ class ElecPriceProvider(PredictionProvider):
|
|||||||
return "ElecPriceProvider"
|
return "ElecPriceProvider"
|
||||||
|
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
return self.provider_id() == self.config.elecprice_provider
|
return self.provider_id() == self.config.elecprice.elecprice_provider
|
||||||
|
@ -108,13 +108,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
|||||||
# Try to take data from 5 weeks back for prediction
|
# 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")
|
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")
|
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)
|
response = requests.get(url)
|
||||||
logger.debug(f"Response from {url}: {response}")
|
logger.debug(f"Response from {url}: {response}")
|
||||||
response.raise_for_status() # Raise an error for bad responses
|
response.raise_for_status() # Raise an error for bad responses
|
||||||
akkudoktor_data = self._validate_data(response.content)
|
akkudoktor_data = self._validate_data(response.content)
|
||||||
# We are working on fresh data (no cache), report update time
|
# 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
|
return akkudoktor_data
|
||||||
|
|
||||||
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
|
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.
|
# in ascending order and have the same timestamps.
|
||||||
|
|
||||||
# Get elecprice_charges_kwh in wh
|
# 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.
|
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
|
series_data = pd.Series(dtype=float) # Initialize an empty series
|
||||||
|
|
||||||
for value in akkudoktor_data.values:
|
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:
|
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
|
||||||
highest_orig_datetime = 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
|
# 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(
|
needed_prediction_hours = int(
|
||||||
self.config.prediction_hours
|
self.config.prediction.prediction_hours
|
||||||
- ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
|
- ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
|
||||||
)
|
)
|
||||||
|
|
||||||
if needed_prediction_hours <= 0:
|
if needed_prediction_hours <= 0:
|
||||||
logger.warning(
|
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}"
|
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_hours in the records
|
) # this might keep data longer than self.start_datetime + self.config.prediction.prediction_hours in the records
|
||||||
return
|
return
|
||||||
|
|
||||||
if amount_datasets > 800: # we do the full ets with seasons of 1 week
|
if amount_datasets > 800: # we do the full ets with seasons of 1 week
|
||||||
|
@ -62,7 +62,12 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
|
|||||||
return "ElecPriceImport"
|
return "ElecPriceImport"
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
if self.config.elecpriceimport_file_path is not None:
|
if self.config.elecprice.provider_settings.elecpriceimport_file_path is not None:
|
||||||
self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice")
|
self.import_from_file(
|
||||||
if self.config.elecpriceimport_json is not None:
|
self.config.elecprice.provider_settings.elecpriceimport_file_path,
|
||||||
self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice")
|
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"
|
||||||
|
)
|
||||||
|
@ -6,6 +6,8 @@ from pathlib import Path
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.interpolate import RegularGridInterpolator
|
from scipy.interpolate import RegularGridInterpolator
|
||||||
|
|
||||||
|
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||||
|
|
||||||
|
|
||||||
class SelfConsumptionProbabilityInterpolator:
|
class SelfConsumptionProbabilityInterpolator:
|
||||||
def __init__(self, filepath: str | Path):
|
def __init__(self, filepath: str | Path):
|
||||||
@ -67,5 +69,17 @@ class SelfConsumptionProbabilityInterpolator:
|
|||||||
# return self_consumption_rate
|
# return self_consumption_rate
|
||||||
|
|
||||||
|
|
||||||
# Test the function
|
class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin):
|
||||||
# print(calculate_self_consumption(1000, 1200))
|
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
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
"""Load forecast module for load predictions."""
|
"""Load forecast module for load predictions."""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
|
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||||
|
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@ -16,3 +18,7 @@ class LoadCommonSettings(SettingsBaseModel):
|
|||||||
load_provider: Optional[str] = Field(
|
load_provider: Optional[str] = Field(
|
||||||
default=None, description="Load provider id of provider to be used."
|
default=None, description="Load provider id of provider to be used."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
|
||||||
|
None
|
||||||
|
)
|
||||||
|
@ -58,4 +58,4 @@ class LoadProvider(PredictionProvider):
|
|||||||
return "LoadProvider"
|
return "LoadProvider"
|
||||||
|
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
return self.provider_id() == self.config.load_provider
|
return self.provider_id() == self.config.load.load_provider
|
||||||
|
@ -91,7 +91,9 @@ class LoadAkkudoktor(LoadProvider):
|
|||||||
list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"]))
|
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
|
# 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:
|
except FileNotFoundError:
|
||||||
error_msg = f"Error: File {load_file} not found."
|
error_msg = f"Error: File {load_file} not found."
|
||||||
logger.error(error_msg)
|
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.
|
# We provide prediction starting at start of day, to be compatible to old system.
|
||||||
# End date for prediction is prediction hours from now.
|
# End date for prediction is prediction hours from now.
|
||||||
date = self.start_datetime.start_of("day")
|
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:
|
while compare_datetimes(date, end_date).lt:
|
||||||
# Extract mean (index 0) and standard deviation (index 1) for the given day and hour
|
# Extract mean (index 0) and standard deviation (index 1) for the given day and hour
|
||||||
# Day indexing starts at 0, -1 because of that
|
# Day indexing starts at 0, -1 because of that
|
||||||
@ -127,4 +129,4 @@ class LoadAkkudoktor(LoadProvider):
|
|||||||
self.update_value(date, values)
|
self.update_value(date, values)
|
||||||
date += to_duration("1 hour")
|
date += to_duration("1 hour")
|
||||||
# We are working on fresh data (no cache), report update time
|
# 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)
|
||||||
|
@ -58,7 +58,11 @@ class LoadImport(LoadProvider, PredictionImportProvider):
|
|||||||
return "LoadImport"
|
return "LoadImport"
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
if self.config.load_import_file_path is not None:
|
if self.config.load.provider_settings.load_import_file_path is not None:
|
||||||
self.import_from_file(self.config.load_import_file_path, key_prefix="load")
|
self.import_from_file(
|
||||||
if self.config.load_import_json is not None:
|
self.config.provider_settings.load_import_file_path, key_prefix="load"
|
||||||
self.import_from_json(self.config.load_import_json, 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"
|
||||||
|
)
|
||||||
|
@ -80,13 +80,13 @@ class PredictionCommonSettings(SettingsBaseModel):
|
|||||||
description="Number of hours into the past for historical predictions data",
|
description="Number of hours into the past for historical predictions data",
|
||||||
)
|
)
|
||||||
latitude: Optional[float] = Field(
|
latitude: Optional[float] = Field(
|
||||||
default=None,
|
default=52.52,
|
||||||
ge=-90.0,
|
ge=-90.0,
|
||||||
le=90.0,
|
le=90.0,
|
||||||
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
|
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
|
||||||
)
|
)
|
||||||
longitude: Optional[float] = Field(
|
longitude: Optional[float] = Field(
|
||||||
default=None,
|
default=13.405,
|
||||||
ge=-180.0,
|
ge=-180.0,
|
||||||
le=180.0,
|
le=180.0,
|
||||||
description="Longitude in decimal degrees, within -180 to 180 (°)",
|
description="Longitude in decimal degrees, within -180 to 180 (°)",
|
||||||
|
@ -121,9 +121,9 @@ class PredictionStartEndKeepMixin(PredictionBase):
|
|||||||
Returns:
|
Returns:
|
||||||
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
|
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(
|
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
|
dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours
|
||||||
logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}")
|
logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}")
|
||||||
@ -147,10 +147,10 @@ class PredictionStartEndKeepMixin(PredictionBase):
|
|||||||
return None
|
return None
|
||||||
historic_hours = self.historic_hours_min()
|
historic_hours = self.historic_hours_min()
|
||||||
if (
|
if (
|
||||||
self.config.prediction_historic_hours
|
self.config.prediction.prediction_historic_hours
|
||||||
and self.config.prediction_historic_hours > 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")
|
return self.start_datetime - to_duration(f"{historic_hours} hours")
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@ -6,6 +6,7 @@ from pydantic import Field, computed_field
|
|||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
|
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@ -260,7 +261,7 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
|||||||
default=None, description="Nominal power of PV system in kW."
|
default=None, description="Nominal power of PV system in kW."
|
||||||
)
|
)
|
||||||
pvforecast4_pvtechchoice: Optional[str] = Field(
|
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(
|
pvforecast4_mountingplace: Optional[str] = Field(
|
||||||
default="free",
|
default="free",
|
||||||
@ -316,7 +317,7 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
|||||||
default=None, description="Nominal power of PV system in kW."
|
default=None, description="Nominal power of PV system in kW."
|
||||||
)
|
)
|
||||||
pvforecast5_pvtechchoice: Optional[str] = Field(
|
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(
|
pvforecast5_mountingplace: Optional[str] = Field(
|
||||||
default="free",
|
default="free",
|
||||||
@ -359,6 +360,8 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
|||||||
|
|
||||||
pvforecast_max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set
|
pvforecast_max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set
|
||||||
|
|
||||||
|
provider_settings: Optional[PVForecastImportCommonSettings] = None
|
||||||
|
|
||||||
# Computed fields
|
# Computed fields
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
@property
|
@property
|
||||||
|
@ -54,6 +54,6 @@ class PVForecastProvider(PredictionProvider):
|
|||||||
|
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
logger.debug(
|
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
|
||||||
|
@ -203,19 +203,23 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
|||||||
"""Build akkudoktor.net API request URL."""
|
"""Build akkudoktor.net API request URL."""
|
||||||
base_url = "https://api.akkudoktor.net/forecast"
|
base_url = "https://api.akkudoktor.net/forecast"
|
||||||
query_params = [
|
query_params = [
|
||||||
f"lat={self.config.latitude}",
|
f"lat={self.config.prediction.latitude}",
|
||||||
f"lon={self.config.longitude}",
|
f"lon={self.config.prediction.longitude}",
|
||||||
]
|
]
|
||||||
|
|
||||||
for i in range(len(self.config.pvforecast_planes)):
|
for i in range(len(self.config.pvforecast.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])}")
|
|
||||||
query_params.append(
|
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(
|
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}")
|
query_params.append(f"horizont={horizon_values}")
|
||||||
|
|
||||||
@ -226,7 +230,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
|||||||
"cellCoEff=-0.36",
|
"cellCoEff=-0.36",
|
||||||
"inverterEfficiency=0.8",
|
"inverterEfficiency=0.8",
|
||||||
"albedo=0.25",
|
"albedo=0.25",
|
||||||
f"timezone={self.config.timezone}",
|
f"timezone={self.config.prediction.timezone}",
|
||||||
"hourly=relativehumidity_2m%2Cwindspeed_10m",
|
"hourly=relativehumidity_2m%2Cwindspeed_10m",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -255,7 +259,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
|||||||
logger.debug(f"Response from {self._url()}: {response}")
|
logger.debug(f"Response from {self._url()}: {response}")
|
||||||
akkudoktor_data = self._validate_data(response.content)
|
akkudoktor_data = self._validate_data(response.content)
|
||||||
# We are working on fresh data (no cache), report update time
|
# 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
|
return akkudoktor_data
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
@ -265,7 +269,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
|||||||
`PVForecastAkkudoktorDataRecord`.
|
`PVForecastAkkudoktorDataRecord`.
|
||||||
"""
|
"""
|
||||||
# Assure we have something to request PV power for.
|
# 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
|
# No planes for PV
|
||||||
error_msg = "Requested PV forecast, but no planes configured."
|
error_msg = "Requested PV forecast, but no planes configured."
|
||||||
logger.error(f"Configuration error: {error_msg}")
|
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
|
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
|
||||||
|
|
||||||
# Timezone of the PV system
|
# Timezone of the PV system
|
||||||
if self.config.timezone != akkudoktor_data.meta.timezone:
|
if self.config.prediction.timezone != akkudoktor_data.meta.timezone:
|
||||||
error_msg = f"Configured timezone '{self.config.timezone}' does not match Akkudoktor 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}")
|
logger.error(f"Akkudoktor schema change: {error_msg}")
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
# Assumption that all lists are the same length and are ordered chronologically
|
# Assumption that all lists are the same length and are ordered chronologically
|
||||||
# in ascending order and have the same timestamps.
|
# 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
|
# Expect one value set per prediction hour
|
||||||
error_msg = (
|
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."
|
f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data."
|
||||||
)
|
)
|
||||||
logger.error(f"Akkudoktor schema change: {error_msg}")
|
logger.error(f"Akkudoktor schema change: {error_msg}")
|
||||||
@ -296,7 +300,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
|||||||
# Iterate over forecast data points
|
# Iterate over forecast data points
|
||||||
for forecast_values in zip(*akkudoktor_data.values):
|
for forecast_values in zip(*akkudoktor_data.values):
|
||||||
original_datetime = forecast_values[0].datetime
|
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
|
# Skip outdated forecast data
|
||||||
if compare_datetimes(dt, self.start_datetime.start_of("day")).lt:
|
if compare_datetimes(dt, self.start_datetime.start_of("day")).lt:
|
||||||
@ -314,9 +318,9 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
|||||||
|
|
||||||
self.update_value(dt, data)
|
self.update_value(dt, data)
|
||||||
|
|
||||||
if len(self) < self.config.prediction_hours:
|
if len(self) < self.config.prediction.prediction_hours:
|
||||||
raise ValueError(
|
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"but only {len(self)} hours starting from {self.start_datetime} "
|
||||||
f"were predicted."
|
f"were predicted."
|
||||||
)
|
)
|
||||||
@ -365,31 +369,35 @@ if __name__ == "__main__":
|
|||||||
"""
|
"""
|
||||||
# Set up the configuration with necessary fields for URL generation
|
# Set up the configuration with necessary fields for URL generation
|
||||||
settings_data = {
|
settings_data = {
|
||||||
"prediction_hours": 48,
|
"prediction": {
|
||||||
"prediction_historic_hours": 24,
|
"prediction_hours": 48,
|
||||||
"latitude": 52.52,
|
"prediction_historic_hours": 24,
|
||||||
"longitude": 13.405,
|
"latitude": 52.52,
|
||||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
"longitude": 13.405,
|
||||||
"pvforecast0_peakpower": 5.0,
|
},
|
||||||
"pvforecast0_surface_azimuth": -10,
|
"pvforecast": {
|
||||||
"pvforecast0_surface_tilt": 7,
|
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
"pvforecast0_peakpower": 5.0,
|
||||||
"pvforecast0_inverter_paco": 10000,
|
"pvforecast0_surface_azimuth": -10,
|
||||||
"pvforecast1_peakpower": 4.8,
|
"pvforecast0_surface_tilt": 7,
|
||||||
"pvforecast1_surface_azimuth": -90,
|
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||||
"pvforecast1_surface_tilt": 7,
|
"pvforecast0_inverter_paco": 10000,
|
||||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
"pvforecast1_peakpower": 4.8,
|
||||||
"pvforecast1_inverter_paco": 10000,
|
"pvforecast1_surface_azimuth": -90,
|
||||||
"pvforecast2_peakpower": 1.4,
|
"pvforecast1_surface_tilt": 7,
|
||||||
"pvforecast2_surface_azimuth": -40,
|
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||||
"pvforecast2_surface_tilt": 60,
|
"pvforecast1_inverter_paco": 10000,
|
||||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
"pvforecast2_peakpower": 1.4,
|
||||||
"pvforecast2_inverter_paco": 2000,
|
"pvforecast2_surface_azimuth": -40,
|
||||||
"pvforecast3_peakpower": 1.6,
|
"pvforecast2_surface_tilt": 60,
|
||||||
"pvforecast3_surface_azimuth": 5,
|
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||||
"pvforecast3_surface_tilt": 45,
|
"pvforecast2_inverter_paco": 2000,
|
||||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
"pvforecast3_peakpower": 1.6,
|
||||||
"pvforecast3_inverter_paco": 1400,
|
"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
|
# Initialize the forecast object with the generated configuration
|
||||||
|
@ -62,7 +62,13 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider):
|
|||||||
return "PVForecastImport"
|
return "PVForecastImport"
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
if self.config.pvforecastimport_file_path is not None:
|
if self.config.pvforecast.provider_settings.pvforecastimport_file_path is not None:
|
||||||
self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast")
|
self.import_from_file(
|
||||||
if self.config.pvforecastimport_json is not None:
|
self.config.pvforecast.provider_settings.pvforecastimport_file_path,
|
||||||
self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast")
|
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",
|
||||||
|
)
|
||||||
|
@ -5,9 +5,12 @@ from typing import Optional
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
|
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
|
||||||
|
|
||||||
|
|
||||||
class WeatherCommonSettings(SettingsBaseModel):
|
class WeatherCommonSettings(SettingsBaseModel):
|
||||||
weather_provider: Optional[str] = Field(
|
weather_provider: Optional[str] = Field(
|
||||||
default=None, description="Weather provider id of provider to be used."
|
default=None, description="Weather provider id of provider to be used."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
provider_settings: Optional[WeatherImportCommonSettings] = None
|
||||||
|
@ -126,7 +126,7 @@ class WeatherProvider(PredictionProvider):
|
|||||||
return "WeatherProvider"
|
return "WeatherProvider"
|
||||||
|
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
return self.provider_id() == self.config.weather_provider
|
return self.provider_id() == self.config.weather.weather_provider
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def estimate_irradiance_from_cloud_cover(
|
def estimate_irradiance_from_cloud_cover(
|
||||||
|
@ -99,7 +99,7 @@ class WeatherBrightSky(WeatherProvider):
|
|||||||
date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD")
|
date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD")
|
||||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||||
response = requests.get(
|
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
|
response.raise_for_status() # Raise an error for bad responses
|
||||||
logger.debug(f"Response from {source}: {response}")
|
logger.debug(f"Response from {source}: {response}")
|
||||||
@ -109,7 +109,7 @@ class WeatherBrightSky(WeatherProvider):
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
# We are working on fresh data (no cache), report update time
|
# 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
|
return brightsky_data
|
||||||
|
|
||||||
def _description_to_series(self, description: str) -> pd.Series:
|
def _description_to_series(self, description: str) -> pd.Series:
|
||||||
@ -200,7 +200,7 @@ class WeatherBrightSky(WeatherProvider):
|
|||||||
description = "Total Clouds (% Sky Obscured)"
|
description = "Total Clouds (% Sky Obscured)"
|
||||||
cloud_cover = self._description_to_series(description)
|
cloud_cover = self._description_to_series(description)
|
||||||
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
|
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)"
|
description = "Global Horizontal Irradiance (W/m2)"
|
||||||
|
@ -91,13 +91,13 @@ class WeatherClearOutside(WeatherProvider):
|
|||||||
response: Weather forecast request reponse from ClearOutside.
|
response: Weather forecast request reponse from ClearOutside.
|
||||||
"""
|
"""
|
||||||
source = "https://clearoutside.com/forecast"
|
source = "https://clearoutside.com/forecast"
|
||||||
latitude = round(self.config.latitude, 2)
|
latitude = round(self.config.prediction.latitude, 2)
|
||||||
longitude = round(self.config.longitude, 2)
|
longitude = round(self.config.prediction.longitude, 2)
|
||||||
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
|
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
|
||||||
response.raise_for_status() # Raise an error for bad responses
|
response.raise_for_status() # Raise an error for bad responses
|
||||||
logger.debug(f"Response from {source}: {response}")
|
logger.debug(f"Response from {source}: {response}")
|
||||||
# We are working on fresh data (no cache), report update time
|
# 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
|
return response
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = None) -> None:
|
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"]
|
data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"]
|
||||||
)
|
)
|
||||||
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
|
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
|
# Add GHI, DNI, DHI to clearout data
|
||||||
|
@ -59,7 +59,11 @@ class WeatherImport(WeatherProvider, PredictionImportProvider):
|
|||||||
return "WeatherImport"
|
return "WeatherImport"
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
if self.config.weatherimport_file_path is not None:
|
if self.config.weather.provider_settings.weatherimport_file_path is not None:
|
||||||
self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather")
|
self.import_from_file(
|
||||||
if self.config.weatherimport_json is not None:
|
self.config.weather.provider_settings.weatherimport_file_path, key_prefix="weather"
|
||||||
self.import_from_json(self.config.weatherimport_json, 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"
|
||||||
|
)
|
||||||
|
@ -29,7 +29,10 @@ from akkudoktoreos.optimization.genetic import (
|
|||||||
OptimizeResponse,
|
OptimizeResponse,
|
||||||
optimization_problem,
|
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
|
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@ -149,16 +152,16 @@ def start_eosdash() -> subprocess.Popen:
|
|||||||
|
|
||||||
if args is None:
|
if args is None:
|
||||||
# No command line arguments
|
# No command line arguments
|
||||||
host = config_eos.server_eosdash_host
|
host = config_eos.server.server_eosdash_host
|
||||||
port = config_eos.server_eosdash_port
|
port = config_eos.server.server_eosdash_port
|
||||||
eos_host = config_eos.server_eos_host
|
eos_host = config_eos.server.server_eos_host
|
||||||
eos_port = config_eos.server_eos_port
|
eos_port = config_eos.server.server_eos_port
|
||||||
log_level = "info"
|
log_level = "info"
|
||||||
access_log = False
|
access_log = False
|
||||||
reload = False
|
reload = False
|
||||||
else:
|
else:
|
||||||
host = args.host
|
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_host = args.host
|
||||||
eos_port = args.port
|
eos_port = args.port
|
||||||
log_level = args.log_level
|
log_level = args.log_level
|
||||||
@ -201,7 +204,7 @@ def start_eosdash() -> subprocess.Popen:
|
|||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Lifespan manager for the app."""
|
"""Lifespan manager for the app."""
|
||||||
# On startup
|
# On startup
|
||||||
if config_eos.server_eos_startup_eosdash:
|
if config_eos.server.server_eos_startup_eosdash:
|
||||||
try:
|
try:
|
||||||
eosdash_process = start_eosdash()
|
eosdash_process = start_eosdash()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -228,7 +231,7 @@ app = FastAPI(
|
|||||||
|
|
||||||
|
|
||||||
# That's the problem
|
# 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()
|
server_dir = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
@ -340,7 +343,7 @@ def fastapi_config_put(
|
|||||||
configuration (ConfigEOS): The current configuration after the write.
|
configuration (ConfigEOS): The current configuration after the write.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
config_eos.merge_settings(settings, force=True)
|
config_eos.merge_settings(settings)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}")
|
raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}")
|
||||||
return config_eos
|
return config_eos
|
||||||
@ -610,7 +613,9 @@ def fastapi_strompreis() -> list[float]:
|
|||||||
'/v1/prediction/list?key=elecprice_marketprice_kwh' instead.
|
'/v1/prediction/list?key=elecprice_marketprice_kwh' instead.
|
||||||
"""
|
"""
|
||||||
settings = SettingsEOS(
|
settings = SettingsEOS(
|
||||||
elecprice_provider="ElecPriceAkkudoktor",
|
elecprice=ElecPriceCommonSettings(
|
||||||
|
elecprice_provider="ElecPriceAkkudoktor",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
config_eos.merge_settings(settings=settings)
|
config_eos.merge_settings(settings=settings)
|
||||||
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
|
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'
|
'/v1/measurement/value'
|
||||||
"""
|
"""
|
||||||
settings = SettingsEOS(
|
settings = SettingsEOS(
|
||||||
prediction_hours=request.hours,
|
prediction=PredictionCommonSettings(
|
||||||
load_provider="LoadAkkudoktor",
|
prediction_hours=request.hours,
|
||||||
loadakkudoktor_year_energy=request.year_energy,
|
),
|
||||||
|
load=LoadCommonSettings(
|
||||||
|
load_provider="LoadAkkudoktor",
|
||||||
|
provider_settings=LoadAkkudoktorCommonSettings(
|
||||||
|
loadakkudoktor_year_energy=request.year_energy,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
config_eos.merge_settings(settings=settings)
|
config_eos.merge_settings(settings=settings)
|
||||||
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
|
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.
|
'/v1/prediction/list?key=load_mean' instead.
|
||||||
"""
|
"""
|
||||||
settings = SettingsEOS(
|
settings = SettingsEOS(
|
||||||
load_provider="LoadAkkudoktor",
|
load=LoadCommonSettings(
|
||||||
loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh
|
load_provider="LoadAkkudoktor",
|
||||||
|
provider_settings=LoadAkkudoktorCommonSettings(
|
||||||
|
loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
config_eos.merge_settings(settings=settings)
|
config_eos.merge_settings(settings=settings)
|
||||||
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
|
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)
|
@app.get("/visualization_results.pdf", response_class=PdfResponse)
|
||||||
def get_pdf() -> PdfResponse:
|
def get_pdf() -> PdfResponse:
|
||||||
# Endpoint to serve the generated PDF with visualization results
|
# 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():
|
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}.")
|
raise HTTPException(status_code=404, detail=f"Output path does not exist: {output_path}.")
|
||||||
file_path = output_path / "visualization_results.pdf"
|
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]:
|
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
|
# 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)
|
headers = dict(request.headers)
|
||||||
|
|
||||||
data = await request.body()
|
data = await request.body()
|
||||||
@ -984,14 +999,14 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--host",
|
"--host",
|
||||||
type=str,
|
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)",
|
help="Host for the EOS server (default: value from config)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--port",
|
"--port",
|
||||||
type=int,
|
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)",
|
help="Port for the EOS server (default: value from config)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional arguments for log_level, access_log, and reload
|
# Optional arguments for log_level, access_log, and reload
|
||||||
|
@ -110,13 +110,13 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--host",
|
"--host",
|
||||||
type=str,
|
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)",
|
help="Host for the EOSdash server (default: value from config_eos)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--port",
|
"--port",
|
||||||
type=int,
|
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)",
|
help="Port for the EOSdash server (default: value from config_eos)",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -124,13 +124,13 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--eos-host",
|
"--eos-host",
|
||||||
type=str,
|
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)",
|
help="Host for the EOS server (default: value from config_eos)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--eos-port",
|
"--eos-port",
|
||||||
type=int,
|
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)",
|
help="Port for the EOS server (default: value from config_eos)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -329,9 +329,9 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
|
|||||||
# File already available
|
# File already available
|
||||||
cache_file_obj = cache_item.cache_file
|
cache_file_obj = cache_item.cache_file
|
||||||
else:
|
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(
|
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(
|
self._store[cache_file_key] = CacheFileRecord(
|
||||||
cache_file=cache_file_obj,
|
cache_file=cache_file_obj,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@ -9,6 +9,14 @@ from akkudoktoreos.core.logging import get_logger
|
|||||||
logger = get_logger(__name__)
|
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):
|
class UtilsCommonSettings(SettingsBaseModel):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ class VisualizationReport(ConfigMixin):
|
|||||||
self.pdf_pages = PdfPages(filename, metadata={}) # Initialize PdfPages without metadata
|
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.version = version # overwrite version as test for constant output of pdf for test
|
||||||
self.current_time = to_datetime(
|
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:
|
def add_chart_to_group(self, chart_func: Callable[[], None]) -> None:
|
||||||
@ -51,7 +51,7 @@ class VisualizationReport(ConfigMixin):
|
|||||||
|
|
||||||
def _initialize_pdf(self) -> None:
|
def _initialize_pdf(self) -> None:
|
||||||
"""Create the output directory if it doesn't exist and initialize the PDF."""
|
"""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 self.filename is already a valid path, use it; otherwise, combine it with output_dir
|
||||||
if os.path.isabs(self.filename):
|
if os.path.isabs(self.filename):
|
||||||
@ -173,7 +173,7 @@ class VisualizationReport(ConfigMixin):
|
|||||||
plt.grid(True)
|
plt.grid(True)
|
||||||
|
|
||||||
# Add vertical line for the current date if within the axis range
|
# 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]:
|
if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]:
|
||||||
plt.axvline(current_time, color="r", linestyle="--", label="Now")
|
plt.axvline(current_time, color="r", linestyle="--", label="Now")
|
||||||
plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom")
|
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,
|
start_hour: Optional[int] = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
report = VisualizationReport(filename)
|
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:
|
# Group 1:
|
||||||
report.create_line_chart_date(
|
report.create_line_chart_date(
|
||||||
next_full_hour_date, # start_date
|
next_full_hour_date, # start_date
|
||||||
|
@ -64,6 +64,25 @@ def config_mixin(config_eos):
|
|||||||
yield config_mixin_patch
|
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
|
# 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)
|
# Before activating, make sure that no user config file exists (e.g. ~/.config/net.akkudoktoreos.eos/EOS.config.json)
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@ -114,8 +133,12 @@ def config_eos(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
) -> ConfigEOS:
|
) -> ConfigEOS:
|
||||||
"""Fixture to reset EOS config to default values."""
|
"""Fixture to reset EOS config to default values."""
|
||||||
monkeypatch.setenv("data_cache_subpath", str(config_default_dirs[-1] / "data/cache"))
|
monkeypatch.setenv(
|
||||||
monkeypatch.setenv("data_output_subpath", str(config_default_dirs[-1] / "data/output"))
|
"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 = config_default_dirs[0] / ConfigEOS.CONFIG_FILE_NAME
|
||||||
config_file_cwd = config_default_dirs[1] / ConfigEOS.CONFIG_FILE_NAME
|
config_file_cwd = config_default_dirs[1] / ConfigEOS.CONFIG_FILE_NAME
|
||||||
assert not config_file.exists()
|
assert not config_file.exists()
|
||||||
@ -125,9 +148,9 @@ def config_eos(
|
|||||||
assert config_file == config_eos.config_file_path
|
assert config_file == config_eos.config_file_path
|
||||||
assert config_file.exists()
|
assert config_file.exists()
|
||||||
assert not config_file_cwd.exists()
|
assert not config_file_cwd.exists()
|
||||||
assert config_default_dirs[-1] / "data" == config_eos.data_folder_path
|
assert config_default_dirs[-1] / "data" == config_eos.general.data_folder_path
|
||||||
assert config_default_dirs[-1] / "data/cache" == config_eos.data_cache_path
|
assert config_default_dirs[-1] / "data/cache" == config_eos.general.data_cache_path
|
||||||
assert config_default_dirs[-1] / "data/output" == config_eos.data_output_path
|
assert config_default_dirs[-1] / "data/output" == config_eos.general.data_output_path
|
||||||
return config_eos
|
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
|
# Set environment before any subprocess run, to keep custom config dir
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["EOS_DIR"] = str(config_default_dirs[-1])
|
env["EOS_DIR"] = str(config_default_dirs[-1])
|
||||||
|
project_dir = config_eos.package_root_path
|
||||||
|
|
||||||
# assure server to be installed
|
# assure server to be installed
|
||||||
try:
|
try:
|
||||||
@ -175,9 +199,9 @@ def server(xprocess, config_eos, config_default_dirs):
|
|||||||
env=env,
|
env=env,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
|
cwd=project_dir,
|
||||||
)
|
)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
project_dir = config_eos.package_root_path
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[sys.executable, "-m", "pip", "install", "-e", project_dir],
|
[sys.executable, "-m", "pip", "install", "-e", project_dir],
|
||||||
check=True,
|
check=True,
|
||||||
|
@ -7,13 +7,15 @@ from akkudoktoreos.devices.battery import Battery, SolarPanelBatteryParameters
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def setup_pv_battery():
|
def setup_pv_battery():
|
||||||
params = SolarPanelBatteryParameters(
|
params = SolarPanelBatteryParameters(
|
||||||
|
device_id="battery1",
|
||||||
capacity_wh=10000,
|
capacity_wh=10000,
|
||||||
initial_soc_percentage=50,
|
initial_soc_percentage=50,
|
||||||
min_soc_percentage=20,
|
min_soc_percentage=20,
|
||||||
max_soc_percentage=80,
|
max_soc_percentage=80,
|
||||||
max_charge_power_w=8000,
|
max_charge_power_w=8000,
|
||||||
|
hours=24,
|
||||||
)
|
)
|
||||||
battery = Battery(params, hours=24)
|
battery = Battery(params)
|
||||||
battery.reset()
|
battery.reset()
|
||||||
return battery
|
return battery
|
||||||
|
|
||||||
@ -113,7 +115,6 @@ def test_soc_limits(setup_pv_battery):
|
|||||||
|
|
||||||
def test_max_charge_power_w(setup_pv_battery):
|
def test_max_charge_power_w(setup_pv_battery):
|
||||||
battery = setup_pv_battery
|
battery = setup_pv_battery
|
||||||
battery.setup()
|
|
||||||
assert (
|
assert (
|
||||||
battery.parameters.max_charge_power_w == 8000
|
battery.parameters.max_charge_power_w == 8000
|
||||||
), "Default max charge power should be 5000W, We ask for 8000W here"
|
), "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):
|
def test_charge_energy_within_limits(setup_pv_battery):
|
||||||
battery = setup_pv_battery
|
battery = setup_pv_battery
|
||||||
battery.setup()
|
|
||||||
initial_soc_wh = battery.soc_wh
|
initial_soc_wh = battery.soc_wh
|
||||||
|
|
||||||
charged_wh, losses_wh = battery.charge_energy(wh=4000, hour=1)
|
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):
|
def test_charge_energy_exceeds_capacity(setup_pv_battery):
|
||||||
battery = setup_pv_battery
|
battery = setup_pv_battery
|
||||||
battery.setup()
|
|
||||||
initial_soc_wh = battery.soc_wh
|
initial_soc_wh = battery.soc_wh
|
||||||
|
|
||||||
# Try to overcharge beyond max capacity
|
# 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):
|
def test_charge_energy_not_allowed_hour(setup_pv_battery):
|
||||||
battery = setup_pv_battery
|
battery = setup_pv_battery
|
||||||
battery.setup()
|
|
||||||
|
|
||||||
# Disable charging for all hours
|
# Disable charging for all hours
|
||||||
battery.set_charge_per_hour(np.zeros(battery.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):
|
def test_charge_energy_relative_power(setup_pv_battery):
|
||||||
battery = setup_pv_battery
|
battery = setup_pv_battery
|
||||||
battery.setup()
|
|
||||||
|
|
||||||
relative_power = 0.5 # 50% of max charge power
|
relative_power = 0.5 # 50% of max charge power
|
||||||
charged_wh, losses_wh = battery.charge_energy(wh=None, hour=4, relative_power=relative_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
|
from akkudoktoreos.devices.battery import ElectricVehicleParameters
|
||||||
|
|
||||||
params = ElectricVehicleParameters(
|
params = ElectricVehicleParameters(
|
||||||
|
device_id="ev1",
|
||||||
capacity_wh=40000,
|
capacity_wh=40000,
|
||||||
initial_soc_percentage=60,
|
initial_soc_percentage=60,
|
||||||
min_soc_percentage=10,
|
min_soc_percentage=10,
|
||||||
max_soc_percentage=90,
|
max_soc_percentage=90,
|
||||||
max_charge_power_w=7000,
|
max_charge_power_w=7000,
|
||||||
|
hours=24,
|
||||||
)
|
)
|
||||||
battery = Battery(params, hours=24)
|
battery = Battery(params)
|
||||||
battery.reset()
|
battery.reset()
|
||||||
return battery
|
return battery
|
||||||
|
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -16,58 +14,58 @@ from akkudoktoreos.devices.battery import (
|
|||||||
)
|
)
|
||||||
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
||||||
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
||||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
|
||||||
|
|
||||||
start_hour = 1
|
start_hour = 1
|
||||||
|
|
||||||
|
|
||||||
# Example initialization of necessary components
|
# Example initialization of necessary components
|
||||||
@pytest.fixture
|
@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."""
|
"""Fixture to create an EnergieManagementSystem instance with given test parameters."""
|
||||||
# Assure configuration holds the correct values
|
# Assure configuration holds the correct values
|
||||||
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
|
config_eos.merge_settings_from_dict(
|
||||||
assert config_eos.prediction_hours is not None
|
{"prediction": {"prediction_hours": 48}, "optimization": {"optimization_hours": 24}}
|
||||||
|
)
|
||||||
|
assert config_eos.prediction.prediction_hours == 48
|
||||||
|
|
||||||
# Initialize the battery and the inverter
|
# Initialize the battery and the inverter
|
||||||
akku = Battery(
|
akku = Battery(
|
||||||
SolarPanelBatteryParameters(
|
SolarPanelBatteryParameters(
|
||||||
capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10
|
device_id="battery1",
|
||||||
),
|
capacity_wh=5000,
|
||||||
hours=config_eos.prediction_hours,
|
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()
|
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)
|
# Household device (currently not used, set to None)
|
||||||
home_appliance = HomeAppliance(
|
home_appliance = HomeAppliance(
|
||||||
HomeApplianceParameters(
|
HomeApplianceParameters(
|
||||||
|
device_id="dishwasher1",
|
||||||
consumption_wh=2000,
|
consumption_wh=2000,
|
||||||
duration_h=2,
|
duration_h=2,
|
||||||
),
|
),
|
||||||
hours=config_eos.prediction_hours,
|
|
||||||
)
|
)
|
||||||
home_appliance.set_starting_time(2)
|
home_appliance.set_starting_time(2)
|
||||||
|
devices_eos.add_device(home_appliance)
|
||||||
|
|
||||||
# Example initialization of electric car battery
|
# Example initialization of electric car battery
|
||||||
eauto = Battery(
|
eauto = Battery(
|
||||||
ElectricVehicleParameters(
|
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
|
# Parameters based on previous example data
|
||||||
pv_prognose_wh = [
|
pv_prognose_wh = [
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -15,64 +13,61 @@ from akkudoktoreos.devices.battery import (
|
|||||||
)
|
)
|
||||||
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
||||||
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
||||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
|
||||||
|
|
||||||
start_hour = 0
|
start_hour = 0
|
||||||
|
|
||||||
|
|
||||||
# Example initialization of necessary components
|
# Example initialization of necessary components
|
||||||
@pytest.fixture
|
@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."""
|
"""Fixture to create an EnergieManagementSystem instance with given test parameters."""
|
||||||
# Assure configuration holds the correct values
|
# Assure configuration holds the correct values
|
||||||
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
|
config_eos.merge_settings_from_dict(
|
||||||
assert config_eos.prediction_hours is not None
|
{"prediction": {"prediction_hours": 48}, "optimization": {"optimization_hours": 24}}
|
||||||
|
)
|
||||||
|
assert config_eos.prediction.prediction_hours == 48
|
||||||
|
|
||||||
# Initialize the battery and the inverter
|
# Initialize the battery and the inverter
|
||||||
akku = Battery(
|
akku = Battery(
|
||||||
SolarPanelBatteryParameters(
|
SolarPanelBatteryParameters(
|
||||||
capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10
|
device_id="pv1", capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10
|
||||||
),
|
)
|
||||||
hours=config_eos.prediction_hours,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate
|
|
||||||
sc = SelfConsumptionProbabilityInterpolator(
|
|
||||||
Path(__file__).parent.resolve()
|
|
||||||
/ ".."
|
|
||||||
/ "src"
|
|
||||||
/ "akkudoktoreos"
|
|
||||||
/ "data"
|
|
||||||
/ "regular_grid_interpolator.pkl"
|
|
||||||
)
|
|
||||||
|
|
||||||
akku.reset()
|
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)
|
# Household device (currently not used, set to None)
|
||||||
home_appliance = HomeAppliance(
|
home_appliance = HomeAppliance(
|
||||||
HomeApplianceParameters(
|
HomeApplianceParameters(
|
||||||
|
device_id="dishwasher1",
|
||||||
consumption_wh=2000,
|
consumption_wh=2000,
|
||||||
duration_h=2,
|
duration_h=2,
|
||||||
),
|
)
|
||||||
hours=config_eos.prediction_hours,
|
|
||||||
)
|
)
|
||||||
home_appliance.set_starting_time(2)
|
home_appliance.set_starting_time(2)
|
||||||
|
devices_eos.add_device(home_appliance)
|
||||||
|
|
||||||
# Example initialization of electric car battery
|
# Example initialization of electric car battery
|
||||||
eauto = Battery(
|
eauto = Battery(
|
||||||
ElectricVehicleParameters(
|
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
|
# 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[10] = 5000.0
|
||||||
pv_prognose_wh[11] = 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[0:10] = [0.00001] * 10
|
||||||
strompreis_euro_pro_wh[11:15] = [0.00005] * 4
|
strompreis_euro_pro_wh[11:15] = [0.00005] * 4
|
||||||
strompreis_euro_pro_wh[20] = 0.00001
|
strompreis_euro_pro_wh[20] = 0.00001
|
||||||
@ -146,10 +141,10 @@ def create_ems_instance(config_eos) -> EnergieManagementSystem:
|
|||||||
home_appliance=home_appliance,
|
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
|
ac[20] = 1
|
||||||
ems.set_akku_ac_charge_hours(ac)
|
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
|
dc[11] = 1
|
||||||
ems.set_akku_dc_charge_hours(dc)
|
ems.set_akku_dc_charge_hours(dc)
|
||||||
|
|
||||||
|
@ -49,7 +49,9 @@ def test_optimize(
|
|||||||
):
|
):
|
||||||
"""Test optimierung_ems."""
|
"""Test optimierung_ems."""
|
||||||
# Assure configuration holds the correct values
|
# 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
|
# Load input and output data
|
||||||
file = DIR_TESTDATA / fn_in
|
file = DIR_TESTDATA / fn_in
|
||||||
|
@ -38,15 +38,19 @@ def test_config_constants(config_eos):
|
|||||||
|
|
||||||
def test_computed_paths(config_eos):
|
def test_computed_paths(config_eos):
|
||||||
"""Test computed paths for output and cache."""
|
"""Test computed paths for output and cache."""
|
||||||
config_eos.merge_settings_from_dict(
|
# Don't actually try to create the data folder
|
||||||
{
|
with patch("pathlib.Path.mkdir"):
|
||||||
"data_folder_path": "/base/data",
|
config_eos.merge_settings_from_dict(
|
||||||
"data_output_subpath": "output",
|
{
|
||||||
"data_cache_subpath": "cache",
|
"general": {
|
||||||
}
|
"data_folder_path": "/base/data",
|
||||||
)
|
"data_output_subpath": "extra/output",
|
||||||
assert config_eos.data_output_path == Path("/base/data/output")
|
"data_cache_subpath": "somewhere/cache",
|
||||||
assert config_eos.data_cache_path == Path("/base/data/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
|
# reset settings so the config_eos fixture can verify the default paths
|
||||||
config_eos.reset_settings()
|
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 = Path(config_default_dir_user) / ConfigEOS.CONFIG_FILE_NAME
|
||||||
config_file.parent.mkdir()
|
config_file.parent.mkdir()
|
||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
config_eos = get_config()
|
config_eos.update()
|
||||||
assert config_eos.config_file_path == config_file
|
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()
|
assert not temp_config_file_path.exists()
|
||||||
with patch("akkudoktoreos.config.config.user_config_dir", return_value=temp_dir):
|
with patch("akkudoktoreos.config.config.user_config_dir", return_value=temp_dir):
|
||||||
assert config_eos._get_config_file_path() == (temp_config_file_path, False)
|
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()
|
assert temp_config_file_path.exists()
|
||||||
|
@ -23,9 +23,10 @@ FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def elecprice_provider(monkeypatch):
|
def elecprice_provider(monkeypatch, config_eos):
|
||||||
"""Fixture to create a ElecPriceProvider instance."""
|
"""Fixture to create a ElecPriceProvider instance."""
|
||||||
monkeypatch.setenv("elecprice_provider", "ElecPriceAkkudoktor")
|
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceAkkudoktor")
|
||||||
|
config_eos.reset_settings()
|
||||||
return ElecPriceAkkudoktor()
|
return ElecPriceAkkudoktor()
|
||||||
|
|
||||||
|
|
||||||
@ -56,9 +57,9 @@ def test_singleton_instance(elecprice_provider):
|
|||||||
|
|
||||||
def test_invalid_provider(elecprice_provider, monkeypatch):
|
def test_invalid_provider(elecprice_provider, monkeypatch):
|
||||||
"""Test requesting an unsupported elecprice_provider."""
|
"""Test requesting an unsupported elecprice_provider."""
|
||||||
monkeypatch.setenv("elecprice_provider", "<invalid>")
|
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
|
||||||
elecprice_provider.config.update()
|
elecprice_provider.config.reset_settings()
|
||||||
assert elecprice_provider.enabled() == False
|
assert not elecprice_provider.enabled()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
|
@ -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):
|
def elecprice_provider(sample_import_1_json, config_eos):
|
||||||
"""Fixture to create a ElecPriceProvider instance."""
|
"""Fixture to create a ElecPriceProvider instance."""
|
||||||
settings = {
|
settings = {
|
||||||
"elecprice_provider": "ElecPriceImport",
|
"elecprice": {
|
||||||
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
|
"elecprice_provider": "ElecPriceImport",
|
||||||
"elecpriceimport_json": json.dumps(sample_import_1_json),
|
"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)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
provider = ElecPriceImport()
|
provider = ElecPriceImport()
|
||||||
@ -48,8 +52,12 @@ def test_singleton_instance(elecprice_provider):
|
|||||||
def test_invalid_provider(elecprice_provider, config_eos):
|
def test_invalid_provider(elecprice_provider, config_eos):
|
||||||
"""Test requesting an unsupported elecprice_provider."""
|
"""Test requesting an unsupported elecprice_provider."""
|
||||||
settings = {
|
settings = {
|
||||||
"elecprice_provider": "<invalid>",
|
"elecprice": {
|
||||||
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
|
"elecprice_provider": "<invalid>",
|
||||||
|
"provider_settings": {
|
||||||
|
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
assert not elecprice_provider.enabled()
|
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 = get_ems()
|
||||||
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
|
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
|
||||||
if from_file:
|
if from_file:
|
||||||
config_eos.elecpriceimport_json = None
|
config_eos.elecprice.provider_settings.elecpriceimport_json = None
|
||||||
assert config_eos.elecpriceimport_json is None
|
assert config_eos.elecprice.provider_settings.elecpriceimport_json is None
|
||||||
else:
|
else:
|
||||||
config_eos.elecpriceimport_file_path = None
|
config_eos.elecprice.provider_settings.elecpriceimport_file_path = None
|
||||||
assert config_eos.elecpriceimport_file_path is None
|
assert config_eos.elecprice.provider_settings.elecpriceimport_file_path is None
|
||||||
elecprice_provider.clear()
|
elecprice_provider.clear()
|
||||||
|
|
||||||
# Call the method
|
# Call the method
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from unittest.mock import Mock
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -6,22 +6,29 @@ from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_battery():
|
def mock_battery() -> Mock:
|
||||||
mock_battery = Mock()
|
mock_battery = Mock()
|
||||||
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
|
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
|
||||||
mock_battery.discharge_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
|
return mock_battery
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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 = Mock()
|
||||||
mock_self_consumption_predictor.calculate_self_consumption.return_value = 1.0
|
mock_self_consumption_predictor.calculate_self_consumption.return_value = 1.0
|
||||||
return Inverter(
|
with patch(
|
||||||
mock_self_consumption_predictor,
|
"akkudoktoreos.devices.inverter.get_eos_load_interpolator",
|
||||||
InverterParameters(max_power_wh=500.0),
|
return_value=mock_self_consumption_predictor,
|
||||||
battery=mock_battery,
|
):
|
||||||
)
|
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):
|
def test_process_energy_excess_generation(inverter, mock_battery):
|
||||||
|
@ -17,9 +17,13 @@ from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_
|
|||||||
def load_provider(config_eos):
|
def load_provider(config_eos):
|
||||||
"""Fixture to initialise the LoadAkkudoktor instance."""
|
"""Fixture to initialise the LoadAkkudoktor instance."""
|
||||||
settings = {
|
settings = {
|
||||||
"load_provider": "LoadAkkudoktor",
|
"load": {
|
||||||
"load_name": "Akkudoktor Profile",
|
"load_provider": "LoadAkkudoktor",
|
||||||
"loadakkudoktor_year_energy": "1000",
|
"provider_settings": {
|
||||||
|
"load_name": "Akkudoktor Profile",
|
||||||
|
"loadakkudoktor_year_energy": "1000",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
return LoadAkkudoktor()
|
return LoadAkkudoktor()
|
||||||
|
@ -3,7 +3,11 @@ import pytest
|
|||||||
from pendulum import datetime, duration
|
from pendulum import datetime, duration
|
||||||
|
|
||||||
from akkudoktoreos.config.config import SettingsEOS
|
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
|
@pytest.fixture
|
||||||
@ -186,8 +190,10 @@ def test_load_total_no_data(measurement_eos):
|
|||||||
def test_name_to_key(measurement_eos):
|
def test_name_to_key(measurement_eos):
|
||||||
"""Test name_to_key functionality."""
|
"""Test name_to_key functionality."""
|
||||||
settings = SettingsEOS(
|
settings = SettingsEOS(
|
||||||
measurement_load0_name="Household",
|
measurement=MeasurementCommonSettings(
|
||||||
measurement_load1_name="Heat Pump",
|
measurement_load0_name="Household",
|
||||||
|
measurement_load1_name="Heat Pump",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
measurement_eos.config.merge_settings(settings)
|
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):
|
def test_name_to_key_invalid_topic(measurement_eos):
|
||||||
"""Test name_to_key with an invalid topic."""
|
"""Test name_to_key with an invalid topic."""
|
||||||
settings = SettingsEOS(
|
settings = SettingsEOS(
|
||||||
measurement_load0_name="Household",
|
MeasurementCommonSettings(
|
||||||
measurement_load1_name="Heat Pump",
|
measurement_load0_name="Household",
|
||||||
|
measurement_load1_name="Heat Pump",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
measurement_eos.config.merge_settings(settings)
|
measurement_eos.config.merge_settings(settings)
|
||||||
|
|
||||||
|
@ -126,9 +126,9 @@ def test_prediction_common_settings_with_location():
|
|||||||
|
|
||||||
def test_prediction_common_settings_timezone_none_when_coordinates_missing():
|
def test_prediction_common_settings_timezone_none_when_coordinates_missing():
|
||||||
"""Test that timezone is None when latitude or longitude is missing."""
|
"""Test that timezone is None when latitude or longitude is missing."""
|
||||||
config_no_latitude = PredictionCommonSettings(longitude=-74.0060)
|
config_no_latitude = PredictionCommonSettings(latitude=None, longitude=-74.0060)
|
||||||
config_no_longitude = PredictionCommonSettings(latitude=40.7128)
|
config_no_longitude = PredictionCommonSettings(latitude=40.7128, longitude=None)
|
||||||
config_no_coords = PredictionCommonSettings()
|
config_no_coords = PredictionCommonSettings(latitude=None, longitude=None)
|
||||||
|
|
||||||
assert config_no_latitude.timezone is None
|
assert config_no_latitude.timezone is None
|
||||||
assert config_no_longitude.timezone is None
|
assert config_no_longitude.timezone is None
|
||||||
|
@ -88,31 +88,31 @@ class TestPredictionBase:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def base(self, monkeypatch):
|
def base(self, monkeypatch):
|
||||||
# Provide default values for configuration
|
# Provide default values for configuration
|
||||||
monkeypatch.setenv("latitude", "50.0")
|
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "50.0")
|
||||||
monkeypatch.setenv("longitude", "10.0")
|
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "10.0")
|
||||||
derived = DerivedBase()
|
derived = DerivedBase()
|
||||||
derived.config.update()
|
derived.config.reset_settings()
|
||||||
return derived
|
return derived
|
||||||
|
|
||||||
def test_config_value_from_env_variable(self, base, monkeypatch):
|
def test_config_value_from_env_variable(self, base, monkeypatch):
|
||||||
# From Prediction Config
|
# From Prediction Config
|
||||||
monkeypatch.setenv("latitude", "2.5")
|
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "2.5")
|
||||||
base.config.update()
|
base.config.reset_settings()
|
||||||
assert base.config.latitude == 2.5
|
assert base.config.prediction.latitude == 2.5
|
||||||
|
|
||||||
def test_config_value_from_field_default(self, base, monkeypatch):
|
def test_config_value_from_field_default(self, base, monkeypatch):
|
||||||
assert base.config.model_fields["prediction_hours"].default == 48
|
assert base.config.prediction.model_fields["prediction_hours"].default == 48
|
||||||
assert base.config.prediction_hours == 48
|
assert base.config.prediction.prediction_hours == 48
|
||||||
monkeypatch.setenv("prediction_hours", "128")
|
monkeypatch.setenv("EOS_PREDICTION__PREDICTION_HOURS", "128")
|
||||||
base.config.update()
|
base.config.reset_settings()
|
||||||
assert base.config.prediction_hours == 128
|
assert base.config.prediction.prediction_hours == 128
|
||||||
monkeypatch.delenv("prediction_hours")
|
monkeypatch.delenv("EOS_PREDICTION__PREDICTION_HOURS")
|
||||||
base.config.update()
|
base.config.reset_settings()
|
||||||
assert base.config.prediction_hours == 48
|
assert base.config.prediction.prediction_hours == 48
|
||||||
|
|
||||||
def test_get_config_value_key_error(self, base):
|
def test_get_config_value_key_error(self, base):
|
||||||
with pytest.raises(AttributeError):
|
with pytest.raises(AttributeError):
|
||||||
base.config.non_existent_key
|
base.config.prediction.non_existent_key
|
||||||
|
|
||||||
|
|
||||||
# TestPredictionRecord fully covered by TestDataRecord
|
# TestPredictionRecord fully covered by TestDataRecord
|
||||||
@ -159,14 +159,14 @@ class TestPredictionProvider:
|
|||||||
"""Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated."""
|
"""Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated."""
|
||||||
ems_eos = get_ems()
|
ems_eos = get_ems()
|
||||||
ems_eos.set_start_datetime(sample_start_datetime)
|
ems_eos.set_start_datetime(sample_start_datetime)
|
||||||
provider.config.prediction_hours = 24 # 24 hours into the future
|
provider.config.prediction.prediction_hours = 24 # 24 hours into the future
|
||||||
provider.config.prediction_historic_hours = 48 # 48 hours into the past
|
provider.config.prediction.prediction_historic_hours = 48 # 48 hours into the past
|
||||||
|
|
||||||
expected_end_datetime = sample_start_datetime + to_duration(
|
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(
|
expected_keep_datetime = sample_start_datetime - to_duration(
|
||||||
provider.config.prediction_historic_hours * 3600
|
provider.config.prediction.prediction_historic_hours * 3600
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
@ -183,31 +183,32 @@ class TestPredictionProvider:
|
|||||||
# EOS config supersedes
|
# EOS config supersedes
|
||||||
ems_eos = get_ems()
|
ems_eos = get_ems()
|
||||||
# The following values are currently not set in EOS config, we can override
|
# The following values are currently not set in EOS config, we can override
|
||||||
monkeypatch.setenv("prediction_historic_hours", "2")
|
monkeypatch.setenv("EOS_PREDICTION__PREDICTION_HISTORIC_HOURS", "2")
|
||||||
assert os.getenv("prediction_historic_hours") == "2"
|
assert os.getenv("EOS_PREDICTION__PREDICTION_HISTORIC_HOURS") == "2"
|
||||||
monkeypatch.setenv("latitude", "37.7749")
|
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "37.7749")
|
||||||
assert os.getenv("latitude") == "37.7749"
|
assert os.getenv("EOS_PREDICTION__LATITUDE") == "37.7749"
|
||||||
monkeypatch.setenv("longitude", "-122.4194")
|
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "-122.4194")
|
||||||
assert os.getenv("longitude") == "-122.4194"
|
assert os.getenv("EOS_PREDICTION__LONGITUDE") == "-122.4194"
|
||||||
|
provider.config.reset_settings()
|
||||||
|
|
||||||
ems_eos.set_start_datetime(sample_start_datetime)
|
ems_eos.set_start_datetime(sample_start_datetime)
|
||||||
provider.update_data()
|
provider.update_data()
|
||||||
|
|
||||||
assert provider.config.prediction_hours == config_eos.prediction_hours
|
assert provider.config.prediction.prediction_hours == config_eos.prediction.prediction_hours
|
||||||
assert provider.config.prediction_historic_hours == 2
|
assert provider.config.prediction.prediction_historic_hours == 2
|
||||||
assert provider.config.latitude == 37.7749
|
assert provider.config.prediction.latitude == 37.7749
|
||||||
assert provider.config.longitude == -122.4194
|
assert provider.config.prediction.longitude == -122.4194
|
||||||
assert provider.start_datetime == sample_start_datetime
|
assert provider.start_datetime == sample_start_datetime
|
||||||
assert provider.end_datetime == sample_start_datetime + to_duration(
|
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")
|
assert provider.keep_datetime == sample_start_datetime - to_duration("2 hours")
|
||||||
|
|
||||||
def test_update_method_force_enable(self, provider, monkeypatch):
|
def test_update_method_force_enable(self, provider, monkeypatch):
|
||||||
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
|
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
|
||||||
# Preset values that are needed by update
|
# Preset values that are needed by update
|
||||||
monkeypatch.setenv("latitude", "37.7749")
|
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "37.7749")
|
||||||
monkeypatch.setenv("longitude", "-122.4194")
|
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "-122.4194")
|
||||||
|
|
||||||
# Override enabled to return False for this test
|
# Override enabled to return False for this test
|
||||||
DerivedPredictionProvider.provider_enabled = False
|
DerivedPredictionProvider.provider_enabled = False
|
||||||
@ -288,7 +289,9 @@ class TestPredictionContainer:
|
|||||||
ems_eos = get_ems()
|
ems_eos = get_ems()
|
||||||
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
||||||
settings = {
|
settings = {
|
||||||
"prediction_hours": hours,
|
"prediction": {
|
||||||
|
"prediction_hours": hours,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
container.config.merge_settings_from_dict(settings)
|
container.config.merge_settings_from_dict(settings)
|
||||||
expected = to_datetime(end, in_timezone="Europe/Berlin")
|
expected = to_datetime(end, in_timezone="Europe/Berlin")
|
||||||
@ -316,7 +319,9 @@ class TestPredictionContainer:
|
|||||||
ems_eos = get_ems()
|
ems_eos = get_ems()
|
||||||
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
||||||
settings = {
|
settings = {
|
||||||
"prediction_historic_hours": historic_hours,
|
"prediction": {
|
||||||
|
"prediction_historic_hours": historic_hours,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
container.config.merge_settings_from_dict(settings)
|
container.config.merge_settings_from_dict(settings)
|
||||||
expected = to_datetime(expected_keep, in_timezone="Europe/Berlin")
|
expected = to_datetime(expected_keep, in_timezone="Europe/Berlin")
|
||||||
@ -336,7 +341,9 @@ class TestPredictionContainer:
|
|||||||
ems_eos = get_ems()
|
ems_eos = get_ems()
|
||||||
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
||||||
settings = {
|
settings = {
|
||||||
"prediction_hours": prediction_hours,
|
"prediction": {
|
||||||
|
"prediction_hours": prediction_hours,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
container.config.merge_settings_from_dict(settings)
|
container.config.merge_settings_from_dict(settings)
|
||||||
assert container.total_hours == expected_hours
|
assert container.total_hours == expected_hours
|
||||||
@ -355,7 +362,9 @@ class TestPredictionContainer:
|
|||||||
ems_eos = get_ems()
|
ems_eos = get_ems()
|
||||||
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
||||||
settings = {
|
settings = {
|
||||||
"prediction_historic_hours": historic_hours,
|
"prediction": {
|
||||||
|
"prediction_historic_hours": historic_hours,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
container.config.merge_settings_from_dict(settings)
|
container.config.merge_settings_from_dict(settings)
|
||||||
assert container.keep_hours == expected_hours
|
assert container.keep_hours == expected_hours
|
||||||
|
@ -25,36 +25,41 @@ FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1
|
|||||||
def sample_settings(config_eos):
|
def sample_settings(config_eos):
|
||||||
"""Fixture that adds settings data to the global config."""
|
"""Fixture that adds settings data to the global config."""
|
||||||
settings = {
|
settings = {
|
||||||
"prediction_hours": 48,
|
"prediction": {
|
||||||
"prediction_historic_hours": 24,
|
"prediction_hours": 48,
|
||||||
"latitude": 52.52,
|
"prediction_historic_hours": 24,
|
||||||
"longitude": 13.405,
|
"latitude": 52.52,
|
||||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
"longitude": 13.405,
|
||||||
"pvforecast0_peakpower": 5.0,
|
},
|
||||||
"pvforecast0_surface_azimuth": -10,
|
"pvforecast": {
|
||||||
"pvforecast0_surface_tilt": 7,
|
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
"pvforecast0_peakpower": 5.0,
|
||||||
"pvforecast0_inverter_paco": 10000,
|
"pvforecast0_surface_azimuth": -10,
|
||||||
"pvforecast1_peakpower": 4.8,
|
"pvforecast0_surface_tilt": 7,
|
||||||
"pvforecast1_surface_azimuth": -90,
|
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||||
"pvforecast1_surface_tilt": 7,
|
"pvforecast0_inverter_paco": 10000,
|
||||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
"pvforecast1_peakpower": 4.8,
|
||||||
"pvforecast1_inverter_paco": 10000,
|
"pvforecast1_surface_azimuth": -90,
|
||||||
"pvforecast2_peakpower": 1.4,
|
"pvforecast1_surface_tilt": 7,
|
||||||
"pvforecast2_surface_azimuth": -40,
|
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||||
"pvforecast2_surface_tilt": 60,
|
"pvforecast1_inverter_paco": 10000,
|
||||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
"pvforecast2_peakpower": 1.4,
|
||||||
"pvforecast2_inverter_paco": 2000,
|
"pvforecast2_surface_azimuth": -40,
|
||||||
"pvforecast3_peakpower": 1.6,
|
"pvforecast2_surface_tilt": 60,
|
||||||
"pvforecast3_surface_azimuth": 5,
|
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||||
"pvforecast3_surface_tilt": 45,
|
"pvforecast2_inverter_paco": 2000,
|
||||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
"pvforecast3_peakpower": 1.6,
|
||||||
"pvforecast3_inverter_paco": 1400,
|
"pvforecast3_surface_azimuth": 5,
|
||||||
"pvforecast4_peakpower": None,
|
"pvforecast3_surface_tilt": 45,
|
||||||
|
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||||
|
"pvforecast3_inverter_paco": 1400,
|
||||||
|
"pvforecast4_peakpower": None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Merge settings to config
|
# Merge settings to config
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
|
assert config_eos.pvforecast.pvforecast_provider == "PVForecastAkkudoktor"
|
||||||
return config_eos
|
return config_eos
|
||||||
|
|
||||||
|
|
||||||
@ -141,15 +146,19 @@ sample_value = AkkudoktorForecastValue(
|
|||||||
windspeed_10m=10.0,
|
windspeed_10m=10.0,
|
||||||
)
|
)
|
||||||
sample_config_data = {
|
sample_config_data = {
|
||||||
"prediction_hours": 48,
|
"prediction": {
|
||||||
"prediction_historic_hours": 24,
|
"prediction_hours": 48,
|
||||||
"latitude": 52.52,
|
"prediction_historic_hours": 24,
|
||||||
"longitude": 13.405,
|
"latitude": 52.52,
|
||||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
"longitude": 13.405,
|
||||||
"pvforecast0_peakpower": 5.0,
|
},
|
||||||
"pvforecast0_surface_azimuth": 180,
|
"pvforecast": {
|
||||||
"pvforecast0_surface_tilt": 30,
|
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||||
"pvforecast0_inverter_paco": 10000,
|
"pvforecast0_peakpower": 5.0,
|
||||||
|
"pvforecast0_surface_azimuth": 180,
|
||||||
|
"pvforecast0_surface_tilt": 30,
|
||||||
|
"pvforecast0_inverter_paco": 10000,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
def pvforecast_provider(sample_import_1_json, config_eos):
|
||||||
"""Fixture to create a PVForecastProvider instance."""
|
"""Fixture to create a PVForecastProvider instance."""
|
||||||
settings = {
|
settings = {
|
||||||
"pvforecast_provider": "PVForecastImport",
|
"pvforecast": {
|
||||||
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
|
"pvforecast_provider": "PVForecastImport",
|
||||||
"pvforecastimport_json": json.dumps(sample_import_1_json),
|
"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)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
provider = PVForecastImport()
|
provider = PVForecastImport()
|
||||||
@ -48,8 +52,12 @@ def test_singleton_instance(pvforecast_provider):
|
|||||||
def test_invalid_provider(pvforecast_provider, config_eos):
|
def test_invalid_provider(pvforecast_provider, config_eos):
|
||||||
"""Test requesting an unsupported pvforecast_provider."""
|
"""Test requesting an unsupported pvforecast_provider."""
|
||||||
settings = {
|
settings = {
|
||||||
"pvforecast_provider": "<invalid>",
|
"pvforecast": {
|
||||||
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
|
"pvforecast_provider": "<invalid>",
|
||||||
|
"provider_settings": {
|
||||||
|
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
assert not pvforecast_provider.enabled()
|
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 = get_ems()
|
||||||
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
|
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
|
||||||
if from_file:
|
if from_file:
|
||||||
config_eos.pvforecastimport_json = None
|
config_eos.pvforecast.provider_settings.pvforecastimport_json = None
|
||||||
assert config_eos.pvforecastimport_json is None
|
assert config_eos.pvforecast.provider_settings.pvforecastimport_json is None
|
||||||
else:
|
else:
|
||||||
config_eos.pvforecastimport_file_path = None
|
config_eos.pvforecast.provider_settings.pvforecastimport_file_path = None
|
||||||
assert config_eos.pvforecastimport_file_path is None
|
assert config_eos.pvforecast.provider_settings.pvforecastimport_file_path is None
|
||||||
pvforecast_provider.clear()
|
pvforecast_provider.clear()
|
||||||
|
|
||||||
# Call the method
|
# Call the method
|
||||||
|
@ -6,8 +6,8 @@ import requests
|
|||||||
def test_server(server, config_eos):
|
def test_server(server, config_eos):
|
||||||
"""Test the server."""
|
"""Test the server."""
|
||||||
# validate correct path in server
|
# validate correct path in server
|
||||||
assert config_eos.data_folder_path is not None
|
assert config_eos.general.data_folder_path is not None
|
||||||
assert config_eos.data_folder_path.is_dir()
|
assert config_eos.general.data_folder_path.is_dir()
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/config")
|
result = requests.get(f"{server}/v1/config")
|
||||||
assert result.status_code == HTTPStatus.OK
|
assert result.status_code == HTTPStatus.OK
|
||||||
|
@ -13,7 +13,7 @@ reference_file = DIR_TESTDATA / "test_example_report.pdf"
|
|||||||
|
|
||||||
def test_generate_pdf_example(config_eos):
|
def test_generate_pdf_example(config_eos):
|
||||||
"""Test generation of example visualization report."""
|
"""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
|
assert output_dir is not None
|
||||||
output_file = output_dir / filename
|
output_file = output_dir / filename
|
||||||
assert not output_file.exists()
|
assert not output_file.exists()
|
||||||
|
@ -19,9 +19,9 @@ FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_b
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def weather_provider(monkeypatch):
|
def weather_provider(monkeypatch):
|
||||||
"""Fixture to create a WeatherProvider instance."""
|
"""Fixture to create a WeatherProvider instance."""
|
||||||
monkeypatch.setenv("weather_provider", "BrightSky")
|
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "BrightSky")
|
||||||
monkeypatch.setenv("latitude", "50.0")
|
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "50.0")
|
||||||
monkeypatch.setenv("longitude", "10.0")
|
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "10.0")
|
||||||
return WeatherBrightSky()
|
return WeatherBrightSky()
|
||||||
|
|
||||||
|
|
||||||
@ -60,19 +60,19 @@ def test_singleton_instance(weather_provider):
|
|||||||
|
|
||||||
def test_invalid_provider(weather_provider, monkeypatch):
|
def test_invalid_provider(weather_provider, monkeypatch):
|
||||||
"""Test requesting an unsupported weather_provider."""
|
"""Test requesting an unsupported weather_provider."""
|
||||||
monkeypatch.setenv("weather_provider", "<invalid>")
|
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "<invalid>")
|
||||||
weather_provider.config.update()
|
weather_provider.config.reset_settings()
|
||||||
assert not weather_provider.enabled()
|
assert not weather_provider.enabled()
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_coordinates(weather_provider, monkeypatch):
|
def test_invalid_coordinates(weather_provider, monkeypatch):
|
||||||
"""Test invalid coordinates raise ValueError."""
|
"""Test invalid coordinates raise ValueError."""
|
||||||
monkeypatch.setenv("latitude", "1000")
|
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "1000")
|
||||||
monkeypatch.setenv("longitude", "1000")
|
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "1000")
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
|
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
|
||||||
):
|
):
|
||||||
weather_provider.config.update()
|
weather_provider.config.reset_settings()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
|
@ -24,9 +24,13 @@ FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA = DIR_TESTDATA.joinpath("weatherforecas
|
|||||||
def weather_provider(config_eos):
|
def weather_provider(config_eos):
|
||||||
"""Fixture to create a WeatherProvider instance."""
|
"""Fixture to create a WeatherProvider instance."""
|
||||||
settings = {
|
settings = {
|
||||||
"weather_provider": "ClearOutside",
|
"weather": {
|
||||||
"latitude": 50.0,
|
"weather_provider": "ClearOutside",
|
||||||
"longitude": 10.0,
|
},
|
||||||
|
"prediction": {
|
||||||
|
"latitude": 50.0,
|
||||||
|
"longitude": 10.0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
return WeatherClearOutside()
|
return WeatherClearOutside()
|
||||||
@ -69,7 +73,9 @@ def test_singleton_instance(weather_provider):
|
|||||||
def test_invalid_provider(weather_provider, config_eos):
|
def test_invalid_provider(weather_provider, config_eos):
|
||||||
"""Test requesting an unsupported weather_provider."""
|
"""Test requesting an unsupported weather_provider."""
|
||||||
settings = {
|
settings = {
|
||||||
"weather_provider": "<invalid>",
|
"weather": {
|
||||||
|
"weather_provider": "<invalid>",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
assert not weather_provider.enabled()
|
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):
|
def test_invalid_coordinates(weather_provider, config_eos):
|
||||||
"""Test invalid coordinates raise ValueError."""
|
"""Test invalid coordinates raise ValueError."""
|
||||||
settings = {
|
settings = {
|
||||||
"weather_provider": "ClearOutside",
|
"weather": {
|
||||||
"latitude": 1000.0,
|
"weather_provider": "ClearOutside",
|
||||||
"longitude": 1000.0,
|
},
|
||||||
|
"prediction": {
|
||||||
|
"latitude": 1000.0,
|
||||||
|
"longitude": 1000.0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
|
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()
|
weather_provider.update_data()
|
||||||
|
|
||||||
# Check for correct prediction time window
|
# Check for correct prediction time window
|
||||||
assert weather_provider.config.prediction_hours == 48
|
assert weather_provider.config.prediction.prediction_hours == 48
|
||||||
assert weather_provider.config.prediction_historic_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.start_datetime, expected_start).equal
|
||||||
assert compare_datetimes(weather_provider.end_datetime, expected_end).equal
|
assert compare_datetimes(weather_provider.end_datetime, expected_end).equal
|
||||||
assert compare_datetimes(weather_provider.keep_datetime, expected_keep).equal
|
assert compare_datetimes(weather_provider.keep_datetime, expected_keep).equal
|
||||||
|
@ -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):
|
def weather_provider(sample_import_1_json, config_eos):
|
||||||
"""Fixture to create a WeatherProvider instance."""
|
"""Fixture to create a WeatherProvider instance."""
|
||||||
settings = {
|
settings = {
|
||||||
"weather_provider": "WeatherImport",
|
"weather": {
|
||||||
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
|
"weather_provider": "WeatherImport",
|
||||||
"weatherimport_json": json.dumps(sample_import_1_json),
|
"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)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
provider = WeatherImport()
|
provider = WeatherImport()
|
||||||
@ -48,8 +52,12 @@ def test_singleton_instance(weather_provider):
|
|||||||
def test_invalid_provider(weather_provider, config_eos, monkeypatch):
|
def test_invalid_provider(weather_provider, config_eos, monkeypatch):
|
||||||
"""Test requesting an unsupported weather_provider."""
|
"""Test requesting an unsupported weather_provider."""
|
||||||
settings = {
|
settings = {
|
||||||
"weather_provider": "<invalid>",
|
"weather": {
|
||||||
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
|
"weather_provider": "<invalid>",
|
||||||
|
"provider_settings": {
|
||||||
|
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
assert weather_provider.enabled() == False
|
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 = get_ems()
|
||||||
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
|
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
|
||||||
if from_file:
|
if from_file:
|
||||||
config_eos.weatherimport_json = None
|
config_eos.weather.provider_settings.weatherimport_json = None
|
||||||
assert config_eos.weatherimport_json is None
|
assert config_eos.weather.provider_settings.weatherimport_json is None
|
||||||
else:
|
else:
|
||||||
config_eos.weatherimport_file_path = None
|
config_eos.weather.provider_settings.weatherimport_file_path = None
|
||||||
assert config_eos.weatherimport_file_path is None
|
assert config_eos.weather.provider_settings.weatherimport_file_path is None
|
||||||
weather_provider.clear()
|
weather_provider.clear()
|
||||||
|
|
||||||
# Call the method
|
# Call the method
|
||||||
|
110
tests/testdata/EOS.config.json
vendored
110
tests/testdata/EOS.config.json
vendored
@ -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
|
|
||||||
}
|
|
6
tests/testdata/optimize_input_1.json
vendored
6
tests/testdata/optimize_input_1.json
vendored
@ -26,15 +26,19 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"pv_akku": {
|
"pv_akku": {
|
||||||
|
"device_id": "battery1",
|
||||||
"capacity_wh": 26400,
|
"capacity_wh": 26400,
|
||||||
"max_charge_power_w": 5000,
|
"max_charge_power_w": 5000,
|
||||||
"initial_soc_percentage": 80,
|
"initial_soc_percentage": 80,
|
||||||
"min_soc_percentage": 15
|
"min_soc_percentage": 15
|
||||||
},
|
},
|
||||||
"inverter": {
|
"inverter": {
|
||||||
"max_power_wh": 10000
|
"device_id": "inverter1",
|
||||||
|
"max_power_wh": 10000,
|
||||||
|
"battery": "battery1"
|
||||||
},
|
},
|
||||||
"eauto": {
|
"eauto": {
|
||||||
|
"device_id": "ev1",
|
||||||
"capacity_wh": 60000,
|
"capacity_wh": 60000,
|
||||||
"charging_efficiency": 0.95,
|
"charging_efficiency": 0.95,
|
||||||
"discharging_efficiency": 1.0,
|
"discharging_efficiency": 1.0,
|
||||||
|
8
tests/testdata/optimize_input_2.json
vendored
8
tests/testdata/optimize_input_2.json
vendored
@ -154,6 +154,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"pv_akku": {
|
"pv_akku": {
|
||||||
|
"device_id": "battery1",
|
||||||
"capacity_wh": 26400,
|
"capacity_wh": 26400,
|
||||||
"initial_soc_percentage": 80,
|
"initial_soc_percentage": 80,
|
||||||
"min_soc_percentage": 0
|
"min_soc_percentage": 0
|
||||||
@ -162,13 +163,20 @@
|
|||||||
"max_power_wh": 10000
|
"max_power_wh": 10000
|
||||||
},
|
},
|
||||||
"eauto": {
|
"eauto": {
|
||||||
|
"device_id": "ev1",
|
||||||
"capacity_wh": 60000,
|
"capacity_wh": 60000,
|
||||||
"charging_efficiency": 0.95,
|
"charging_efficiency": 0.95,
|
||||||
"max_charge_power_w": 11040,
|
"max_charge_power_w": 11040,
|
||||||
"initial_soc_percentage": 5,
|
"initial_soc_percentage": 5,
|
||||||
"min_soc_percentage": 80
|
"min_soc_percentage": 80
|
||||||
},
|
},
|
||||||
|
"inverter": {
|
||||||
|
"device_id": "inverter1",
|
||||||
|
"max_power_wh": 10000,
|
||||||
|
"battery": "battery1"
|
||||||
|
},
|
||||||
"dishwasher": {
|
"dishwasher": {
|
||||||
|
"device_id": "dishwasher1",
|
||||||
"consumption_wh": 5000,
|
"consumption_wh": 5000,
|
||||||
"duration_h": 2
|
"duration_h": 2
|
||||||
},
|
},
|
||||||
|
1
tests/testdata/optimize_result_1.json
vendored
1
tests/testdata/optimize_result_1.json
vendored
@ -557,6 +557,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"eauto_obj": {
|
"eauto_obj": {
|
||||||
|
"device_id": "ev1",
|
||||||
"charge_array": [
|
"charge_array": [
|
||||||
1.0,
|
1.0,
|
||||||
1.0,
|
1.0,
|
||||||
|
1
tests/testdata/optimize_result_2.json
vendored
1
tests/testdata/optimize_result_2.json
vendored
@ -606,6 +606,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"eauto_obj": {
|
"eauto_obj": {
|
||||||
|
"device_id": "ev1",
|
||||||
"charge_array": [
|
"charge_array": [
|
||||||
1.0,
|
1.0,
|
||||||
1.0,
|
1.0,
|
||||||
|
1
tests/testdata/optimize_result_2_full.json
vendored
1
tests/testdata/optimize_result_2_full.json
vendored
@ -606,6 +606,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"eauto_obj": {
|
"eauto_obj": {
|
||||||
|
"device_id": "ev1",
|
||||||
"charge_array": [
|
"charge_array": [
|
||||||
1.0,
|
1.0,
|
||||||
1.0,
|
1.0,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user