Nested config, devices registry

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

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

View File

@ -205,309 +205,29 @@ Returns:
**Parameters**:
- `server_eos_host` (query, optional): EOS server IP address.
- `general` (query, optional): No description provided.
- `server_eos_port` (query, optional): EOS server IP port number.
- `logging` (query, optional): No description provided.
- `server_eos_verbose` (query, optional): Enable debug output
- `devices` (query, optional): No description provided.
- `server_eos_startup_eosdash` (query, optional): EOS server to start EOSdash server.
- `measurement` (query, optional): No description provided.
- `server_eosdash_host` (query, optional): EOSdash server IP address.
- `optimization` (query, optional): No description provided.
- `server_eosdash_port` (query, optional): EOSdash server IP port number.
- `prediction` (query, optional): No description provided.
- `weatherimport_file_path` (query, optional): Path to the file to import weather data from.
- `elecprice` (query, optional): No description provided.
- `weatherimport_json` (query, optional): JSON string, dictionary of weather forecast value lists.
- `load` (query, optional): No description provided.
- `weather_provider` (query, optional): Weather provider id of provider to be used.
- `pvforecast` (query, optional): No description provided.
- `pvforecastimport_file_path` (query, optional): Path to the file to import PV forecast data from.
- `weather` (query, optional): No description provided.
- `pvforecastimport_json` (query, optional): JSON string, dictionary of PV forecast value lists.
- `server` (query, optional): No description provided.
- `pvforecast_provider` (query, optional): PVForecast provider id of provider to be used.
- `pvforecast0_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast0_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast0_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast0_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast0_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast0_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast0_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast0_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast0_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast0_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast0_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast0_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast0_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast0_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast0_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast0_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast1_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast1_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast1_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast1_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast1_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast1_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast1_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast1_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast1_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast1_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast1_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast1_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast1_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast1_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast1_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast1_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast2_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast2_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast2_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast2_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast2_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast2_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast2_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast2_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast2_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast2_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast2_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast2_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast2_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast2_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast2_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast2_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast3_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast3_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast3_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast3_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast3_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast3_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast3_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast3_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast3_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast3_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast3_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast3_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast3_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast3_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast3_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast3_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast4_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast4_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast4_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast4_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast4_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast4_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast4_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast4_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast4_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast4_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast4_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast4_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast4_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast4_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast4_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast4_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast5_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast5_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast5_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast5_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast5_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast5_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast5_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast5_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast5_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast5_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast5_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast5_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast5_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast5_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast5_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast5_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `load_import_file_path` (query, optional): Path to the file to import load data from.
- `load_import_json` (query, optional): JSON string, dictionary of load forecast value lists.
- `loadakkudoktor_year_energy` (query, optional): Yearly energy consumption (kWh).
- `load_provider` (query, optional): Load provider id of provider to be used.
- `elecpriceimport_file_path` (query, optional): Path to the file to import elecprice data from.
- `elecpriceimport_json` (query, optional): JSON string, dictionary of electricity price forecast value lists.
- `elecprice_provider` (query, optional): Electricity price provider id of provider to be used.
- `elecprice_charges_kwh` (query, optional): Electricity price charges (€/kWh).
- `prediction_hours` (query, optional): Number of hours into the future for predictions
- `prediction_historic_hours` (query, optional): Number of hours into the past for historical predictions data
- `latitude` (query, optional): Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)
- `longitude` (query, optional): Longitude in decimal degrees, within -180 to 180 (°)
- `optimization_hours` (query, optional): Number of hours into the future for optimizations.
- `optimization_penalty` (query, optional): Penalty factor used in optimization.
- `optimization_ev_available_charge_rates_percent` (query, optional): Charge rates available for the EV in percent of maximum charge.
- `measurement_load0_name` (query, optional): Name of the load0 source (e.g. 'Household', 'Heat Pump')
- `measurement_load1_name` (query, optional): Name of the load1 source (e.g. 'Household', 'Heat Pump')
- `measurement_load2_name` (query, optional): Name of the load2 source (e.g. 'Household', 'Heat Pump')
- `measurement_load3_name` (query, optional): Name of the load3 source (e.g. 'Household', 'Heat Pump')
- `measurement_load4_name` (query, optional): Name of the load4 source (e.g. 'Household', 'Heat Pump')
- `battery_provider` (query, optional): Id of Battery simulation provider.
- `battery_capacity` (query, optional): Battery capacity [Wh].
- `battery_initial_soc` (query, optional): Battery initial state of charge [%].
- `battery_soc_min` (query, optional): Battery minimum state of charge [%].
- `battery_soc_max` (query, optional): Battery maximum state of charge [%].
- `battery_charging_efficiency` (query, optional): Battery charging efficiency [%].
- `battery_discharging_efficiency` (query, optional): Battery discharging efficiency [%].
- `battery_max_charging_power` (query, optional): Battery maximum charge power [W].
- `bev_provider` (query, optional): Id of Battery Electric Vehicle simulation provider.
- `bev_capacity` (query, optional): Battery Electric Vehicle capacity [Wh].
- `bev_initial_soc` (query, optional): Battery Electric Vehicle initial state of charge [%].
- `bev_soc_max` (query, optional): Battery Electric Vehicle maximum state of charge [%].
- `bev_charging_efficiency` (query, optional): Battery Electric Vehicle charging efficiency [%].
- `bev_discharging_efficiency` (query, optional): Battery Electric Vehicle discharging efficiency [%].
- `bev_max_charging_power` (query, optional): Battery Electric Vehicle maximum charge power [W].
- `dishwasher_provider` (query, optional): Id of Dish Washer simulation provider.
- `dishwasher_consumption` (query, optional): Dish Washer energy consumption [Wh].
- `dishwasher_duration` (query, optional): Dish Washer usage duration [h].
- `inverter_provider` (query, optional): Id of PV Inverter simulation provider.
- `inverter_power_max` (query, optional): Inverter maximum power [W].
- `logging_level_default` (query, optional): EOS default logging level.
- `data_folder_path` (query, optional): Path to EOS data directory.
- `data_output_subpath` (query, optional): Sub-path for the EOS output data directory.
- `data_cache_subpath` (query, optional): Sub-path for the EOS cache data directory.
- `utils` (query, optional): No description provided.
**Responses**:

View File

@ -14,3 +14,4 @@ platformdirs==4.3.6
pvlib==0.11.2
pydantic==2.10.5
statsmodels==0.14.4
pydantic-settings==2.7.0

View File

@ -79,9 +79,11 @@ def generate_config_md() -> str:
Returns:
str: The Markdown representation of the configuration spec.
"""
# FIXME: Support for nested
configs = {}
config_keys = config_eos.config_keys
config_keys_read_only = config_eos.config_keys_read_only
config_keys = config_eos.model_fields_set
# config_keys_read_only = config_eos.config_keys_read_only
config_keys_read_only: list[str] = []
for config_key in config_keys:
config = {}
config["name"] = config_key

View File

@ -30,42 +30,52 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
"""
# Make a config
settings = {
# -- General --
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
# -- Predictions --
"prediction": {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
},
# PV Forecast
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
"pvforecast4_peakpower": None,
"pvforecast": {
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
"pvforecast4_peakpower": None,
},
# Weather Forecast
"weather_provider": "ClearOutside",
"weather": {
"weather_provider": "ClearOutside",
},
# Electricity Price Forecast
"elecprice_provider": "ElecPriceAkkudoktor",
"elecprice": {
"elecprice_provider": "ElecPriceAkkudoktor",
},
# Load Forecast
"load_provider": "LoadAkkudoktor",
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
"load": {
"load_provider": "LoadAkkudoktor",
"provider_settings": {
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
},
},
# -- Simulations --
}
config_eos = get_config()
@ -129,11 +139,14 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
},
"pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400,
"initial_soc_percentage": 15,
"min_soc_percentage": 15,
},
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery": "battery1"},
"eauto": {
"device_id": "ev1",
"min_soc_percentage": 50,
"capacity_wh": 60000,
"charging_efficiency": 0.95,
@ -283,11 +296,14 @@ def prepare_optimization_parameters() -> OptimizationParameters:
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
},
"pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400,
"initial_soc_percentage": 15,
"min_soc_percentage": 15,
},
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery": "battery1"},
"eauto": {
"device_id": "ev1",
"min_soc_percentage": 50,
"capacity_wh": 60000,
"charging_efficiency": 0.95,
@ -330,7 +346,9 @@ def run_optimization(
# Initialize the optimization problem using the default configuration
config_eos = get_config()
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48})
config_eos.merge_settings_from_dict(
{"prediction": {"prediction_hours": 48}, "optimization": {"optimization_hours": 48}}
)
opt_class = optimization_problem(verbose=verbose, fixed_seed=seed)
# Perform the optimisation based on the provided parameters and start hour

View File

@ -16,32 +16,36 @@ prediction_eos = get_prediction()
def config_pvforecast() -> dict:
"""Configure settings for PV forecast."""
settings = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
"pvforecast4_peakpower": None,
"prediction": {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
},
"pvforecast": {
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
"pvforecast4_peakpower": None,
},
}
return settings
@ -49,10 +53,13 @@ def config_pvforecast() -> dict:
def config_weather() -> dict:
"""Configure settings for weather forecast."""
settings = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"prediction": {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
},
"weather": dict(),
}
return settings
@ -60,10 +67,13 @@ def config_weather() -> dict:
def config_elecprice() -> dict:
"""Configure settings for electricity price forecast."""
settings = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"prediction": {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
},
"elecprice": dict(),
}
return settings
@ -71,10 +81,12 @@ def config_elecprice() -> dict:
def config_load() -> dict:
"""Configure settings for load forecast."""
settings = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"prediction": {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
}
}
return settings
@ -96,17 +108,17 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
print(f"\nProvider ID: {provider_id}")
if provider_id in ("PVForecastAkkudoktor",):
settings = config_pvforecast()
settings["pvforecast_provider"] = provider_id
settings["pvforecast"]["pvforecast_provider"] = provider_id
elif provider_id in ("BrightSky", "ClearOutside"):
settings = config_weather()
settings["weather_provider"] = provider_id
settings["weather"]["weather_provider"] = provider_id
elif provider_id in ("ElecPriceAkkudoktor",):
settings = config_elecprice()
settings["elecprice_provider"] = provider_id
settings["elecprice"]["elecprice_provider"] = provider_id
elif provider_id in ("LoadAkkudoktor",):
settings = config_elecprice()
settings["loadakkudoktor_year_energy"] = 1000
settings["load_provider"] = provider_id
settings["load"]["loadakkudoktor_year_energy"] = 1000
settings["load"]["load_provider"] = provider_id
else:
raise ValueError(f"Unknown provider '{provider_id}'.")
config_eos.merge_settings_from_dict(settings)

View File

@ -12,31 +12,34 @@ Key features:
import os
import shutil
from pathlib import Path
from typing import Any, ClassVar, List, Optional
from typing import Any, ClassVar, Optional, Type
from platformdirs import user_config_dir, user_data_dir
from pydantic import Field, ValidationError, computed_field
from pydantic import Field, computed_field
from pydantic_settings import (
BaseSettings,
JsonConfigSettingsSource,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
from pydantic_settings.sources import ConfigFileSourceMixin
# settings
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.logsettings import LoggingCommonSettings
from akkudoktoreos.devices.devices import DevicesCommonSettings
from akkudoktoreos.core.pydantic import merge_models
from akkudoktoreos.devices.settings import DevicesCommonSettings
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
from akkudoktoreos.prediction.prediction import PredictionCommonSettings
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
from akkudoktoreos.prediction.weather import WeatherCommonSettings
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
from akkudoktoreos.server.server import ServerCommonSettings
from akkudoktoreos.utils.utils import UtilsCommonSettings
from akkudoktoreos.utils.utils import UtilsCommonSettings, classproperty
logger = get_logger(__name__)
@ -67,11 +70,11 @@ class ConfigCommonSettings(SettingsBaseModel):
)
data_output_subpath: Optional[Path] = Field(
"output", description="Sub-path for the EOS output data directory."
default="output", description="Sub-path for the EOS output data directory."
)
data_cache_subpath: Optional[Path] = Field(
"cache", description="Sub-path for the EOS cache data directory."
default="cache", description="Sub-path for the EOS cache data directory."
)
# Computed fields
@ -89,31 +92,51 @@ class ConfigCommonSettings(SettingsBaseModel):
return get_absolute_path(self.data_folder_path, self.data_cache_subpath)
class SettingsEOS(
ConfigCommonSettings,
LoggingCommonSettings,
DevicesCommonSettings,
MeasurementCommonSettings,
OptimizationCommonSettings,
PredictionCommonSettings,
ElecPriceCommonSettings,
ElecPriceImportCommonSettings,
LoadCommonSettings,
LoadAkkudoktorCommonSettings,
LoadImportCommonSettings,
PVForecastCommonSettings,
PVForecastImportCommonSettings,
WeatherCommonSettings,
WeatherImportCommonSettings,
ServerCommonSettings,
UtilsCommonSettings,
):
"""Settings for all EOS."""
class SettingsEOS(BaseSettings):
"""Settings for all EOS.
pass
Used by updating the configuration with specific settings only.
"""
general: Optional[ConfigCommonSettings] = None
logging: Optional[LoggingCommonSettings] = None
devices: Optional[DevicesCommonSettings] = None
measurement: Optional[MeasurementCommonSettings] = None
optimization: Optional[OptimizationCommonSettings] = None
prediction: Optional[PredictionCommonSettings] = None
elecprice: Optional[ElecPriceCommonSettings] = None
load: Optional[LoadCommonSettings] = None
pvforecast: Optional[PVForecastCommonSettings] = None
weather: Optional[WeatherCommonSettings] = None
server: Optional[ServerCommonSettings] = None
utils: Optional[UtilsCommonSettings] = None
model_config = SettingsConfigDict(
env_nested_delimiter="__", nested_model_default_partial_update=True, env_prefix="EOS_"
)
class ConfigEOS(SingletonMixin, SettingsEOS):
class SettingsEOSDefaults(SettingsEOS):
"""Settings for all of EOS with defaults.
Used by ConfigEOS instance to make all fields available.
"""
general: ConfigCommonSettings = ConfigCommonSettings()
logging: LoggingCommonSettings = LoggingCommonSettings()
devices: DevicesCommonSettings = DevicesCommonSettings()
measurement: MeasurementCommonSettings = MeasurementCommonSettings()
optimization: OptimizationCommonSettings = OptimizationCommonSettings()
prediction: PredictionCommonSettings = PredictionCommonSettings()
elecprice: ElecPriceCommonSettings = ElecPriceCommonSettings()
load: LoadCommonSettings = LoadCommonSettings()
pvforecast: PVForecastCommonSettings = PVForecastCommonSettings()
weather: WeatherCommonSettings = WeatherCommonSettings()
server: ServerCommonSettings = ServerCommonSettings()
utils: UtilsCommonSettings = UtilsCommonSettings()
class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""Singleton configuration handler for the EOS application.
ConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic
@ -143,8 +166,6 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
in one part of the application reflects across all references to this class.
Attributes:
_settings (ClassVar[SettingsEOS]): Holds application-wide settings.
_file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file.
config_folder_path (Optional[Path]): Path to the configuration directory.
config_file_path (Optional[Path]): Path to the configuration file.
@ -155,7 +176,7 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
To initialize and access configuration attributes (only one instance is created):
```python
config_eos = ConfigEOS() # Always returns the same instance
print(config_eos.prediction_hours) # Access a setting from the loaded configuration
print(config_eos.prediction.prediction_hours) # Access a setting from the loaded configuration
```
"""
@ -167,111 +188,126 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
ENCODING: ClassVar[str] = "UTF-8"
CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json"
_settings: ClassVar[Optional[SettingsEOS]] = None
_file_settings: ClassVar[Optional[SettingsEOS]] = None
_config_folder_path: ClassVar[Optional[Path]] = None
_config_file_path: ClassVar[Optional[Path]] = None
_config_folder_path: Optional[Path] = None
_config_file_path: Optional[Path] = None
@classmethod
def settings_customise_sources(
cls,
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
"""Customizes the order and handling of settings sources for a Pydantic BaseSettings subclass.
This method determines the sources for application configuration settings, including
environment variables, dotenv files, JSON configuration files, and file secrets.
It ensures that a default configuration file exists and creates one if necessary.
Args:
settings_cls (Type[BaseSettings]): The Pydantic BaseSettings class for which sources are customized.
init_settings (PydanticBaseSettingsSource): The initial settings source, typically passed at runtime.
env_settings (PydanticBaseSettingsSource): Settings sourced from environment variables.
dotenv_settings (PydanticBaseSettingsSource): Settings sourced from a dotenv file.
file_secret_settings (PydanticBaseSettingsSource): Settings sourced from secret files.
Returns:
tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
Behavior:
1. Checks for the existence of a JSON configuration file in the expected location.
2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a
default configuration file to the location. If the copy fails, uses the default configuration file directly.
3. Creates a `JsonConfigSettingsSource` for both the configuration file and the default configuration file.
4. Updates class attributes `_config_folder_path` and `_config_file_path` to reflect the determined paths.
5. Returns a tuple containing all provided and newly created settings sources in the desired order.
Notes:
- This method logs a warning if the default configuration file cannot be copied.
- It ensures that a fallback to the default configuration file is always possible.
"""
file_settings: Optional[ConfigFileSourceMixin] = None
config_file, exists = cls._get_config_file_path()
config_dir = config_file.parent
if not exists:
config_dir.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(cls.config_default_file_path, config_file)
except Exception as exc:
logger.warning(f"Could not copy default config: {exc}. Using default config...")
config_file = cls.config_default_file_path
config_dir = config_file.parent
file_settings = JsonConfigSettingsSource(settings_cls, json_file=config_file)
default_settings = JsonConfigSettingsSource(
settings_cls, json_file=cls.config_default_file_path
)
cls._config_folder_path = config_dir
cls._config_file_path = config_file
return (
init_settings,
env_settings,
dotenv_settings,
file_settings,
file_secret_settings,
default_settings,
)
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def config_folder_path(self) -> Optional[Path]:
"""Path to EOS configuration directory."""
return self._config_folder_path
@computed_field # type: ignore[prop-decorator]
@property
def config_file_path(self) -> Optional[Path]:
"""Path to EOS configuration file."""
return self._config_file_path
@computed_field # type: ignore[prop-decorator]
@property
def config_default_file_path(self) -> Path:
@classmethod
@classproperty
def config_default_file_path(cls) -> Path:
"""Compute the default config file path."""
return self.package_root_path.joinpath("data/default.config.json")
return cls.package_root_path.joinpath("data/default.config.json")
@computed_field # type: ignore[prop-decorator]
@property
def package_root_path(self) -> Path:
@classmethod
@classproperty
def package_root_path(cls) -> Path:
"""Compute the package root path."""
return Path(__file__).parent.parent.resolve()
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def config_keys(self) -> List[str]:
"""Returns the keys of all fields in the configuration."""
key_list = []
key_list.extend(list(self.model_fields.keys()))
key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys()))
return key_list
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def config_keys_read_only(self) -> List[str]:
"""Returns the keys of all read only fields in the configuration."""
key_list = []
key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys()))
return key_list
def __init__(self) -> None:
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initializes the singleton ConfigEOS instance.
Configuration data is loaded from a configuration file or a default one is created if none
exists.
"""
super().__init__()
self.from_config_file()
self.update()
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
self._create_initial_config_file()
self._update_data_folder_path()
@property
def settings(self) -> Optional[SettingsEOS]:
"""Returns global settings for EOS.
def _setup(self, *args: Any, **kwargs: Any) -> None:
"""Re-initialize global settings."""
SettingsEOSDefaults.__init__(self, *args, **kwargs)
self._create_initial_config_file()
self._update_data_folder_path()
Settings generally provide configuration for EOS and are typically set only once.
Returns:
SettingsEOS: The settings for EOS or None.
"""
return ConfigEOS._settings
@classmethod
def _merge_and_update_settings(cls, settings: SettingsEOS) -> None:
"""Merge new and available settings.
Args:
settings (SettingsEOS): The new settings to apply.
"""
for key in SettingsEOS.model_fields:
if value := getattr(settings, key, None):
setattr(cls._settings, key, value)
def merge_settings(self, settings: SettingsEOS, force: Optional[bool] = None) -> None:
def merge_settings(self, settings: SettingsEOS) -> None:
"""Merges the provided settings into the global settings for EOS, with optional overwrite.
Args:
settings (SettingsEOS): The settings to apply globally.
force (Optional[bool]): If True, overwrites the existing settings completely.
If False, the new settings are merged to the existing ones with priority for
the new ones. Defaults to False.
Raises:
ValueError: If settings are already set and `force` is not True or
if the `settings` is not a `SettingsEOS` instance.
ValueError: If the `settings` is not a `SettingsEOS` instance.
"""
if not isinstance(settings, SettingsEOS):
raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.")
if ConfigEOS._settings is None or force:
ConfigEOS._settings = settings
else:
self._merge_and_update_settings(settings)
# Update configuration after merging
self.update()
self.merge_settings_from_dict(settings.model_dump())
def merge_settings_from_dict(self, data: dict) -> None:
"""Merges the provided dictionary data into the current instance.
@ -289,141 +325,78 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
Example:
>>> config = get_config()
>>> new_data = {"prediction_hours": 24, "server_eos_port": 8000}
>>> new_data = {"prediction": {"prediction_hours": 24}, "server": {"server_eos_port": 8000}}
>>> config.merge_settings_from_dict(new_data)
"""
# Create new settings instance with reset optional fields and merged data
settings = SettingsEOS.from_dict(data)
self.merge_settings(settings)
self._setup(**merge_models(self, data))
def reset_settings(self) -> None:
"""Reset all available settings.
"""Reset all changed settings to environment/config file defaults.
This functions basically deletes the settings provided before.
"""
ConfigEOS._settings = None
self._setup()
def _create_initial_config_file(self) -> None:
if self.config_file_path is not None and not self.config_file_path.exists():
self.config_file_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.config_file_path, "w") as f:
f.write(self.model_dump_json(indent=4))
def _update_data_folder_path(self) -> None:
"""Updates path to the data directory."""
# From Settings
if self.settings and (data_dir := self.settings.data_folder_path):
if data_dir := self.general.data_folder_path:
try:
data_dir.mkdir(parents=True, exist_ok=True)
self.data_folder_path = data_dir
self.general.data_folder_path = data_dir
return
except:
pass
except Exception as e:
logger.warning(f"Could not setup data dir: {e}")
# From EOS_DIR env
env_dir = os.getenv(self.EOS_DIR)
if env_dir is not None:
if env_dir := os.getenv(self.EOS_DIR):
try:
data_dir = Path(env_dir).resolve()
data_dir.mkdir(parents=True, exist_ok=True)
self.data_folder_path = data_dir
self.general.data_folder_path = data_dir
return
except:
pass
# From configuration file
if self._file_settings and (data_dir := self._file_settings.data_folder_path):
try:
data_dir.mkdir(parents=True, exist_ok=True)
self.data_folder_path = data_dir
return
except:
pass
except Exception as e:
logger.warning(f"Could not setup data dir: {e}")
# From platform specific default path
try:
data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR))
if data_dir is not None:
data_dir.mkdir(parents=True, exist_ok=True)
self.data_folder_path = data_dir
self.general.data_folder_path = data_dir
return
except:
pass
except Exception as e:
logger.warning(f"Could not setup data dir: {e}")
# Current working directory
data_dir = Path.cwd()
self.data_folder_path = data_dir
self.general.data_folder_path = data_dir
def _get_config_file_path(self) -> tuple[Path, bool]:
@classmethod
def _get_config_file_path(cls) -> tuple[Path, bool]:
"""Finds the a valid configuration file or returns the desired path for a new config file.
Returns:
tuple[Path, bool]: The path to the configuration directory and if there is already a config file there
"""
config_dirs = []
env_base_dir = os.getenv(self.EOS_DIR)
env_config_dir = os.getenv(self.EOS_CONFIG_DIR)
env_base_dir = os.getenv(cls.EOS_DIR)
env_config_dir = os.getenv(cls.EOS_CONFIG_DIR)
env_dir = get_absolute_path(env_base_dir, env_config_dir)
logger.debug(f"Envionment config dir: '{env_dir}'")
logger.debug(f"Environment config dir: '{env_dir}'")
if env_dir is not None:
config_dirs.append(env_dir.resolve())
config_dirs.append(Path(user_config_dir(self.APP_NAME)))
config_dirs.append(Path(user_config_dir(cls.APP_NAME)))
config_dirs.append(Path.cwd())
for cdir in config_dirs:
cfile = cdir.joinpath(self.CONFIG_FILE_NAME)
cfile = cdir.joinpath(cls.CONFIG_FILE_NAME)
if cfile.exists():
logger.debug(f"Found config file: '{cfile}'")
return cfile, True
return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False
def settings_from_config_file(self) -> tuple[SettingsEOS, Path]:
"""Load settings from the configuration file.
If the config file does not exist, it will be created.
Returns:
tuple of settings and path
settings (SettingsEOS): The settings defined by the EOS configuration file.
path (pathlib.Path): The path of the configuration file.
Raises:
ValueError: If the configuration file is invalid or incomplete.
"""
config_file, exists = self._get_config_file_path()
config_dir = config_file.parent
# Create config directory and copy default config if file does not exist
if not exists:
config_dir.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(self.config_default_file_path, config_file)
except Exception as exc:
logger.warning(f"Could not copy default config: {exc}. Using default config...")
config_file = self.config_default_file_path
config_dir = config_file.parent
# Load and validate the configuration file
with config_file.open("r", encoding=self.ENCODING) as f_in:
try:
json_str = f_in.read()
settings = SettingsEOS.model_validate_json(json_str)
except ValidationError as exc:
raise ValueError(f"Configuration '{config_file}' is incomplete or not valid: {exc}")
return settings, config_file
def from_config_file(self) -> tuple[SettingsEOS, Path]:
"""Load the configuration file settings for EOS.
Returns:
tuple of settings and path
settings (SettingsEOS): The settings defined by the EOS configuration file.
path (pathlib.Path): The path of the configuration file.
Raises:
ValueError: If the configuration file is invalid or incomplete.
"""
# Load settings from config file
ConfigEOS._file_settings, config_file = self.settings_from_config_file()
# Update configuration in memory
self.update()
# Everything worked, remember the values
self._config_folder_path = config_file.parent
self._config_file_path = config_file
return ConfigEOS._file_settings, config_file
return config_dirs[0].joinpath(cls.CONFIG_FILE_NAME), False
def to_config_file(self) -> None:
"""Saves the current configuration to the configuration file.
@ -436,74 +409,21 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
if not self.config_file_path:
raise ValueError("Configuration file path unknown.")
with self.config_file_path.open("w", encoding=self.ENCODING) as f_out:
try:
json_str = super().to_json()
# Write to file
f_out.write(json_str)
# Also remember as actual settings
ConfigEOS._file_settings = SettingsEOS.model_validate_json(json_str)
except ValidationError as exc:
raise ValueError(f"Could not update '{self.config_file_path}': {exc}")
def _config_value(self, key: str) -> Any:
"""Retrieves the configuration value for a specific key, following a priority order.
Values are fetched in the following order:
1. Settings.
2. Environment variables.
3. EOS configuration file.
4. Current configuration.
5. Field default constants.
Args:
key (str): The configuration key to retrieve.
Returns:
Any: The configuration value, or None if not found.
"""
# Settings
if ConfigEOS._settings:
if (value := getattr(self.settings, key, None)) is not None:
return value
# Environment variables
if (value := os.getenv(key)) is not None:
try:
return float(value)
except ValueError:
return value
# EOS configuration file.
if self._file_settings:
if (value := getattr(self._file_settings, key, None)) is not None:
return value
# Current configuration - key is valid as called by update().
if (value := getattr(self, key, None)) is not None:
return value
# Field default constants
if (value := ConfigEOS.model_fields[key].default) is not None:
return value
logger.debug(f"Value for configuration key '{key}' not found or is {value}")
return None
json_str = super().model_dump_json()
f_out.write(json_str)
def update(self) -> None:
"""Updates all configuration fields.
This method updates all configuration fields using the following order for value retrieval:
1. Settings.
1. Current settings.
2. Environment variables.
3. EOS configuration file.
4. Current configuration.
5. Field default constants.
4. Field default constants.
The first non None value in priority order is taken.
"""
self._update_data_folder_path()
for key in self.model_fields:
setattr(self, key, self._config_value(key))
self._setup(**self.model_dump())
def get_config() -> ConfigEOS:

View File

@ -4,10 +4,6 @@ from akkudoktoreos.core.pydantic import PydanticBaseModel
class SettingsBaseModel(PydanticBaseModel):
"""Base model class for all settings configurations.
Note:
Settings property names shall be disjunctive to all existing settings' property names.
"""
"""Base model class for all settings configurations."""
pass

View File

@ -265,6 +265,12 @@ class SingletonMixin:
class MySingletonModel(SingletonMixin, PydanticBaseModel):
name: str
# implement __init__ to avoid re-initialization of parent class PydanticBaseModel:
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
instance1 = MySingletonModel(name="Instance 1")
instance2 = MySingletonModel(name="Instance 2")

View File

@ -1110,7 +1110,7 @@ class DataProvider(SingletonMixin, DataSequence):
To be implemented by derived classes.
"""
return self.provider_id() == self.config.abstract_provider
raise NotImplementedError()
@abstractmethod
def _update_data(self, force_update: Optional[bool] = False) -> None:
@ -1121,6 +1121,11 @@ class DataProvider(SingletonMixin, DataSequence):
"""
pass
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def update_data(
self,
force_enable: Optional[bool] = False,
@ -1595,6 +1600,11 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
)
return list(key_set)
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def __getitem__(self, key: str) -> pd.Series:
"""Retrieve a Pandas Series for a specified key from the data in each DataProvider.

View File

@ -169,6 +169,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def set_parameters(
self,
parameters: EnergieManagementSystemParameters,
@ -193,9 +198,9 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
self.ev = ev
self.home_appliance = home_appliance
self.inverter = inverter
self.ac_charge_hours = np.full(self.config.prediction_hours, 0.0)
self.dc_charge_hours = np.full(self.config.prediction_hours, 1.0)
self.ev_charge_hours = np.full(self.config.prediction_hours, 0.0)
self.ac_charge_hours = np.full(self.config.prediction.prediction_hours, 0.0)
self.dc_charge_hours = np.full(self.config.prediction.prediction_hours, 1.0)
self.ev_charge_hours = np.full(self.config.prediction.prediction_hours, 0.0)
def set_akku_discharge_hours(self, ds: np.ndarray) -> None:
if self.battery is not None:
@ -246,11 +251,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
error_msg = "Start datetime unknown."
logger.error(error_msg)
raise ValueError(error_msg)
if self.config.prediction_hours is None:
if self.config.prediction.prediction_hours is None:
error_msg = "Prediction hours unknown."
logger.error(error_msg)
raise ValueError(error_msg)
if self.config.optimisation_hours is None:
if self.config.prediction.optimisation_hours is None:
error_msg = "Optimisation hours unknown."
logger.error(error_msg)
raise ValueError(error_msg)

View File

@ -35,6 +35,21 @@ from pydantic import (
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]:
def deep_update(source_dict: dict[str, Any], update_dict: dict[str, Any]) -> dict[str, Any]:
for key, value in source_dict.items():
if isinstance(value, dict) and isinstance(update_dict.get(key), dict):
update_dict[key] = deep_update(update_dict[key], value)
else:
update_dict[key] = value
return update_dict
source_dict = source.model_dump(exclude_unset=True)
merged_dict = deep_update(source_dict, update_dict)
return merged_dict
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
"""Custom type adapter for Pendulum DateTime fields."""

View File

@ -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
}

View File

@ -1,11 +1,14 @@
from typing import Any, Optional
import numpy as np
from pydantic import BaseModel, Field, field_validator
from pydantic import Field, field_validator
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ParametersBaseModel
from akkudoktoreos.devices.devicesabc import DeviceBase
from akkudoktoreos.devices.devicesabc import (
DeviceBase,
DeviceOptimizeResult,
DeviceParameters,
)
from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__)
@ -25,9 +28,10 @@ def initial_soc_percentage_field(description: str) -> int:
return Field(default=0, ge=0, le=100, description=description)
class BaseBatteryParameters(ParametersBaseModel):
class BaseBatteryParameters(DeviceParameters):
"""Base class for battery parameters with fields for capacity, efficiency, and state of charge."""
device_id: str = Field(description="ID of battery")
capacity_wh: int = Field(
gt=0, description="An integer representing the capacity of the battery in watt-hours."
)
@ -68,15 +72,17 @@ class SolarPanelBatteryParameters(BaseBatteryParameters):
class ElectricVehicleParameters(BaseBatteryParameters):
"""Parameters specific to an electric vehicle (EV)."""
device_id: str = Field(description="ID of electric vehicle")
discharging_efficiency: float = 1.0
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the current state of charge (SOC) of the battery in percentage."
)
class ElectricVehicleResult(BaseModel):
class ElectricVehicleResult(DeviceOptimizeResult):
"""Result class containing information related to the electric vehicle's charging and discharging behavior."""
device_id: str = Field(description="ID of electric vehicle")
charge_array: list[float] = Field(
description="Hourly charging status (0 for no charging, 1 for charging)."
)
@ -84,7 +90,6 @@ class ElectricVehicleResult(BaseModel):
description="Hourly discharging status (0 for no discharging, 1 for discharging)."
)
discharging_efficiency: float = Field(description="The discharge efficiency as a float..")
hours: int = Field(description="Number of hours in the simulation.")
capacity_wh: int = Field(description="Capacity of the EVs battery in watt-hours.")
charging_efficiency: float = Field(description="Charging efficiency as a float..")
max_charge_power_w: int = Field(description="Maximum charging power in watts.")
@ -103,81 +108,30 @@ class ElectricVehicleResult(BaseModel):
class Battery(DeviceBase):
"""Represents a battery device with methods to simulate energy charging and discharging."""
def __init__(
self,
parameters: Optional[BaseBatteryParameters] = None,
hours: Optional[int] = 24,
provider_id: Optional[str] = None,
):
# Initialize configuration and parameters
self.provider_id = provider_id
self.prefix = "<invalid>"
if self.provider_id == "GenericBattery":
self.prefix = "battery"
elif self.provider_id == "GenericBEV":
self.prefix = "bev"
def __init__(self, parameters: Optional[BaseBatteryParameters] = None):
self.parameters: Optional[BaseBatteryParameters] = None
super().__init__(parameters)
self.parameters = parameters
if hours is None:
self.hours = self.total_hours # TODO where does that come from?
else:
self.hours = hours
self.initialised = False
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
if self.parameters is not None:
self.setup()
def setup(self) -> None:
def _setup(self) -> None:
"""Sets up the battery parameters based on configuration or provided parameters."""
if self.initialised:
return
assert self.parameters is not None
self.capacity_wh = self.parameters.capacity_wh
self.initial_soc_percentage = self.parameters.initial_soc_percentage
self.charging_efficiency = self.parameters.charging_efficiency
self.discharging_efficiency = self.parameters.discharging_efficiency
if self.provider_id:
# Setup from configuration
self.capacity_wh = getattr(self.config, f"{self.prefix}_capacity")
self.initial_soc_percentage = getattr(self.config, f"{self.prefix}_initial_soc")
self.hours = self.total_hours # TODO where does that come from?
self.charging_efficiency = getattr(self.config, f"{self.prefix}_charging_efficiency")
self.discharging_efficiency = getattr(
self.config, f"{self.prefix}_discharging_efficiency"
)
self.max_charge_power_w = getattr(self.config, f"{self.prefix}_max_charging_power")
if self.provider_id == "GenericBattery":
self.min_soc_percentage = getattr(
self.config,
f"{self.prefix}_soc_min",
)
else:
self.min_soc_percentage = 0
self.max_soc_percentage = getattr(
self.config,
f"{self.prefix}_soc_max",
)
elif self.parameters:
# Setup from parameters
self.capacity_wh = self.parameters.capacity_wh
self.initial_soc_percentage = self.parameters.initial_soc_percentage
self.charging_efficiency = self.parameters.charging_efficiency
self.discharging_efficiency = self.parameters.discharging_efficiency
self.max_charge_power_w = self.parameters.max_charge_power_w
# Only assign for storage battery
self.min_soc_percentage = (
self.parameters.min_soc_percentage
if isinstance(self.parameters, SolarPanelBatteryParameters)
else 0
)
self.max_soc_percentage = self.parameters.max_soc_percentage
else:
error_msg = "Parameters and provider ID are missing. Cannot instantiate."
logger.error(error_msg)
raise ValueError(error_msg)
# Only assign for storage battery
self.min_soc_percentage = (
self.parameters.min_soc_percentage
if isinstance(self.parameters, SolarPanelBatteryParameters)
else 0
)
self.max_soc_percentage = self.parameters.max_soc_percentage
# Initialize state of charge
if self.max_charge_power_w is None:
if self.parameters.max_charge_power_w is not None:
self.max_charge_power_w = self.parameters.max_charge_power_w
else:
self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh
self.discharge_array = np.full(self.hours, 1)
self.charge_array = np.full(self.hours, 1)
@ -185,11 +139,10 @@ class Battery(DeviceBase):
self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh
self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh
self.initialised = True
def to_dict(self) -> dict[str, Any]:
"""Converts the object to a dictionary representation."""
return {
"device_id": self.device_id,
"capacity_wh": self.capacity_wh,
"initial_soc_percentage": self.initial_soc_percentage,
"soc_wh": self.soc_wh,

View File

@ -1,307 +1,189 @@
from typing import Any, ClassVar, Dict, Optional, Union
from typing import Optional
import numpy as np
from numpydantic import NDArray, Shape
from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.devices.battery import Battery
from akkudoktoreos.devices.devicesabc import DevicesBase
from akkudoktoreos.devices.generic import HomeAppliance
from akkudoktoreos.devices.inverter import Inverter
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
from akkudoktoreos.utils.datetimeutil import to_duration
from akkudoktoreos.devices.settings import DevicesCommonSettings
logger = get_logger(__name__)
class DevicesCommonSettings(SettingsBaseModel):
"""Base configuration for devices simulation settings."""
# Battery
# -------
battery_provider: Optional[str] = Field(
default=None, description="Id of Battery simulation provider."
)
battery_capacity: Optional[int] = Field(default=None, description="Battery capacity [Wh].")
battery_initial_soc: Optional[int] = Field(
default=None, description="Battery initial state of charge [%]."
)
battery_soc_min: Optional[int] = Field(
default=None, description="Battery minimum state of charge [%]."
)
battery_soc_max: Optional[int] = Field(
default=None, description="Battery maximum state of charge [%]."
)
battery_charging_efficiency: Optional[float] = Field(
default=None, description="Battery charging efficiency [%]."
)
battery_discharging_efficiency: Optional[float] = Field(
default=None, description="Battery discharging efficiency [%]."
)
battery_max_charging_power: Optional[int] = Field(
default=None, description="Battery maximum charge power [W]."
)
# Battery Electric Vehicle
# ------------------------
bev_provider: Optional[str] = Field(
default=None, description="Id of Battery Electric Vehicle simulation provider."
)
bev_capacity: Optional[int] = Field(
default=None, description="Battery Electric Vehicle capacity [Wh]."
)
bev_initial_soc: Optional[int] = Field(
default=None, description="Battery Electric Vehicle initial state of charge [%]."
)
bev_soc_max: Optional[int] = Field(
default=None, description="Battery Electric Vehicle maximum state of charge [%]."
)
bev_charging_efficiency: Optional[float] = Field(
default=None, description="Battery Electric Vehicle charging efficiency [%]."
)
bev_discharging_efficiency: Optional[float] = Field(
default=None, description="Battery Electric Vehicle discharging efficiency [%]."
)
bev_max_charging_power: Optional[int] = Field(
default=None, description="Battery Electric Vehicle maximum charge power [W]."
)
# Home Appliance - Dish Washer
# ----------------------------
dishwasher_provider: Optional[str] = Field(
default=None, description="Id of Dish Washer simulation provider."
)
dishwasher_consumption: Optional[int] = Field(
default=None, description="Dish Washer energy consumption [Wh]."
)
dishwasher_duration: Optional[int] = Field(
default=None, description="Dish Washer usage duration [h]."
)
# PV Inverter
# -----------
inverter_provider: Optional[str] = Field(
default=None, description="Id of PV Inverter simulation provider."
)
inverter_power_max: Optional[float] = Field(
default=None, description="Inverter maximum power [W]."
)
class Devices(SingletonMixin, DevicesBase):
# Results of the devices simulation and
# insights into various parameters over the entire forecast period.
# -----------------------------------------------------------------
last_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The load in watt-hours per hour."
)
eauto_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The state of charge of the EV for each hour."
)
einnahmen_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="The revenue from grid feed-in or other sources in euros per hour.",
)
home_appliance_wh_per_hour: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="The energy consumption of a household appliance in watt-hours per hour.",
)
kosten_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The costs in euros per hour."
)
grid_import_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The grid energy drawn in watt-hours per hour."
)
grid_export_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The energy fed into the grid in watt-hours per hour."
)
verluste_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The losses in watt-hours per hour."
)
akku_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="The state of charge of the battery (not the EV) in percentage per hour.",
)
def __init__(self, settings: Optional[DevicesCommonSettings] = None):
if hasattr(self, "_initialized"):
return
super().__init__()
if settings is None:
settings = self.config.devices
if settings is None:
return
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def total_balance_euro(self) -> float:
"""The total balance of revenues minus costs in euros."""
return self.total_revenues_euro - self.total_costs_euro
# initialize devices
if settings.batteries is not None:
for battery_params in settings.batteries:
self.add_device(Battery(battery_params))
if settings.inverters is not None:
for inverter_params in settings.inverters:
self.add_device(Inverter(inverter_params))
if settings.home_appliances is not None:
for home_appliance_params in settings.home_appliances:
self.add_device(HomeAppliance(home_appliance_params))
@computed_field # type: ignore[prop-decorator]
@property
def total_revenues_euro(self) -> float:
"""The total revenues in euros."""
if self.einnahmen_euro_pro_stunde is None:
return 0
return np.nansum(self.einnahmen_euro_pro_stunde)
self.post_setup()
@computed_field # type: ignore[prop-decorator]
@property
def total_costs_euro(self) -> float:
"""The total costs in euros."""
if self.kosten_euro_pro_stunde is None:
return 0
return np.nansum(self.kosten_euro_pro_stunde)
def post_setup(self) -> None:
for device in self.devices.values():
device.post_setup()
@computed_field # type: ignore[prop-decorator]
@property
def total_losses_wh(self) -> float:
"""The total losses in watt-hours over the entire period."""
if self.verluste_wh_pro_stunde is None:
return 0
return np.nansum(self.verluste_wh_pro_stunde)
# Devices
# TODO: Make devices class a container of device simulation providers.
# Device simulations to be used are then enabled in the configuration.
battery: ClassVar[Battery] = Battery(provider_id="GenericBattery")
ev: ClassVar[Battery] = Battery(provider_id="GenericBEV")
home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher")
inverter: ClassVar[Inverter] = Inverter(
self_consumption_predictor=SelfConsumptionProbabilityInterpolator,
battery=battery,
provider_id="GenericInverter",
)
def update_data(self) -> None:
"""Update device simulation data."""
# Assure devices are set up
self.battery.setup()
self.ev.setup()
self.home_appliance.setup()
self.inverter.setup()
# Pre-allocate arrays for the results, optimized for speed
self.last_wh_pro_stunde = np.full((self.total_hours), np.nan)
self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan)
self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan)
self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan)
self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan)
self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan)
self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan)
self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan)
self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan)
# Set initial state
simulation_step = to_duration("1 hour")
if self.battery:
self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage()
if self.ev:
self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage()
# Get predictions for full device simulation time range
# gesamtlast[stunde]
load_total_mean = self.prediction.key_to_array(
"load_total_mean",
start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=simulation_step,
)
# pv_prognose_wh[stunde]
pvforecast_ac_power = self.prediction.key_to_array(
"pvforecast_ac_power",
start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=simulation_step,
)
# strompreis_euro_pro_wh[stunde]
elecprice_marketprice_wh = self.prediction.key_to_array(
"elecprice_marketprice_wh",
start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=simulation_step,
)
# einspeiseverguetung_euro_pro_wh_arr[stunde]
# TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr
einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078)
for stunde_since_now in range(0, self.total_hours):
hour = self.start_datetime.hour + stunde_since_now
# Accumulate loads and PV generation
consumption = load_total_mean[stunde_since_now]
self.verluste_wh_pro_stunde[stunde_since_now] = 0.0
# Home appliances
if self.home_appliance:
ha_load = self.home_appliance.get_load_for_hour(hour)
consumption += ha_load
self.home_appliance_wh_per_hour[stunde_since_now] = ha_load
# E-Auto handling
if self.ev:
if self.ev_charge_hours[hour] > 0:
geladene_menge_eauto, verluste_eauto = self.ev.charge_energy(
None, hour, relative_power=self.ev_charge_hours[hour]
)
consumption += geladene_menge_eauto
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto
self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage()
# Process inverter logic
grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0)
if self.battery:
self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour)
if self.inverter:
generation = pvforecast_ac_power[hour]
grid_export, grid_import, losses, self_consumption = self.inverter.process_energy(
generation, consumption, hour
)
# AC PV Battery Charge
if self.battery and self.ac_charge_hours[hour] > 0.0:
self.battery.set_charge_allowed_for_hour(1, hour)
geladene_menge, verluste_wh = self.battery.charge_energy(
None, hour, relative_power=self.ac_charge_hours[hour]
)
# print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage())
consumption += geladene_menge
grid_import += geladene_menge
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh
self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export
self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import
self.verluste_wh_pro_stunde[stunde_since_now] += losses
self.last_wh_pro_stunde[stunde_since_now] = consumption
# Financial calculations
self.kosten_euro_pro_stunde[stunde_since_now] = (
grid_import * self.strompreis_euro_pro_wh[hour]
)
self.einnahmen_euro_pro_stunde[stunde_since_now] = (
grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour]
)
# battery SOC tracking
if self.battery:
self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage()
else:
self.akku_soc_pro_stunde[stunde_since_now] = 0.0
def report_dict(self) -> Dict[str, Any]:
"""Provides devices simulation output as a dictionary."""
out: Dict[str, Optional[Union[np.ndarray, float]]] = {
"Last_Wh_pro_Stunde": self.last_wh_pro_stunde,
"grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde,
"grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde,
"Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde,
"akku_soc_pro_stunde": self.akku_soc_pro_stunde,
"Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde,
"Gesamtbilanz_Euro": self.total_balance_euro,
"EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde,
"Gesamteinnahmen_Euro": self.total_revenues_euro,
"Gesamtkosten_Euro": self.total_costs_euro,
"Verluste_Pro_Stunde": self.verluste_wh_pro_stunde,
"Gesamt_Verluste": self.total_losses_wh,
"Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour,
}
return out
# # Devices
# # TODO: Make devices class a container of device simulation providers.
# # Device simulations to be used are then enabled in the configuration.
# battery: ClassVar[Battery] = Battery(provider_id="GenericBattery")
# ev: ClassVar[Battery] = Battery(provider_id="GenericBEV")
# home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher")
# inverter: ClassVar[Inverter] = Inverter(
# self_consumption_predictor=SelfConsumptionProbabilityInterpolator,
# battery=battery,
# provider_id="GenericInverter",
# )
#
# def update_data(self) -> None:
# """Update device simulation data."""
# # Assure devices are set up
# self.battery.setup()
# self.ev.setup()
# self.home_appliance.setup()
# self.inverter.setup()
#
# # Pre-allocate arrays for the results, optimized for speed
# self.last_wh_pro_stunde = np.full((self.total_hours), np.nan)
# self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan)
# self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan)
# self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan)
# self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan)
# self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan)
# self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan)
# self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan)
# self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan)
#
# # Set initial state
# simulation_step = to_duration("1 hour")
# if self.battery:
# self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage()
# if self.ev:
# self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage()
#
# # Get predictions for full device simulation time range
# # gesamtlast[stunde]
# load_total_mean = self.prediction.key_to_array(
# "load_total_mean",
# start_datetime=self.start_datetime,
# end_datetime=self.end_datetime,
# interval=simulation_step,
# )
# # pv_prognose_wh[stunde]
# pvforecast_ac_power = self.prediction.key_to_array(
# "pvforecast_ac_power",
# start_datetime=self.start_datetime,
# end_datetime=self.end_datetime,
# interval=simulation_step,
# )
# # strompreis_euro_pro_wh[stunde]
# elecprice_marketprice_wh = self.prediction.key_to_array(
# "elecprice_marketprice_wh",
# start_datetime=self.start_datetime,
# end_datetime=self.end_datetime,
# interval=simulation_step,
# )
# # einspeiseverguetung_euro_pro_wh_arr[stunde]
# # TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr
# einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078)
#
# for stunde_since_now in range(0, self.total_hours):
# hour = self.start_datetime.hour + stunde_since_now
#
# # Accumulate loads and PV generation
# consumption = load_total_mean[stunde_since_now]
# self.verluste_wh_pro_stunde[stunde_since_now] = 0.0
#
# # Home appliances
# if self.home_appliance:
# ha_load = self.home_appliance.get_load_for_hour(hour)
# consumption += ha_load
# self.home_appliance_wh_per_hour[stunde_since_now] = ha_load
#
# # E-Auto handling
# if self.ev:
# if self.ev_charge_hours[hour] > 0:
# geladene_menge_eauto, verluste_eauto = self.ev.charge_energy(
# None, hour, relative_power=self.ev_charge_hours[hour]
# )
# consumption += geladene_menge_eauto
# self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto
# self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage()
#
# # Process inverter logic
# grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0)
# if self.battery:
# self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour)
# if self.inverter:
# generation = pvforecast_ac_power[hour]
# grid_export, grid_import, losses, self_consumption = self.inverter.process_energy(
# generation, consumption, hour
# )
#
# # AC PV Battery Charge
# if self.battery and self.ac_charge_hours[hour] > 0.0:
# self.battery.set_charge_allowed_for_hour(1, hour)
# geladene_menge, verluste_wh = self.battery.charge_energy(
# None, hour, relative_power=self.ac_charge_hours[hour]
# )
# # print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage())
# consumption += geladene_menge
# grid_import += geladene_menge
# self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh
#
# self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export
# self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import
# self.verluste_wh_pro_stunde[stunde_since_now] += losses
# self.last_wh_pro_stunde[stunde_since_now] = consumption
#
# # Financial calculations
# self.kosten_euro_pro_stunde[stunde_since_now] = (
# grid_import * self.strompreis_euro_pro_wh[hour]
# )
# self.einnahmen_euro_pro_stunde[stunde_since_now] = (
# grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour]
# )
#
# # battery SOC tracking
# if self.battery:
# self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage()
# else:
# self.akku_soc_pro_stunde[stunde_since_now] = 0.0
#
# def report_dict(self) -> Dict[str, Any]:
# """Provides devices simulation output as a dictionary."""
# out: Dict[str, Optional[Union[np.ndarray, float]]] = {
# "Last_Wh_pro_Stunde": self.last_wh_pro_stunde,
# "grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde,
# "grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde,
# "Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde,
# "akku_soc_pro_stunde": self.akku_soc_pro_stunde,
# "Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde,
# "Gesamtbilanz_Euro": self.total_balance_euro,
# "EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde,
# "Gesamteinnahmen_Euro": self.total_revenues_euro,
# "Gesamtkosten_Euro": self.total_costs_euro,
# "Verluste_Pro_Stunde": self.verluste_wh_pro_stunde,
# "Gesamt_Verluste": self.total_losses_wh,
# "Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour,
# }
# return out
# Initialize the Devices simulation, it is a singleton.

View File

@ -1,22 +1,46 @@
"""Abstract and base classes for devices."""
from typing import Optional
from enum import Enum
from typing import Optional, Type
from pendulum import DateTime
from pydantic import ConfigDict, computed_field
from pydantic import Field, computed_field
from akkudoktoreos.core.coreabc import (
ConfigMixin,
DevicesMixin,
EnergyManagementSystemMixin,
PredictionMixin,
)
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.core.pydantic import ParametersBaseModel
from akkudoktoreos.utils.datetimeutil import to_duration
logger = get_logger(__name__)
# class DeviceParameters(PydanticBaseModel):
class DeviceParameters(ParametersBaseModel):
device_id: str = Field(description="ID of device")
hours: Optional[int] = Field(
default=None,
gt=0,
description="Number of prediction hours. Defaults to global config prediction hours.",
)
# class DeviceOptimizeResult(PydanticBaseModel):
class DeviceOptimizeResult(ParametersBaseModel):
device_id: str = Field(description="ID of device")
hours: int = Field(gt=0, description="Number of hours in the simulation.")
class DeviceState(Enum):
UNINITIALIZED = 0
PREPARED = 1
INITIALIZED = 2
class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
"""A mixin to manage start, end datetimes for devices data.
@ -35,9 +59,9 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
Returns:
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
"""
if self.ems.start_datetime and self.config.prediction_hours:
if self.ems.start_datetime and self.config.prediction.prediction_hours:
end_datetime = self.ems.start_datetime + to_duration(
f"{self.config.prediction_hours} hours"
f"{self.config.prediction.prediction_hours} hours"
)
dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours
logger.debug(
@ -68,33 +92,92 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
return int(duration.total_hours())
class DeviceBase(DevicesStartEndMixin, PredictionMixin):
class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin):
"""Base class for device simulations.
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
`prediction`).
Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute
`prediction`) and EOS device registry (attribute `devices`).
Note:
Validation on assignment of the Pydantic model is disabled to speed up simulation runs.
Behavior:
- Several initialization phases (setup, post_setup):
- setup: Initialize class attributes from DeviceParameters (pydantic input validation)
- post_setup: Set connections between devices
- NotImplemented:
- hooks during optimization
Notes:
- This class is base to concrete devices like battery, inverter, etc. that are used in optimization.
- Not a pydantic model for a low footprint during optimization.
"""
# Disable validation on assignment to speed up simulation runs.
model_config = ConfigDict(
validate_assignment=False,
)
def __init__(self, parameters: Optional[DeviceParameters] = None):
self.device_id: str = "<invalid>"
self.parameters: Optional[DeviceParameters] = None
self.hours = -1
if self.total_hours is not None:
self.hours = self.total_hours
self.initialized = DeviceState.UNINITIALIZED
if parameters is not None:
self.setup(parameters)
def setup(self, parameters: DeviceParameters) -> None:
if self.initialized != DeviceState.UNINITIALIZED:
return
self.parameters = parameters
self.device_id = self.parameters.device_id
if self.parameters.hours is not None:
self.hours = self.parameters.hours
if self.hours < 0:
raise ValueError("hours is unset")
self._setup()
self.initialized = DeviceState.PREPARED
def post_setup(self) -> None:
if self.initialized.value >= DeviceState.INITIALIZED.value:
return
self._post_setup()
self.initialized = DeviceState.INITIALIZED
def _setup(self) -> None:
"""Implement custom setup in derived device classes."""
pass
def _post_setup(self) -> None:
"""Implement custom setup in derived device classes that is run when all devices are initialized."""
pass
class DevicesBase(DevicesStartEndMixin, PredictionMixin, PydanticBaseModel):
class DevicesBase(DevicesStartEndMixin, PredictionMixin):
"""Base class for handling device data.
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
`prediction`).
Note:
Validation on assignment of the Pydantic model is disabled to speed up simulation runs.
"""
# Disable validation on assignment to speed up simulation runs.
model_config = ConfigDict(
validate_assignment=False,
)
def __init__(self) -> None:
super().__init__()
self.devices: dict[str, "DeviceBase"] = dict()
def get_device_by_id(self, device_id: str) -> Optional["DeviceBase"]:
return self.devices.get(device_id)
def add_device(self, device: Optional["DeviceBase"]) -> None:
if device is None:
return
assert device.device_id not in self.devices, f"{device.device_id} already registered"
self.devices[device.device_id] = device
def remove_device(self, device: Type["DeviceBase"] | str) -> bool:
if isinstance(device, DeviceBase):
device = device.device_id
return self.devices.pop(device, None) is not None # type: ignore[arg-type]
def reset(self) -> None:
self.devices = dict()

View File

@ -4,13 +4,13 @@ import numpy as np
from pydantic import Field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ParametersBaseModel
from akkudoktoreos.devices.devicesabc import DeviceBase
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
logger = get_logger(__name__)
class HomeApplianceParameters(ParametersBaseModel):
class HomeApplianceParameters(DeviceParameters):
device_id: str = Field(description="ID of home appliance")
consumption_wh: int = Field(
gt=0,
description="An integer representing the energy consumption of a household device in watt-hours.",
@ -25,46 +25,15 @@ class HomeAppliance(DeviceBase):
def __init__(
self,
parameters: Optional[HomeApplianceParameters] = None,
hours: Optional[int] = 24,
provider_id: Optional[str] = None,
):
# Configuration initialisation
self.provider_id = provider_id
self.prefix = "<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.parameters: Optional[HomeApplianceParameters] = None
super().__init__(parameters)
self.initialised = False
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
if self.parameters is not None:
self.setup()
def setup(self) -> None:
if self.initialised:
return
if self.provider_id is not None:
# Setup by configuration
self.hours = self.total_hours
self.consumption_wh = getattr(self.config, f"{self.prefix}_consumption")
self.duration_h = getattr(self.config, f"{self.prefix}_duration")
elif self.parameters is not None:
# Setup by parameters
self.consumption_wh = (
self.parameters.consumption_wh
) # Total energy consumption of the device in kWh
self.duration_h = self.parameters.duration_h # Duration of use in hours
else:
error_msg = "Parameters and provider ID missing. Can't instantiate."
logger.error(error_msg)
raise ValueError(error_msg)
def _setup(self) -> None:
assert self.parameters is not None
self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros
self.initialised = True
self.duration_h = self.parameters.duration_h
self.consumption_wh = self.parameters.consumption_wh
def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> None:
"""Sets the start time of the device and generates the corresponding load curve.

View File

@ -1,64 +1,44 @@
from typing import Optional
from pydantic import Field
from scipy.interpolate import RegularGridInterpolator
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ParametersBaseModel
from akkudoktoreos.devices.battery import Battery
from akkudoktoreos.devices.devicesabc import DeviceBase
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
logger = get_logger(__name__)
class InverterParameters(ParametersBaseModel):
class InverterParameters(DeviceParameters):
device_id: str = Field(description="ID of inverter")
max_power_wh: float = Field(gt=0)
battery: Optional[str] = Field(default=None, description="ID of battery")
class Inverter(DeviceBase):
def __init__(
self,
self_consumption_predictor: RegularGridInterpolator,
parameters: Optional[InverterParameters] = None,
battery: Optional[Battery] = None,
provider_id: Optional[str] = None,
):
# Configuration initialisation
self.provider_id = provider_id
self.prefix = "<invalid>"
if self.provider_id == "GenericInverter":
self.prefix = "inverter"
# Parameter initialisiation
self.parameters = parameters
if battery is None:
self.parameters: Optional[InverterParameters] = None
super().__init__(parameters)
def _setup(self) -> None:
assert self.parameters is not None
if self.parameters.battery is None:
# For the moment raise exception
# TODO: Make battery configurable by config
error_msg = "Battery for PV inverter is mandatory."
logger.error(error_msg)
raise NotImplementedError(error_msg)
self.battery = battery # Connection to a battery object
self.self_consumption_predictor = self_consumption_predictor
self.self_consumption_predictor = get_eos_load_interpolator()
self.max_power_wh = (
self.parameters.max_power_wh
) # Maximum power that the inverter can handle
self.initialised = False
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
if self.parameters is not None:
self.setup()
def setup(self) -> None:
if self.initialised:
return
if self.provider_id is not None:
# Setup by configuration
self.max_power_wh = getattr(self.config, f"{self.prefix}_power_max")
elif self.parameters is not None:
# Setup by parameters
self.max_power_wh = (
self.parameters.max_power_wh # Maximum power that the inverter can handle
)
else:
error_msg = "Parameters and provider ID missing. Can't instantiate."
logger.error(error_msg)
raise ValueError(error_msg)
def _post_setup(self) -> None:
assert self.parameters is not None
self.battery = self.devices.get_device_by_id(self.parameters.battery)
def process_energy(
self, generation: float, consumption: float, hour: int

View 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"
)

View File

@ -106,6 +106,11 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
"measurement_load",
]
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def _interval_count(
self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration
) -> int:
@ -143,11 +148,16 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
if topic not in self.topics:
return None
topic_keys = [key for key in self.config.config_keys if key.startswith(topic)]
topic_keys = [
key for key in self.config.measurement.model_fields.keys() if key.startswith(topic)
]
key = None
if topic == "measurement_load":
for config_key in topic_keys:
if config_key.endswith("_name") and getattr(self.config, config_key) == name:
if (
config_key.endswith("_name")
and getattr(self.config.measurement, config_key) == name
):
key = topic + config_key[len(topic) : len(topic) + 1] + "_mr"
break

View File

@ -1,7 +1,6 @@
import logging
import random
import time
from pathlib import Path
from typing import Any, Optional
import numpy as np
@ -25,7 +24,6 @@ from akkudoktoreos.devices.battery import (
)
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__)
@ -112,8 +110,12 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
):
"""Initialize the optimization problem with the required parameters."""
self.opti_param: dict[str, Any] = {}
self.fixed_eauto_hours = self.config.prediction_hours - self.config.optimization_hours
self.possible_charge_values = self.config.optimization_ev_available_charge_rates_percent
self.fixed_eauto_hours = (
self.config.prediction.prediction_hours - self.config.optimization.optimization_hours
)
self.possible_charge_values = (
self.config.optimization.optimization_ev_available_charge_rates_percent
)
self.verbose = verbose
self.fix_seed = fixed_seed
self.optimize_ev = True
@ -180,25 +182,27 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
total_states = 3 * len_ac
# 1. Mutating the charge_discharge part
charge_discharge_part = individual[: self.config.prediction_hours]
charge_discharge_part = individual[: self.config.prediction.prediction_hours]
(charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part)
# Instead of a fixed clamping to 0..8 or 0..6 dynamically:
charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1)
individual[: self.config.prediction_hours] = charge_discharge_mutated
individual[: self.config.prediction.prediction_hours] = charge_discharge_mutated
# 2. Mutating the EV charge part, if active
if self.optimize_ev:
ev_charge_part = individual[
self.config.prediction_hours : self.config.prediction_hours * 2
self.config.prediction.prediction_hours : self.config.prediction.prediction_hours
* 2
]
(ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part)
ev_charge_part_mutated[self.config.prediction_hours - self.fixed_eauto_hours :] = [
0
] * self.fixed_eauto_hours
individual[self.config.prediction_hours : self.config.prediction_hours * 2] = (
ev_charge_part_mutated
)
ev_charge_part_mutated[
self.config.prediction.prediction_hours - self.fixed_eauto_hours :
] = [0] * self.fixed_eauto_hours
individual[
self.config.prediction.prediction_hours : self.config.prediction.prediction_hours
* 2
] = ev_charge_part_mutated
# 3. Mutating the appliance start time, if applicable
if self.opti_param["home_appliance"] > 0:
@ -212,13 +216,15 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
def create_individual(self) -> list[int]:
# Start with discharge states for the individual
individual_components = [
self.toolbox.attr_discharge_state() for _ in range(self.config.prediction_hours)
self.toolbox.attr_discharge_state()
for _ in range(self.config.prediction.prediction_hours)
]
# Add EV charge index values if optimize_ev is True
if self.optimize_ev:
individual_components += [
self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction_hours)
self.toolbox.attr_ev_charge_index()
for _ in range(self.config.prediction.prediction_hours)
]
# Add the start time of the household appliance if it's being optimized
@ -251,7 +257,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
individual.extend(eautocharge_hours_index.tolist())
elif self.optimize_ev:
# Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu
individual.extend([0] * self.config.prediction_hours)
individual.extend([0] * self.config.prediction.prediction_hours)
# Add dishwasher start time if applicable
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None:
@ -273,12 +279,17 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
3. Dishwasher start time (integer if applicable).
"""
# Discharge hours as a NumPy array of ints
discharge_hours_bin = np.array(individual[: self.config.prediction_hours], dtype=int)
discharge_hours_bin = np.array(
individual[: self.config.prediction.prediction_hours], dtype=int
)
# EV charge hours as a NumPy array of ints (if optimize_ev is True)
eautocharge_hours_index = (
np.array(
individual[self.config.prediction_hours : self.config.prediction_hours * 2],
individual[
self.config.prediction.prediction_hours : self.config.prediction.prediction_hours
* 2
],
dtype=int,
)
if self.optimize_ev
@ -390,7 +401,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
)
self.ems.set_ev_charge_hours(eautocharge_hours_float)
else:
self.ems.set_ev_charge_hours(np.full(self.config.prediction_hours, 0))
self.ems.set_ev_charge_hours(np.full(self.config.prediction.prediction_hours, 0))
return self.ems.simulate(self.ems.start_datetime.hour)
@ -452,7 +463,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
# min_length = min(battery_soc_per_hour.size, discharge_hours_bin.size)
# battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:]
# discharge_hours_bin_tail = discharge_hours_bin[-min_length:]
# len_ac = len(self.config.optimization_ev_available_charge_rates_percent)
# len_ac = len(self.config.optimization.optimization_ev_available_charge_rates_percent)
# # # Find hours where battery SoC is 0
# # zero_soc_mask = battery_soc_per_hour_tail == 0
@ -501,7 +512,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
if parameters.eauto and self.ems.ev
else 0
)
* self.config.optimization_penalty,
* self.config.optimization.optimization_penalty,
)
return (gesamtbilanz,)
@ -569,30 +580,26 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
start_hour = self.ems.start_datetime.hour
einspeiseverguetung_euro_pro_wh = np.full(
self.config.prediction_hours, parameters.ems.einspeiseverguetung_euro_pro_wh
self.config.prediction.prediction_hours, parameters.ems.einspeiseverguetung_euro_pro_wh
)
# 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate
sc = SelfConsumptionProbabilityInterpolator(
Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
)
# TODO: Refactor device setup phase out
self.devices.reset()
# Initialize PV and EV batteries
akku: Optional[Battery] = None
if parameters.pv_akku:
akku = Battery(
parameters.pv_akku,
hours=self.config.prediction_hours,
)
akku.set_charge_per_hour(np.full(self.config.prediction_hours, 1))
akku = Battery(parameters.pv_akku)
self.devices.add_device(akku)
akku.set_charge_per_hour(np.full(self.config.prediction.prediction_hours, 1))
eauto: Optional[Battery] = None
if parameters.eauto:
eauto = Battery(
parameters.eauto,
hours=self.config.prediction_hours,
)
eauto.set_charge_per_hour(np.full(self.config.prediction_hours, 1))
self.devices.add_device(eauto)
eauto.set_charge_per_hour(np.full(self.config.prediction.prediction_hours, 1))
self.optimize_ev = (
parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0
)
@ -603,20 +610,22 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
dishwasher = (
HomeAppliance(
parameters=parameters.dishwasher,
hours=self.config.prediction_hours,
)
if parameters.dishwasher is not None
else None
)
self.devices.add_device(dishwasher)
# Initialize the inverter and energy management system
inverter: Optional[Inverter] = None
if parameters.inverter:
inverter = Inverter(
sc,
parameters.inverter,
akku,
)
self.devices.add_device(inverter)
self.devices.post_setup()
self.ems.set_parameters(
parameters.ems,
inverter=inverter,

View File

@ -16,7 +16,7 @@ class OptimizationCommonSettings(SettingsBaseModel):
"""
optimization_hours: Optional[int] = Field(
default=24, ge=0, description="Number of hours into the future for optimizations."
default=48, ge=0, description="Number of hours into the future for optimizations."
)
optimization_penalty: Optional[int] = Field(

View File

@ -3,6 +3,7 @@ from typing import Optional
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
class ElecPriceCommonSettings(SettingsBaseModel):
@ -12,3 +13,5 @@ class ElecPriceCommonSettings(SettingsBaseModel):
elecprice_charges_kwh: Optional[float] = Field(
default=None, ge=0, description="Electricity price charges (€/kWh)."
)
provider_settings: Optional[ElecPriceImportCommonSettings] = None

View File

@ -71,4 +71,4 @@ class ElecPriceProvider(PredictionProvider):
return "ElecPriceProvider"
def enabled(self) -> bool:
return self.provider_id() == self.config.elecprice_provider
return self.provider_id() == self.config.elecprice.elecprice_provider

View File

@ -108,13 +108,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
# Try to take data from 5 weeks back for prediction
date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD")
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}"
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.prediction.timezone}"
response = requests.get(url)
logger.debug(f"Response from {url}: {response}")
response.raise_for_status() # Raise an error for bad responses
akkudoktor_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)
return akkudoktor_data
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
@ -156,13 +156,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
# in ascending order and have the same timestamps.
# Get elecprice_charges_kwh in wh
charges_wh = (self.config.elecprice_charges_kwh or 0) / 1000
charges_wh = (self.config.elecprice.elecprice_charges_kwh or 0) / 1000
highest_orig_datetime = None # newest datetime from the api after that we want to update.
series_data = pd.Series(dtype=float) # Initialize an empty series
for value in akkudoktor_data.values:
orig_datetime = to_datetime(value.start, in_timezone=self.config.timezone)
orig_datetime = to_datetime(value.start, in_timezone=self.config.prediction.timezone)
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
highest_orig_datetime = orig_datetime
@ -184,14 +184,14 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
needed_prediction_hours = int(
self.config.prediction_hours
self.config.prediction.prediction_hours
- ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
)
if needed_prediction_hours <= 0:
logger.warning(
f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}"
) # this might keep data longer than self.start_datetime + self.config.prediction_hours in the records
f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}"
) # this might keep data longer than self.start_datetime + self.config.prediction.prediction_hours in the records
return
if amount_datasets > 800: # we do the full ets with seasons of 1 week

View File

@ -62,7 +62,12 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
return "ElecPriceImport"
def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.elecpriceimport_file_path is not None:
self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice")
if self.config.elecpriceimport_json is not None:
self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice")
if self.config.elecprice.provider_settings.elecpriceimport_file_path is not None:
self.import_from_file(
self.config.elecprice.provider_settings.elecpriceimport_file_path,
key_prefix="elecprice",
)
if self.config.elecprice.provider_settings.elecpriceimport_json is not None:
self.import_from_json(
self.config.elecprice.provider_settings.elecpriceimport_json, key_prefix="elecprice"
)

View File

@ -6,6 +6,8 @@ from pathlib import Path
import numpy as np
from scipy.interpolate import RegularGridInterpolator
from akkudoktoreos.core.coreabc import SingletonMixin
class SelfConsumptionProbabilityInterpolator:
def __init__(self, filepath: str | Path):
@ -67,5 +69,17 @@ class SelfConsumptionProbabilityInterpolator:
# return self_consumption_rate
# Test the function
# print(calculate_self_consumption(1000, 1200))
class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin):
def __init__(self) -> None:
if hasattr(self, "_initialized"):
return
filename = Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
super().__init__(filename)
# Initialize the Energy Management System, it is a singleton.
eos_load_interpolator = EOSLoadInterpolator()
def get_eos_load_interpolator() -> EOSLoadInterpolator:
return eos_load_interpolator

View File

@ -1,11 +1,13 @@
"""Load forecast module for load predictions."""
from typing import Optional
from typing import Optional, Union
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
logger = get_logger(__name__)
@ -16,3 +18,7 @@ class LoadCommonSettings(SettingsBaseModel):
load_provider: Optional[str] = Field(
default=None, description="Load provider id of provider to be used."
)
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
None
)

View File

@ -58,4 +58,4 @@ class LoadProvider(PredictionProvider):
return "LoadProvider"
def enabled(self) -> bool:
return self.provider_id() == self.config.load_provider
return self.provider_id() == self.config.load.load_provider

View File

@ -91,7 +91,9 @@ class LoadAkkudoktor(LoadProvider):
list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"]))
)
# Calculate values in W by relative profile data and yearly consumption given in kWh
data_year_energy = profile_data * self.config.loadakkudoktor_year_energy * 1000
data_year_energy = (
profile_data * self.config.load.provider_settings.loadakkudoktor_year_energy * 1000
)
except FileNotFoundError:
error_msg = f"Error: File {load_file} not found."
logger.error(error_msg)
@ -109,7 +111,7 @@ class LoadAkkudoktor(LoadProvider):
# We provide prediction starting at start of day, to be compatible to old system.
# End date for prediction is prediction hours from now.
date = self.start_datetime.start_of("day")
end_date = self.start_datetime.add(hours=self.config.prediction_hours)
end_date = self.start_datetime.add(hours=self.config.prediction.prediction_hours)
while compare_datetimes(date, end_date).lt:
# Extract mean (index 0) and standard deviation (index 1) for the given day and hour
# Day indexing starts at 0, -1 because of that
@ -127,4 +129,4 @@ class LoadAkkudoktor(LoadProvider):
self.update_value(date, values)
date += to_duration("1 hour")
# We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)

View File

@ -58,7 +58,11 @@ class LoadImport(LoadProvider, PredictionImportProvider):
return "LoadImport"
def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.load_import_file_path is not None:
self.import_from_file(self.config.load_import_file_path, key_prefix="load")
if self.config.load_import_json is not None:
self.import_from_json(self.config.load_import_json, key_prefix="load")
if self.config.load.provider_settings.load_import_file_path is not None:
self.import_from_file(
self.config.provider_settings.load_import_file_path, key_prefix="load"
)
if self.config.load.provider_settings.load_import_json is not None:
self.import_from_json(
self.config.load.provider_settings.load_import_json, key_prefix="load"
)

View File

@ -80,13 +80,13 @@ class PredictionCommonSettings(SettingsBaseModel):
description="Number of hours into the past for historical predictions data",
)
latitude: Optional[float] = Field(
default=None,
default=52.52,
ge=-90.0,
le=90.0,
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
)
longitude: Optional[float] = Field(
default=None,
default=13.405,
ge=-180.0,
le=180.0,
description="Longitude in decimal degrees, within -180 to 180 (°)",

View File

@ -121,9 +121,9 @@ class PredictionStartEndKeepMixin(PredictionBase):
Returns:
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
"""
if self.start_datetime and self.config.prediction_hours:
if self.start_datetime and self.config.prediction.prediction_hours:
end_datetime = self.start_datetime + to_duration(
f"{self.config.prediction_hours} hours"
f"{self.config.prediction.prediction_hours} hours"
)
dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours
logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}")
@ -147,10 +147,10 @@ class PredictionStartEndKeepMixin(PredictionBase):
return None
historic_hours = self.historic_hours_min()
if (
self.config.prediction_historic_hours
and self.config.prediction_historic_hours > historic_hours
self.config.prediction.prediction_historic_hours
and self.config.prediction.prediction_historic_hours > historic_hours
):
historic_hours = int(self.config.prediction_historic_hours)
historic_hours = int(self.config.prediction.prediction_historic_hours)
return self.start_datetime - to_duration(f"{historic_hours} hours")
@computed_field # type: ignore[prop-decorator]

View File

@ -6,6 +6,7 @@ from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
logger = get_logger(__name__)
@ -260,7 +261,7 @@ class PVForecastCommonSettings(SettingsBaseModel):
default=None, description="Nominal power of PV system in kW."
)
pvforecast4_pvtechchoice: Optional[str] = Field(
"crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
)
pvforecast4_mountingplace: Optional[str] = Field(
default="free",
@ -316,7 +317,7 @@ class PVForecastCommonSettings(SettingsBaseModel):
default=None, description="Nominal power of PV system in kW."
)
pvforecast5_pvtechchoice: Optional[str] = Field(
"crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
)
pvforecast5_mountingplace: Optional[str] = Field(
default="free",
@ -359,6 +360,8 @@ class PVForecastCommonSettings(SettingsBaseModel):
pvforecast_max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set
provider_settings: Optional[PVForecastImportCommonSettings] = None
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property

View File

@ -54,6 +54,6 @@ class PVForecastProvider(PredictionProvider):
def enabled(self) -> bool:
logger.debug(
f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast_provider}"
f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast.pvforecast_provider}"
)
return self.provider_id() == self.config.pvforecast_provider
return self.provider_id() == self.config.pvforecast.pvforecast_provider

View File

@ -203,19 +203,23 @@ class PVForecastAkkudoktor(PVForecastProvider):
"""Build akkudoktor.net API request URL."""
base_url = "https://api.akkudoktor.net/forecast"
query_params = [
f"lat={self.config.latitude}",
f"lon={self.config.longitude}",
f"lat={self.config.prediction.latitude}",
f"lon={self.config.prediction.longitude}",
]
for i in range(len(self.config.pvforecast_planes)):
query_params.append(f"power={int(self.config.pvforecast_planes_peakpower[i] * 1000)}")
query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}")
query_params.append(f"tilt={int(self.config.pvforecast_planes_tilt[i])}")
for i in range(len(self.config.pvforecast.pvforecast_planes)):
query_params.append(
f"powerInverter={int(self.config.pvforecast_planes_inverter_paco[i])}"
f"power={int(self.config.pvforecast.pvforecast_planes_peakpower[i] * 1000)}"
)
query_params.append(
f"azimuth={int(self.config.pvforecast.pvforecast_planes_azimuth[i])}"
)
query_params.append(f"tilt={int(self.config.pvforecast.pvforecast_planes_tilt[i])}")
query_params.append(
f"powerInverter={int(self.config.pvforecast.pvforecast_planes_inverter_paco[i])}"
)
horizon_values = ",".join(
str(int(h)) for h in self.config.pvforecast_planes_userhorizon[i]
str(int(h)) for h in self.config.pvforecast.pvforecast_planes_userhorizon[i]
)
query_params.append(f"horizont={horizon_values}")
@ -226,7 +230,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
"cellCoEff=-0.36",
"inverterEfficiency=0.8",
"albedo=0.25",
f"timezone={self.config.timezone}",
f"timezone={self.config.prediction.timezone}",
"hourly=relativehumidity_2m%2Cwindspeed_10m",
]
)
@ -255,7 +259,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
logger.debug(f"Response from {self._url()}: {response}")
akkudoktor_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)
return akkudoktor_data
def _update_data(self, force_update: Optional[bool] = False) -> None:
@ -265,7 +269,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
`PVForecastAkkudoktorDataRecord`.
"""
# Assure we have something to request PV power for.
if not self.config.pvforecast_planes:
if not self.config.pvforecast.pvforecast_planes:
# No planes for PV
error_msg = "Requested PV forecast, but no planes configured."
logger.error(f"Configuration error: {error_msg}")
@ -275,17 +279,17 @@ class PVForecastAkkudoktor(PVForecastProvider):
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
# Timezone of the PV system
if self.config.timezone != akkudoktor_data.meta.timezone:
error_msg = f"Configured timezone '{self.config.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
if self.config.prediction.timezone != akkudoktor_data.meta.timezone:
error_msg = f"Configured timezone '{self.config.prediction.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(error_msg)
# Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps.
if len(akkudoktor_data.values[0]) < self.config.prediction_hours:
if len(akkudoktor_data.values[0]) < self.config.prediction.prediction_hours:
# Expect one value set per prediction hour
error_msg = (
f"The forecast must cover at least {self.config.prediction_hours} hours, "
f"The forecast must cover at least {self.config.prediction.prediction_hours} hours, "
f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data."
)
logger.error(f"Akkudoktor schema change: {error_msg}")
@ -296,7 +300,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
# Iterate over forecast data points
for forecast_values in zip(*akkudoktor_data.values):
original_datetime = forecast_values[0].datetime
dt = to_datetime(original_datetime, in_timezone=self.config.timezone)
dt = to_datetime(original_datetime, in_timezone=self.config.prediction.timezone)
# Skip outdated forecast data
if compare_datetimes(dt, self.start_datetime.start_of("day")).lt:
@ -314,9 +318,9 @@ class PVForecastAkkudoktor(PVForecastProvider):
self.update_value(dt, data)
if len(self) < self.config.prediction_hours:
if len(self) < self.config.prediction.prediction_hours:
raise ValueError(
f"The forecast must cover at least {self.config.prediction_hours} hours, "
f"The forecast must cover at least {self.config.prediction.prediction_hours} hours, "
f"but only {len(self)} hours starting from {self.start_datetime} "
f"were predicted."
)
@ -365,31 +369,35 @@ if __name__ == "__main__":
"""
# Set up the configuration with necessary fields for URL generation
settings_data = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
"prediction": {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
},
"pvforecast": {
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
},
}
# Initialize the forecast object with the generated configuration

View File

@ -62,7 +62,13 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider):
return "PVForecastImport"
def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.pvforecastimport_file_path is not None:
self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast")
if self.config.pvforecastimport_json is not None:
self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast")
if self.config.pvforecast.provider_settings.pvforecastimport_file_path is not None:
self.import_from_file(
self.config.pvforecast.provider_settings.pvforecastimport_file_path,
key_prefix="pvforecast",
)
if self.config.pvforecast.provider_settings.pvforecastimport_json is not None:
self.import_from_json(
self.config.pvforecast.provider_settings.pvforecastimport_json,
key_prefix="pvforecast",
)

View File

@ -5,9 +5,12 @@ from typing import Optional
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
class WeatherCommonSettings(SettingsBaseModel):
weather_provider: Optional[str] = Field(
default=None, description="Weather provider id of provider to be used."
)
provider_settings: Optional[WeatherImportCommonSettings] = None

View File

@ -126,7 +126,7 @@ class WeatherProvider(PredictionProvider):
return "WeatherProvider"
def enabled(self) -> bool:
return self.provider_id() == self.config.weather_provider
return self.provider_id() == self.config.weather.weather_provider
@classmethod
def estimate_irradiance_from_cloud_cover(

View File

@ -99,7 +99,7 @@ class WeatherBrightSky(WeatherProvider):
date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD")
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
response = requests.get(
f"{source}/weather?lat={self.config.latitude}&lon={self.config.longitude}&date={date}&last_date={last_date}&tz={self.config.timezone}"
f"{source}/weather?lat={self.config.prediction.latitude}&lon={self.config.prediction.longitude}&date={date}&last_date={last_date}&tz={self.config.prediction.timezone}"
)
response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}")
@ -109,7 +109,7 @@ class WeatherBrightSky(WeatherProvider):
logger.error(error_msg)
raise ValueError(error_msg)
# We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)
return brightsky_data
def _description_to_series(self, description: str) -> pd.Series:
@ -200,7 +200,7 @@ class WeatherBrightSky(WeatherProvider):
description = "Total Clouds (% Sky Obscured)"
cloud_cover = self._description_to_series(description)
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
self.config.latitude, self.config.longitude, cloud_cover
self.config.prediction.latitude, self.config.prediction.longitude, cloud_cover
)
description = "Global Horizontal Irradiance (W/m2)"

View File

@ -91,13 +91,13 @@ class WeatherClearOutside(WeatherProvider):
response: Weather forecast request reponse from ClearOutside.
"""
source = "https://clearoutside.com/forecast"
latitude = round(self.config.latitude, 2)
longitude = round(self.config.longitude, 2)
latitude = round(self.config.prediction.latitude, 2)
longitude = round(self.config.prediction.longitude, 2)
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}")
# We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)
return response
def _update_data(self, force_update: Optional[bool] = None) -> None:
@ -307,7 +307,7 @@ class WeatherClearOutside(WeatherProvider):
data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"]
)
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
self.config.latitude, self.config.longitude, cloud_cover
self.config.prediction.latitude, self.config.prediction.longitude, cloud_cover
)
# Add GHI, DNI, DHI to clearout data

View File

@ -59,7 +59,11 @@ class WeatherImport(WeatherProvider, PredictionImportProvider):
return "WeatherImport"
def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.weatherimport_file_path is not None:
self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather")
if self.config.weatherimport_json is not None:
self.import_from_json(self.config.weatherimport_json, key_prefix="weather")
if self.config.weather.provider_settings.weatherimport_file_path is not None:
self.import_from_file(
self.config.weather.provider_settings.weatherimport_file_path, key_prefix="weather"
)
if self.config.weather.provider_settings.weatherimport_json is not None:
self.import_from_json(
self.config.weather.provider_settings.weatherimport_json, key_prefix="weather"
)

View File

@ -29,7 +29,10 @@ from akkudoktoreos.optimization.genetic import (
OptimizeResponse,
optimization_problem,
)
from akkudoktoreos.prediction.prediction import get_prediction
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_prediction
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
logger = get_logger(__name__)
@ -149,16 +152,16 @@ def start_eosdash() -> subprocess.Popen:
if args is None:
# No command line arguments
host = config_eos.server_eosdash_host
port = config_eos.server_eosdash_port
eos_host = config_eos.server_eos_host
eos_port = config_eos.server_eos_port
host = config_eos.server.server_eosdash_host
port = config_eos.server.server_eosdash_port
eos_host = config_eos.server.server_eos_host
eos_port = config_eos.server.server_eos_port
log_level = "info"
access_log = False
reload = False
else:
host = args.host
port = config_eos.server_eosdash_port if config_eos.server_eosdash_port else (args.port + 1)
port = config_eos.server.server_eosdash_port if config_eos.server.server_eosdash_port else (args.port + 1)
eos_host = args.host
eos_port = args.port
log_level = args.log_level
@ -201,7 +204,7 @@ def start_eosdash() -> subprocess.Popen:
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Lifespan manager for the app."""
# On startup
if config_eos.server_eos_startup_eosdash:
if config_eos.server.server_eos_startup_eosdash:
try:
eosdash_process = start_eosdash()
except Exception as e:
@ -228,7 +231,7 @@ app = FastAPI(
# That's the problem
opt_class = optimization_problem(verbose=bool(config_eos.server_eos_verbose))
opt_class = optimization_problem(verbose=bool(config_eos.server.server_eos_verbose))
server_dir = Path(__file__).parent.resolve()
@ -340,7 +343,7 @@ def fastapi_config_put(
configuration (ConfigEOS): The current configuration after the write.
"""
try:
config_eos.merge_settings(settings, force=True)
config_eos.merge_settings(settings)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}")
return config_eos
@ -610,7 +613,9 @@ def fastapi_strompreis() -> list[float]:
'/v1/prediction/list?key=elecprice_marketprice_kwh' instead.
"""
settings = SettingsEOS(
elecprice_provider="ElecPriceAkkudoktor",
elecprice=ElecPriceCommonSettings(
elecprice_provider="ElecPriceAkkudoktor",
)
)
config_eos.merge_settings(settings=settings)
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
@ -660,9 +665,15 @@ def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
'/v1/measurement/value'
"""
settings = SettingsEOS(
prediction_hours=request.hours,
load_provider="LoadAkkudoktor",
loadakkudoktor_year_energy=request.year_energy,
prediction=PredictionCommonSettings(
prediction_hours=request.hours,
),
load=LoadCommonSettings(
load_provider="LoadAkkudoktor",
provider_settings=LoadAkkudoktorCommonSettings(
loadakkudoktor_year_energy=request.year_energy,
),
),
)
config_eos.merge_settings(settings=settings)
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
@ -738,8 +749,12 @@ def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
'/v1/prediction/list?key=load_mean' instead.
"""
settings = SettingsEOS(
load_provider="LoadAkkudoktor",
loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh
load=LoadCommonSettings(
load_provider="LoadAkkudoktor",
provider_settings=LoadAkkudoktorCommonSettings(
loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh
),
)
)
config_eos.merge_settings(settings=settings)
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
@ -844,7 +859,7 @@ def fastapi_optimize(
@app.get("/visualization_results.pdf", response_class=PdfResponse)
def get_pdf() -> PdfResponse:
# Endpoint to serve the generated PDF with visualization results
output_path = config_eos.data_output_path
output_path = config_eos.config.data_output_path
if output_path is None or not output_path.is_dir():
raise HTTPException(status_code=404, detail=f"Output path does not exist: {output_path}.")
file_path = output_path / "visualization_results.pdf"
@ -882,9 +897,9 @@ async def proxy_put(request: Request, path: str) -> Response:
async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse | HTMLResponse]:
if config_eos.server_eosdash_host and config_eos.server_eosdash_port:
if config_eos.server.server_eosdash_host and config_eos.server.server_eosdash_port:
# Proxy to EOSdash server
url = f"http://{config_eos.server_eosdash_host}:{config_eos.server_eosdash_port}/{path}"
url = f"http://{config_eos.server.server_eosdash_host}:{config_eos.server.server_eosdash_port}/{path}"
headers = dict(request.headers)
data = await request.body()
@ -984,14 +999,14 @@ def main() -> None:
parser.add_argument(
"--host",
type=str,
default=str(config_eos.server_eos_host),
help="Host for the EOS server (default: value from config_eos)",
default=str(config_eos.server.server_eos_host),
help="Host for the EOS server (default: value from config)",
)
parser.add_argument(
"--port",
type=int,
default=config_eos.server_eos_port,
help="Port for the EOS server (default: value from config_eos)",
default=config_eos.server.server_eos_port,
help="Port for the EOS server (default: value from config)",
)
# Optional arguments for log_level, access_log, and reload

View File

@ -110,13 +110,13 @@ def main() -> None:
parser.add_argument(
"--host",
type=str,
default=str(config_eos.server_eosdash_host),
default=str(config_eos.server.server_eosdash_host),
help="Host for the EOSdash server (default: value from config_eos)",
)
parser.add_argument(
"--port",
type=int,
default=config_eos.server_eosdash_port,
default=config_eos.server.server_eosdash_port,
help="Port for the EOSdash server (default: value from config_eos)",
)
@ -124,13 +124,13 @@ def main() -> None:
parser.add_argument(
"--eos-host",
type=str,
default=str(config_eos.server_eos_host),
default=str(config_eos.server.server_eos_host),
help="Host for the EOS server (default: value from config_eos)",
)
parser.add_argument(
"--eos-port",
type=int,
default=config_eos.server_eos_port,
default=config_eos.server.server_eos_port,
help="Port for the EOS server (default: value from config_eos)",
)

View File

@ -329,9 +329,9 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
# File already available
cache_file_obj = cache_item.cache_file
else:
self.config.data_cache_path.mkdir(parents=True, exist_ok=True)
self.config.general.data_cache_path.mkdir(parents=True, exist_ok=True)
cache_file_obj = tempfile.NamedTemporaryFile(
mode=mode, delete=delete, suffix=suffix, dir=self.config.data_cache_path
mode=mode, delete=delete, suffix=suffix, dir=self.config.general.data_cache_path
)
self._store[cache_file_key] = CacheFileRecord(
cache_file=cache_file_obj,

View File

@ -1,5 +1,5 @@
import json
from typing import Any
from typing import Any, Optional
import numpy as np
@ -9,6 +9,14 @@ from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__)
class classproperty(property):
def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any:
if owner_cls is None:
return self
assert self.fget is not None
return self.fget(owner_cls)
class UtilsCommonSettings(SettingsBaseModel):
pass

View File

@ -34,7 +34,7 @@ class VisualizationReport(ConfigMixin):
self.pdf_pages = PdfPages(filename, metadata={}) # Initialize PdfPages without metadata
self.version = version # overwrite version as test for constant output of pdf for test
self.current_time = to_datetime(
as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.timezone
as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.prediction.timezone
)
def add_chart_to_group(self, chart_func: Callable[[], None]) -> None:
@ -51,7 +51,7 @@ class VisualizationReport(ConfigMixin):
def _initialize_pdf(self) -> None:
"""Create the output directory if it doesn't exist and initialize the PDF."""
output_dir = self.config.data_output_path
output_dir = self.config.general.data_output_path
# If self.filename is already a valid path, use it; otherwise, combine it with output_dir
if os.path.isabs(self.filename):
@ -173,7 +173,7 @@ class VisualizationReport(ConfigMixin):
plt.grid(True)
# Add vertical line for the current date if within the axis range
current_time = pendulum.now(self.config.timezone)
current_time = pendulum.now(self.config.prediction.timezone)
if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]:
plt.axvline(current_time, color="r", linestyle="--", label="Now")
plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom")
@ -419,7 +419,7 @@ def prepare_visualize(
start_hour: Optional[int] = 0,
) -> None:
report = VisualizationReport(filename)
next_full_hour_date = pendulum.now(report.config.timezone).start_of("hour").add(hours=1)
next_full_hour_date = pendulum.now(report.config.prediction.timezone).start_of("hour").add(hours=1)
# Group 1:
report.create_line_chart_date(
next_full_hour_date, # start_date

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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