Review comments

This commit is contained in:
Dominique Lasserre 2025-01-24 21:14:37 +01:00
parent 56403fe053
commit 26762e5e93
10 changed files with 75 additions and 190 deletions

View File

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

View File

@ -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 (°)

View File

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

View File

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

View File

@ -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,
)

View File

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

View File

@ -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()

View File

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

View File

@ -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."""

View File

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