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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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": { "pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400, "capacity_wh": 26400,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"initial_soc_percentage": 80, "initial_soc_percentage": 80,
"min_soc_percentage": 15 "min_soc_percentage": 15
}, },
"inverter": { "inverter": {
"max_power_wh": 10000 "device_id": "inverter1",
"max_power_wh": 10000,
"battery": "battery1"
}, },
"eauto": { "eauto": {
"device_id": "ev1",
"capacity_wh": 60000, "capacity_wh": 60000,
"charging_efficiency": 0.95, "charging_efficiency": 0.95,
"discharging_efficiency": 1.0, "discharging_efficiency": 1.0,

View File

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

View File

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

View File

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

View File

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