From 26762e5e9373553474b77295d7c9f13b4a35b43c Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Fri, 24 Jan 2025 21:14:37 +0100 Subject: [PATCH] Review comments --- docs/akkudoktoreos/configuration.md | 2 +- docs/akkudoktoreos/prediction.md | 2 +- openapi.json | 12 +- scripts/generate_config_md.py | 6 +- src/akkudoktoreos/config/config.py | 22 +-- src/akkudoktoreos/core/decorators.py | 42 ++++++ src/akkudoktoreos/devices/devices.py | 147 ------------------- src/akkudoktoreos/measurement/measurement.py | 2 - src/akkudoktoreos/utils/utils.py | 10 +- tests/test_config.py | 20 +-- 10 files changed, 75 insertions(+), 190 deletions(-) create mode 100644 src/akkudoktoreos/core/decorators.py diff --git a/docs/akkudoktoreos/configuration.md b/docs/akkudoktoreos/configuration.md index 09d36e4..5d7e94b 100644 --- a/docs/akkudoktoreos/configuration.md +++ b/docs/akkudoktoreos/configuration.md @@ -38,7 +38,7 @@ The configuration sources and their priorities are as follows: ### Runtime Config Updates -The EOS configuration can be updated at runtime. Note that those updates are not persistet +The EOS configuration can be updated at runtime. Note that those updates are not persistent automatically. However it is possible to save the configuration to the `EOS configuration file`. Use the following endpoints to change the current runtime configuration: diff --git a/docs/akkudoktoreos/prediction.md b/docs/akkudoktoreos/prediction.md index 6bf82f3..ba7befe 100644 --- a/docs/akkudoktoreos/prediction.md +++ b/docs/akkudoktoreos/prediction.md @@ -196,7 +196,7 @@ Prediction keys: Configuration options: -- `prediction`: General prediction configuration. +- `general`: General configuration. - `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)" - `longitude`: Longitude in decimal degrees, within -180 to 180 (°) diff --git a/openapi.json b/openapi.json index 35af176..bc419af 100644 --- a/openapi.json +++ b/openapi.json @@ -106,7 +106,7 @@ "title": "BaseBatteryParameters", "type": "object" }, - "ConfigCommonSettings-Input": { + "GeneralSettings-Input": { "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", "properties": { "data_cache_subpath": { @@ -185,10 +185,10 @@ "title": "Longitude" } }, - "title": "ConfigCommonSettings", + "title": "GeneralSettings", "type": "object" }, - "ConfigCommonSettings-Output": { + "GeneralSettings-Output": { "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", "properties": { "config_file_path": { @@ -343,7 +343,7 @@ "config_folder_path", "config_file_path" ], - "title": "ConfigCommonSettings", + "title": "GeneralSettings", "type": "object" }, "ConfigEOS": { @@ -359,7 +359,7 @@ "default": {} }, "general": { - "$ref": "#/components/schemas/ConfigCommonSettings-Output", + "$ref": "#/components/schemas/GeneralSettings-Output", "default": { "config_file_path": "/home/user/.config/net.akkudoktoreos.net/EOS.config.json", "config_folder_path": "/home/user/.config/net.akkudoktoreos.net", @@ -2317,7 +2317,7 @@ "general": { "anyOf": [ { - "$ref": "#/components/schemas/ConfigCommonSettings-Input" + "$ref": "#/components/schemas/GeneralSettings-Input" }, { "type": "null" diff --git a/scripts/generate_config_md.py b/scripts/generate_config_md.py index 3b68395..226dfa0 100755 --- a/scripts/generate_config_md.py +++ b/scripts/generate_config_md.py @@ -11,7 +11,7 @@ from typing import Any, Union from pydantic.fields import ComputedFieldInfo, FieldInfo from pydantic_core import PydanticUndefined -from akkudoktoreos.config.config import ConfigCommonSettings, ConfigEOS, get_config +from akkudoktoreos.config.config import ConfigEOS, GeneralSettings, get_config from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.pydantic import PydanticBaseModel from akkudoktoreos.utils.docs import get_model_structure_from_examples @@ -250,10 +250,10 @@ def generate_config_md(config_eos: ConfigEOS) -> str: str: The Markdown representation of the configuration spec. """ # Fix file path for general settings to not show local/test file path - ConfigCommonSettings._config_file_path = Path( + GeneralSettings._config_file_path = Path( "/home/user/.config/net.akkudoktoreos.net/EOS.config.json" ) - ConfigCommonSettings._config_folder_path = config_eos.general.config_file_path.parent + GeneralSettings._config_folder_path = config_eos.general.config_file_path.parent markdown = "# Configuration Table\n\n" diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index a7d3322..8e658aa 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -27,6 +27,7 @@ from pydantic_settings.sources import ConfigFileSourceMixin # settings from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.coreabc import SingletonMixin +from akkudoktoreos.core.decorators import classproperty from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logsettings import LoggingCommonSettings from akkudoktoreos.core.pydantic import merge_models @@ -40,7 +41,7 @@ from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.prediction.weather import WeatherCommonSettings from akkudoktoreos.server.server import ServerCommonSettings from akkudoktoreos.utils.datetimeutil import to_timezone -from akkudoktoreos.utils.utils import UtilsCommonSettings, classproperty +from akkudoktoreos.utils.utils import UtilsCommonSettings logger = get_logger(__name__) @@ -63,7 +64,7 @@ def get_absolute_path( return None -class ConfigCommonSettings(SettingsBaseModel): +class GeneralSettings(SettingsBaseModel): """Settings for common configuration. General configuration to set directories of cache and output files and system location (latitude @@ -152,7 +153,7 @@ class SettingsEOS(BaseSettings): Used by updating the configuration with specific settings only. """ - general: Optional[ConfigCommonSettings] = None + general: Optional[GeneralSettings] = None logging: Optional[LoggingCommonSettings] = None devices: Optional[DevicesCommonSettings] = None measurement: Optional[MeasurementCommonSettings] = None @@ -176,7 +177,7 @@ class SettingsEOSDefaults(SettingsEOS): Used by ConfigEOS instance to make all fields available. """ - general: ConfigCommonSettings = ConfigCommonSettings() + general: GeneralSettings = GeneralSettings() logging: LoggingCommonSettings = LoggingCommonSettings() devices: DevicesCommonSettings = DevicesCommonSettings() measurement: MeasurementCommonSettings = MeasurementCommonSettings() @@ -254,7 +255,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): """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. + environment variables, dotenv files and JSON configuration files. It ensures that a default configuration file exists and creates one if necessary. Args: @@ -262,7 +263,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): 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. + file_secret_settings (PydanticBaseSettingsSource): Unused (needed for parent class interface). Returns: tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied. @@ -272,8 +273,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): 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 `ConfigCommonSettings._config_folder_path` and - `ConfigCommonSettings._config_file_path` to reflect the determined paths. + 4. Updates class attributes `GeneralSettings._config_folder_path` and + `GeneralSettings._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: @@ -295,15 +296,14 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): default_settings = JsonConfigSettingsSource( settings_cls, json_file=cls.config_default_file_path ) - ConfigCommonSettings._config_folder_path = config_dir - ConfigCommonSettings._config_file_path = config_file + GeneralSettings._config_folder_path = config_dir + GeneralSettings._config_file_path = config_file return ( init_settings, env_settings, dotenv_settings, file_settings, - file_secret_settings, default_settings, ) diff --git a/src/akkudoktoreos/core/decorators.py b/src/akkudoktoreos/core/decorators.py new file mode 100644 index 0000000..7811a9c --- /dev/null +++ b/src/akkudoktoreos/core/decorators.py @@ -0,0 +1,42 @@ +from typing import Any, Optional + +from akkudoktoreos.core.logging import get_logger + +logger = get_logger(__name__) + + +class classproperty(property): + """A decorator to define a read-only property at the class level. + + This class extends the built-in `property` to allow a method to be accessed + as a property on the class itself, rather than an instance. This is useful + when you want a property-like syntax for methods that depend on the class + rather than any instance of the class. + + Example: + class MyClass: + _value = 42 + + @classproperty + def value(cls): + return cls._value + + print(MyClass.value) # Outputs: 42 + + Methods: + __get__: Retrieves the value of the class property by calling the + decorated method on the class. + + Parameters: + fget (Callable[[type], Any]): A method that takes the class as an + argument and returns a value. + + Raises: + AssertionError: If `fget` is not defined when `__get__` is called. + """ + + 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) diff --git a/src/akkudoktoreos/devices/devices.py b/src/akkudoktoreos/devices/devices.py index 4ade2a7..26235d6 100644 --- a/src/akkudoktoreos/devices/devices.py +++ b/src/akkudoktoreos/devices/devices.py @@ -39,153 +39,6 @@ class Devices(SingletonMixin, DevicesBase): device.post_setup() -# # Devices -# # TODO: Make devices class a container of device simulation providers. -# # Device simulations to be used are then enabled in the configuration. -# battery: ClassVar[Battery] = Battery(provider_id="GenericBattery") -# ev: ClassVar[Battery] = Battery(provider_id="GenericBEV") -# home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher") -# inverter: ClassVar[Inverter] = Inverter( -# self_consumption_predictor=SelfConsumptionProbabilityInterpolator, -# battery=battery, -# provider_id="GenericInverter", -# ) -# -# def update_data(self) -> None: -# """Update device simulation data.""" -# # Assure devices are set up -# self.battery.setup() -# self.ev.setup() -# self.home_appliance.setup() -# self.inverter.setup() -# -# # Pre-allocate arrays for the results, optimized for speed -# self.last_wh_pro_stunde = np.full((self.total_hours), np.nan) -# self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan) -# self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan) -# self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan) -# self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan) -# self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan) -# self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan) -# self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan) -# self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan) -# -# # Set initial state -# simulation_step = to_duration("1 hour") -# if self.battery: -# self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage() -# if self.ev: -# self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage() -# -# # Get predictions for full device simulation time range -# # gesamtlast[stunde] -# load_total_mean = self.prediction.key_to_array( -# "load_total_mean", -# start_datetime=self.start_datetime, -# end_datetime=self.end_datetime, -# interval=simulation_step, -# ) -# # pv_prognose_wh[stunde] -# pvforecast_ac_power = self.prediction.key_to_array( -# "pvforecast_ac_power", -# start_datetime=self.start_datetime, -# end_datetime=self.end_datetime, -# interval=simulation_step, -# ) -# # strompreis_euro_pro_wh[stunde] -# elecprice_marketprice_wh = self.prediction.key_to_array( -# "elecprice_marketprice_wh", -# start_datetime=self.start_datetime, -# end_datetime=self.end_datetime, -# interval=simulation_step, -# ) -# # einspeiseverguetung_euro_pro_wh_arr[stunde] -# # TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr -# einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078) -# -# for stunde_since_now in range(0, self.total_hours): -# hour = self.start_datetime.hour + stunde_since_now -# -# # Accumulate loads and PV generation -# consumption = load_total_mean[stunde_since_now] -# self.verluste_wh_pro_stunde[stunde_since_now] = 0.0 -# -# # Home appliances -# if self.home_appliance: -# ha_load = self.home_appliance.get_load_for_hour(hour) -# consumption += ha_load -# self.home_appliance_wh_per_hour[stunde_since_now] = ha_load -# -# # E-Auto handling -# if self.ev: -# if self.ev_charge_hours[hour] > 0: -# geladene_menge_eauto, verluste_eauto = self.ev.charge_energy( -# None, hour, relative_power=self.ev_charge_hours[hour] -# ) -# consumption += geladene_menge_eauto -# self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto -# self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage() -# -# # Process inverter logic -# grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0) -# if self.battery: -# self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour) -# if self.inverter: -# generation = pvforecast_ac_power[hour] -# grid_export, grid_import, losses, self_consumption = self.inverter.process_energy( -# generation, consumption, hour -# ) -# -# # AC PV Battery Charge -# if self.battery and self.ac_charge_hours[hour] > 0.0: -# self.battery.set_charge_allowed_for_hour(1, hour) -# geladene_menge, verluste_wh = self.battery.charge_energy( -# None, hour, relative_power=self.ac_charge_hours[hour] -# ) -# # print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage()) -# consumption += geladene_menge -# grid_import += geladene_menge -# self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh -# -# self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export -# self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import -# self.verluste_wh_pro_stunde[stunde_since_now] += losses -# self.last_wh_pro_stunde[stunde_since_now] = consumption -# -# # Financial calculations -# self.kosten_euro_pro_stunde[stunde_since_now] = ( -# grid_import * self.strompreis_euro_pro_wh[hour] -# ) -# self.einnahmen_euro_pro_stunde[stunde_since_now] = ( -# grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour] -# ) -# -# # battery SOC tracking -# if self.battery: -# self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage() -# else: -# self.akku_soc_pro_stunde[stunde_since_now] = 0.0 -# -# def report_dict(self) -> Dict[str, Any]: -# """Provides devices simulation output as a dictionary.""" -# out: Dict[str, Optional[Union[np.ndarray, float]]] = { -# "Last_Wh_pro_Stunde": self.last_wh_pro_stunde, -# "grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde, -# "grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde, -# "Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde, -# "akku_soc_pro_stunde": self.akku_soc_pro_stunde, -# "Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde, -# "Gesamtbilanz_Euro": self.total_balance_euro, -# "EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde, -# "Gesamteinnahmen_Euro": self.total_revenues_euro, -# "Gesamtkosten_Euro": self.total_costs_euro, -# "Verluste_Pro_Stunde": self.verluste_wh_pro_stunde, -# "Gesamt_Verluste": self.total_losses_wh, -# "Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour, -# } -# return out - - # Initialize the Devices simulation, it is a singleton. devices = Devices() diff --git a/src/akkudoktoreos/measurement/measurement.py b/src/akkudoktoreos/measurement/measurement.py index bfba35e..395e8a1 100644 --- a/src/akkudoktoreos/measurement/measurement.py +++ b/src/akkudoktoreos/measurement/measurement.py @@ -147,14 +147,12 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence): """Provides measurement key for given name and topic.""" topic = topic.lower() - print(self.topics) if topic not in self.topics: return None topic_keys = [ key for key in self.config.measurement.model_fields.keys() if key.startswith(topic) ] - print(topic_keys) key = None if topic == "load": for config_key in topic_keys: diff --git a/src/akkudoktoreos/utils/utils.py b/src/akkudoktoreos/utils/utils.py index 9ecf982..c3ecd88 100644 --- a/src/akkudoktoreos/utils/utils.py +++ b/src/akkudoktoreos/utils/utils.py @@ -1,5 +1,5 @@ import json -from typing import Any, Optional +from typing import Any import numpy as np @@ -9,14 +9,6 @@ from akkudoktoreos.core.logging import get_logger logger = get_logger(__name__) -class classproperty(property): - def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any: - if owner_cls is None: - return self - assert self.fget is not None - return self.fget(owner_cls) - - class UtilsCommonSettings(SettingsBaseModel): """Utils Configuration.""" diff --git a/tests/test_config.py b/tests/test_config.py index e0cb17b..a0f09fe 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from pydantic import ValidationError -from akkudoktoreos.config.config import ConfigCommonSettings, ConfigEOS +from akkudoktoreos.config.config import ConfigEOS, GeneralSettings from akkudoktoreos.core.logging import get_logger logger = get_logger(__name__) @@ -159,8 +159,8 @@ def test_config_copy(config_eos, monkeypatch): ], ) def test_config_common_settings_valid(latitude, longitude, expected_timezone): - """Test valid settings for ConfigCommonSettings.""" - general_settings = ConfigCommonSettings( + """Test valid settings for GeneralSettings.""" + general_settings = GeneralSettings( latitude=latitude, longitude=longitude, ) @@ -184,30 +184,30 @@ def test_config_common_settings_invalid(field_name, invalid_value, expected_erro "latitude": 40.7128, "longitude": -74.0060, } - assert ConfigCommonSettings(**valid_data) is not None + assert GeneralSettings(**valid_data) is not None valid_data[field_name] = invalid_value with pytest.raises(ValidationError, match=expected_error): - ConfigCommonSettings(**valid_data) + GeneralSettings(**valid_data) def test_config_common_settings_no_location(): """Test that timezone is None when latitude and longitude are not provided.""" - settings = ConfigCommonSettings(latitude=None, longitude=None) + settings = GeneralSettings(latitude=None, longitude=None) assert settings.timezone is None def test_config_common_settings_with_location(): """Test that timezone is correctly computed when latitude and longitude are provided.""" - settings = ConfigCommonSettings(latitude=34.0522, longitude=-118.2437) + settings = GeneralSettings(latitude=34.0522, longitude=-118.2437) assert settings.timezone == "America/Los_Angeles" def test_config_common_settings_timezone_none_when_coordinates_missing(): """Test that timezone is None when latitude or longitude is missing.""" - config_no_latitude = ConfigCommonSettings(latitude=None, longitude=-74.0060) - config_no_longitude = ConfigCommonSettings(latitude=40.7128, longitude=None) - config_no_coords = ConfigCommonSettings(latitude=None, longitude=None) + config_no_latitude = GeneralSettings(latitude=None, longitude=-74.0060) + config_no_longitude = GeneralSettings(latitude=40.7128, longitude=None) + config_no_coords = GeneralSettings(latitude=None, longitude=None) assert config_no_latitude.timezone is None assert config_no_longitude.timezone is None