diff --git a/config.yaml b/config.yaml index 0431c9f..9a2c9c1 100644 --- a/config.yaml +++ b/config.yaml @@ -6,7 +6,7 @@ # the root directory (no add-on folder as usual). name: "Akkudoktor-EOS" -version: "0.2.0.dev2602281697121815" +version: "0.2.0.dev2603032000228213" slug: "eos" description: "Akkudoktor-EOS add-on" url: "https://github.com/Akkudoktor-EOS/EOS" diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index 499f5ea..05425ad 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -120,7 +120,7 @@ } }, "general": { - "version": "0.2.0.dev2602281697121815", + "version": "0.0.0", "data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net", "data_output_subpath": "output", "latitude": 52.52, diff --git a/docs/_generated/configgeneral.md b/docs/_generated/configgeneral.md index d7d95ec..f74b0a1 100644 --- a/docs/_generated/configgeneral.md +++ b/docs/_generated/configgeneral.md @@ -16,7 +16,7 @@ | latitude | `EOS_GENERAL__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees between -90 and 90. North is positive (ISO 19115) (°) | | longitude | `EOS_GENERAL__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees within -180 to 180 (°) | | timezone | | `Optional[str]` | `ro` | `N/A` | Computed timezone based on latitude and longitude. | -| version | `EOS_GENERAL__VERSION` | `str` | `rw` | `0.2.0.dev2602281697121815` | Configuration file version. Used to check compatibility. | +| version | `EOS_GENERAL__VERSION` | `Optional[str]` | `rw` | `None` | Configuration file version. | ::: @@ -28,7 +28,7 @@ ```json { "general": { - "version": "0.2.0.dev2602281697121815", + "version": "0.0.0", "data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net", "data_output_subpath": "output", "latitude": 52.52, @@ -46,7 +46,7 @@ ```json { "general": { - "version": "0.2.0.dev2602281697121815", + "version": "0.0.0", "data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net", "data_output_subpath": "output", "latitude": 52.52, diff --git a/docs/_generated/configoptimization.md b/docs/_generated/configoptimization.md index d70f144..7dd36bf 100644 --- a/docs/_generated/configoptimization.md +++ b/docs/_generated/configoptimization.md @@ -75,7 +75,7 @@ | ---- | ---- | --------- | ------- | ----------- | | generations | `Optional[int]` | `rw` | `400` | Number of generations to evaluate the optimal solution [>= 10]. Defaults to 400. | | individuals | `Optional[int]` | `rw` | `300` | Number of individuals (solutions) to generate for the (initial) generation [>= 10]. Defaults to 300. | -| penalties | `Optional[dict[str, Union[float, int, str]]]` | `rw` | `None` | A dictionary of penalty function parameters consisting of a penalty function parameter name and the associated value. | +| penalties | `dict[str, Union[float, int, str]]` | `rw` | `required` | A dictionary of penalty function parameters consisting of a penalty function parameter name and the associated value. | | seed | `Optional[int]` | `rw` | `None` | Fixed seed for genetic algorithm. Defaults to 'None' which means random seed. | ::: diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index 653e23a..69af270 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -1,6 +1,6 @@ # Akkudoktor-EOS -**Version**: `v0.2.0.dev2602281697121815` +**Version**: `v0.2.0.dev2603032000228213` **Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period. diff --git a/openapi.json b/openapi.json index 57ac34a..c0dc673 100644 --- a/openapi.json +++ b/openapi.json @@ -8,7 +8,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "v0.2.0.dev2602281697121815" + "version": "v0.2.0.dev2603032000228213" }, "paths": { "/v1/admin/cache/clear": { @@ -4448,10 +4448,19 @@ "description": "EOS is running as home assistant add-on." }, "version": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Version", - "description": "Configuration file version. Used to check compatibility.", - "default": "0.2.0.dev2602281697121815" + "description": "Configuration file version.", + "examples": [ + "0.0.0" + ] }, "data_folder_path": { "type": "string", @@ -4511,10 +4520,19 @@ "GeneralSettings-Output": { "properties": { "version": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Version", - "description": "Configuration file version. Used to check compatibility.", - "default": "0.2.0.dev2602281697121815" + "description": "Configuration file version.", + "examples": [ + "0.0.0" + ] }, "data_folder_path": { "type": "string", @@ -4685,27 +4703,20 @@ ] }, "penalties": { - "anyOf": [ - { - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "integer" - }, - { - "type": "string" - } - ] + "additionalProperties": { + "anyOf": [ + { + "type": "number" }, - "type": "object" - }, - { - "type": "null" - } - ], + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "type": "object", "title": "Penalties", "description": "A dictionary of penalty function parameters consisting of a penalty function parameter name and the associated value.", "examples": [ diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index cd21e93..db90db1 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -129,11 +129,10 @@ class GeneralSettings(SettingsBaseModel): exclude=True, ) - version: str = Field( - default=__version__, - json_schema_extra={ - "description": "Configuration file version. Used to check compatibility." - }, + version: Optional[str] = Field( + default=None, # keep None here, will be set elsewhere + json_schema_extra={"description": "Configuration file version."}, + examples=["0.0.0"], ) data_folder_path: Path = Field( @@ -195,18 +194,6 @@ class GeneralSettings(SettingsBaseModel): compatible_versions: ClassVar[list[str]] = [__version__] - @field_validator("version") - @classmethod - def check_version(cls, v: str) -> str: - if v not in cls.compatible_versions: - error = ( - f"Incompatible configuration version '{v}'. " - f"Expected: {', '.join(cls.compatible_versions)}." - ) - logger.error(error) - raise ValueError(error) - return v - @field_validator("data_folder_path", mode="after") @classmethod def validate_data_folder_path(cls, value: Optional[Union[str, Path]]) -> Path: @@ -886,6 +873,73 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): return config_file_path + def to_config_json(self) -> str: + """Serialize the configuration to a normalized JSON string. + + The serialization routine ensures that the resulting JSON: + + - Excludes computed fields. + - Excludes fields set to their default values. + - Excludes fields with value ``None``. + - Uses field aliases. + - Recursively removes empty dictionaries and lists. + - Ensures that ``general.version`` is always present and set + to the current application version. + + Returns: + str: A normalized, human-readable JSON string representation + of the configuration. + + Raises: + TypeError: If the serialized configuration root is not a dictionary. + """ + + def remove_empty( + obj: Union[dict[str, Any], list[Any], Any], + ) -> Union[dict[str, Any], list[Any], Any]: + """Recursively remove empty dictionaries, lists, and None values.""" + if isinstance(obj, dict): + cleaned: dict[str, Any] = {k: remove_empty(v) for k, v in obj.items()} + return {k: v for k, v in cleaned.items() if v not in (None, {}, [])} + elif isinstance(obj, list): + cleaned_list: list[Any] = [remove_empty(v) for v in obj] + return [v for v in cleaned_list if v not in (None, {}, [])] + else: + return obj + + # Use model_dump_json to respect custom Pydantic serialization + json_str = self.model_dump_json( + exclude_computed_fields=True, + exclude_defaults=True, + exclude_none=True, + by_alias=True, + ) + + # Load as JSON + root: Any = json.loads(json_str) + + # Remove empty values recursively + cleaned_root = remove_empty(root) + + # Validate that root is a dictionary + if not isinstance(cleaned_root, dict): + raise TypeError( + f"Configuration serialization error: root element must be a dictionary, " + f"got {type(cleaned_root).__name__}" + ) + + # Ensure version is present and correct + cleaned_root.setdefault("general", {}) + cleaned_root["general"]["version"] = __version__ + + # Return pretty-printed JSON + return json.dumps( + cleaned_root, + indent=4, + sort_keys=True, + ensure_ascii=False, + ) + def to_config_file(self) -> None: """Saves the current configuration to the configuration file. @@ -897,8 +951,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): if not self.general.config_file_path: raise ValueError("Configuration file path unknown.") with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out: - json_str = super().model_dump_json(indent=4) - f_out.write(json_str) + f_out.write(self.to_config_json()) def update(self) -> None: """Updates all configuration fields. diff --git a/src/akkudoktoreos/config/configmigrate.py b/src/akkudoktoreos/config/configmigrate.py index 9aab653..f74b853 100644 --- a/src/akkudoktoreos/config/configmigrate.py +++ b/src/akkudoktoreos/config/configmigrate.py @@ -2,11 +2,25 @@ import json import shutil +import types from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Set, Tuple, Union, cast +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Set, + Tuple, + Union, + cast, + get_args, + get_origin, +) from loguru import logger +from akkudoktoreos.core.pydantic import BaseModel from akkudoktoreos.core.version import __version__ if TYPE_CHECKING: @@ -173,13 +187,27 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults": return new_config +def remove_empty(obj: dict) -> dict: + """Recursively remove empty dicts and lists from a dictionary or list.""" + if isinstance(obj, dict): + cleaned = {k: remove_empty(v) for k, v in obj.items()} + # Remove keys where value is None, empty dict, or empty list + return {k: v for k, v in cleaned.items() if v not in (None, {}, [])} + elif isinstance(obj, list): + cleaned = [remove_empty(v) for v in obj] + # Remove empty elements + return [v for v in cleaned if v not in (None, {}, [])] + else: + return obj + + def migrate_config_file(config_file: Path, backup_file: Path) -> bool: """Migrate configuration file to the current version. Returns: bool: True if up-to-date or successfully migrated, False on failure. """ - global migrated_source_paths, mapped_count, auto_count, skipped_paths + global migrated_source_paths, mapped_count, auto_count, skipped_paths, remove_empty # Reset globals at the start of each migration migrated_source_paths = set() @@ -220,13 +248,26 @@ def migrate_config_file(config_file: Path, backup_file: Path) -> bool: f"Failed to backup existing config (replace: {e_replace}; copy: {e_copy}). Continuing without backup." ) + from akkudoktoreos.config.config import SettingsEOSDefaults + + # Strip computed fields + config_data = _strip_computed_fields(config_data, SettingsEOSDefaults) + # Migrate config data new_config = migrate_config_data(config_data) # Write migrated configuration try: with config_file.open("w", encoding="utf-8", newline=None) as f_out: - json_str = new_config.model_dump_json(indent=4) + # Need model_dump_json (not model_dump) because of special serialisation. + json_str = new_config.model_dump_json( + exclude_computed_fields=True, + exclude_defaults=True, + exclude_none=True, + by_alias=True, + ) + cleaned = remove_empty(json.loads(json_str)) + json_str = json.dumps(cleaned, indent=4, ensure_ascii=False) f_out.write(json_str) except Exception as e_write: logger.error(f"Failed to write migrated configuration to '{config_file}': {e_write}") @@ -289,3 +330,93 @@ def _migrate_matching_fields( skipped_paths.append(full_path) continue return count + + +def _unwrap_model_type(annotation: Any) -> type[BaseModel] | None: + """Extract a BaseModel subclass from complex typing annotations. + + Supports: + - Optional[T] + - T | None + - Union[T, ...] + - Annotated[T, ...] + """ + origin = get_origin(annotation) + + # Direct BaseModel + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + return annotation + + # Handle Union / Optional / | + if origin in (Union, types.UnionType): + for arg in get_args(annotation): + model = _unwrap_model_type(arg) + if model: + return model + return None + + # Handle Annotated[T, ...] + if origin is not None: + for arg in get_args(annotation): + model = _unwrap_model_type(arg) + if model: + return model + + return None + + +def _strip_computed_fields( + data: Any, + model: type[BaseModel], +) -> Any: + """Recursively remove computed fields from input data. + + This removes only fields declared via `@computed_field` + in the Pydantic model hierarchy. + + Unknown fields are preserved to allow later migration handling. + + Args: + data: Raw JSON-like data. + model: Target Pydantic model class. + + Returns: + Cleaned data structure. + """ + if not isinstance(data, dict): + return data + + cleaned = dict(data) + + # Remove computed fields at this level + for field_name in model.model_computed_fields: + cleaned.pop(field_name, None) + + # Recurse into declared model fields + for field_name, field in model.model_fields.items(): + if field_name not in cleaned: + continue + + value = cleaned[field_name] + annotation = field.annotation + origin = get_origin(annotation) + + # 1️⃣ Direct nested model or Optional/Union-wrapped model + nested_model = _unwrap_model_type(annotation) + if nested_model and isinstance(value, dict): + cleaned[field_name] = _strip_computed_fields(value, nested_model) + continue + + # 2️⃣ List of models + if origin is list: + item_type = get_args(annotation)[0] + nested_model = _unwrap_model_type(item_type) + + if nested_model and isinstance(value, list): + cleaned[field_name] = [ + _strip_computed_fields(v, nested_model) if isinstance(v, dict) else v + for v in value + ] + continue + + return cleaned diff --git a/src/akkudoktoreos/core/dataabc.py b/src/akkudoktoreos/core/dataabc.py index 89cad5a..6809612 100644 --- a/src/akkudoktoreos/core/dataabc.py +++ b/src/akkudoktoreos/core/dataabc.py @@ -10,6 +10,7 @@ and manipulation of configuration and generic data in a clear, scalable, and str import difflib import json +import traceback from abc import abstractmethod from collections.abc import KeysView, MutableMapping from itertools import chain @@ -1980,8 +1981,15 @@ class DataContainer(SingletonMixin, DataABC, MutableMapping): for provider in self.providers: try: provider.update_data(force_enable=force_enable, force_update=force_update) - except Exception as ex: - error = f"Provider {provider.provider_id()} fails on update - enabled={provider.enabled()}, force_enable={force_enable}, force_update={force_update}: {ex}" + except Exception as e: + trace = "".join(traceback.TracebackException.from_exception(e).format()) + error = ( + f"Provider {provider.provider_id()} fails on update - " + f"enabled={provider.enabled()}, " + f"force_enable={force_enable}, " + f"force_update={force_update}" + f":\n{e}\n{trace}" + ) if provider.enabled(): # The active provider failed — this is a real error worth propagating. logger.error(error) diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index 06412e9..87df573 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -215,7 +215,7 @@ class EnergyManagement( cls.adapter.update_data(force_enable) except Exception as e: trace = "".join(traceback.TracebackException.from_exception(e).format()) - error_msg = f"Adapter update failed - phase {cls._stage}: {e}\n{trace}" + error_msg = f"Adapter update failed - phase {cls._stage}:\n{e}\n{trace}" logger.error(error_msg) cls._stage = EnergyManagementStage.FORECAST_RETRIEVAL @@ -292,7 +292,7 @@ class EnergyManagement( cls.adapter.update_data(force_enable) except Exception as e: trace = "".join(traceback.TracebackException.from_exception(e).format()) - error_msg = f"Adapter update failed - phase {cls._stage}: {e}\n{trace}" + error_msg = f"Adapter update failed - phase {cls._stage}:\n{e}\n{trace}" logger.error(error_msg) # Remember energy run datetime. diff --git a/src/akkudoktoreos/optimization/genetic/geneticparams.py b/src/akkudoktoreos/optimization/genetic/geneticparams.py index 6edf346..b32716d 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticparams.py +++ b/src/akkudoktoreos/optimization/genetic/geneticparams.py @@ -218,17 +218,6 @@ class GeneticOptimizationParameters( if cls.config.optimization.genetic.generations is None: logger.info("Genetic generations unknown - defaulting to 400.") cls.config.optimization.genetic.generations = 400 - if cls.config.optimization.genetic.penalties is None: - logger.info("Genetic penalties unknown - defaulting to demo config.") - cls.config.optimization.genetic.penalties = {"ev_soc_miss": 10} - if "ev_soc_miss" not in cls.config.optimization.genetic.penalties: - logger.info("ev_soc_miss penalty function parameter unknown - defaulting to 10.") - cls.config.optimization.genetic.penalties["ev_soc_miss"] = 10 - if "ac_charge_break_even" not in cls.config.optimization.genetic.penalties: - # Default multiplier 1.0: penalty equals the exact economic loss in € from - # charging at a price that cannot be recovered given the round-trip efficiency - # and the best available future discharge price (after free PV energy is used). - cls.config.optimization.genetic.penalties["ac_charge_break_even"] = 1.0 # Get start solution from last run start_solution = None diff --git a/src/akkudoktoreos/optimization/genetic/geneticsolution.py b/src/akkudoktoreos/optimization/genetic/geneticsolution.py index 210317c..520fc90 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsolution.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsolution.py @@ -2,6 +2,7 @@ from typing import Any, Optional +import numpy as np import pandas as pd from loguru import logger from pydantic import Field, field_validator @@ -460,91 +461,67 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): ) pred = get_prediction() - if "pvforecast_ac_power" in pred.record_keys: - prediction["pvforecast_ac_energy_wh"] = ( - pred.key_to_array( - key="pvforecast_ac_power", + for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in [ + ( + "pvforecast_ac_power", + "linear", + "pvforecast_ac_energy_wh", + power_to_energy_per_interval_factor, + ), + ( + "pvforecast_dc_power", + "linear", + "pvforecast_dc_energy_wh", + power_to_energy_per_interval_factor, + ), + ( + "elecprice_marketprice_wh", + "ffill", + "elec_price_amt_kwh", + 1000.0, + ), + ( + "feed_in_tariff_wh", + "linear", + "feed_in_tariff_amt_kwh", + 1000.0, + ), + ( + "weather_temp_air", + "linear", + "weather_air_temp_celcius", + 1.0, + ), + ( + "loadforecast_power_w", + "linear", + "loadforecast_energy_wh", + power_to_energy_per_interval_factor, + ), + ( + "loadakkudoktor_std_power_w", + "linear", + "loadakkudoktor_std_energy_wh", + power_to_energy_per_interval_factor, + ), + ( + "loadakkudoktor_mean_power_w", + "linear", + "loadakkudoktor_mean_energy_wh", + power_to_energy_per_interval_factor, + ), + ]: + if pred_key in pred.record_keys: + array = pred.key_to_array( + key=pred_key, start_datetime=start_datetime, end_datetime=end_datetime, interval=to_duration(f"{interval_hours} hours"), - fill_method="linear", + fill_method=pred_fill_method, ) - * power_to_energy_per_interval_factor - ).tolist() - if "pvforecast_dc_power" in pred.record_keys: - prediction["pvforecast_dc_energy_wh"] = ( - pred.key_to_array( - key="pvforecast_dc_power", - start_datetime=start_datetime, - end_datetime=end_datetime, - interval=to_duration(f"{interval_hours} hours"), - fill_method="linear", - ) - * power_to_energy_per_interval_factor - ).tolist() - if "elecprice_marketprice_wh" in pred.record_keys: - prediction["elec_price_amt_kwh"] = ( - pred.key_to_array( - key="elecprice_marketprice_wh", - start_datetime=start_datetime, - end_datetime=end_datetime, - interval=to_duration(f"{interval_hours} hours"), - fill_method="ffill", - ) - * 1000 - ).tolist() - if "feed_in_tariff_wh" in pred.record_keys: - prediction["feed_in_tariff_amt_kwh"] = ( - pred.key_to_array( - key="feed_in_tariff_wh", - start_datetime=start_datetime, - end_datetime=end_datetime, - interval=to_duration(f"{interval_hours} hours"), - fill_method="linear", - ) - * 1000 - ).tolist() - if "weather_temp_air" in pred.record_keys: - prediction["weather_air_temp_celcius"] = pred.key_to_array( - key="weather_temp_air", - start_datetime=start_datetime, - end_datetime=end_datetime, - interval=to_duration(f"{interval_hours} hours"), - fill_method="linear", - ).tolist() - if "loadforecast_power_w" in pred.record_keys: - prediction["loadforecast_energy_wh"] = ( - pred.key_to_array( - key="loadforecast_power_w", - start_datetime=start_datetime, - end_datetime=end_datetime, - interval=to_duration(f"{interval_hours} hours"), - fill_method="linear", - ) - * power_to_energy_per_interval_factor - ).tolist() - if "loadakkudoktor_std_power_w" in pred.record_keys: - prediction["loadakkudoktor_std_energy_wh"] = ( - pred.key_to_array( - key="loadakkudoktor_std_power_w", - start_datetime=start_datetime, - end_datetime=end_datetime, - interval=to_duration(f"{interval_hours} hours"), - fill_method="linear", - ) - * power_to_energy_per_interval_factor - ).tolist() - if "loadakkudoktor_mean_power_w" in pred.record_keys: - prediction["loadakkudoktor_mean_energy_wh"] = ( - pred.key_to_array( - key="loadakkudoktor_mean_power_w", - start_datetime=start_datetime, - end_datetime=end_datetime, - interval=to_duration(f"{interval_hours} hours"), - fill_method="linear", - ) - * power_to_energy_per_interval_factor - ).tolist() + # 'key_to_array()' creates None values array if no data records are available. + if array is not None and array.size > 0 and not np.any(pd.isna(array)): + prediction[pred_solution_key] = (array * pred_solution_factor).tolist() optimization_solution = OptimizationSolution( id=f"optimization-genetic@{to_datetime(as_string=True)}", diff --git a/src/akkudoktoreos/optimization/optimization.py b/src/akkudoktoreos/optimization/optimization.py index c2e557a..159aba3 100644 --- a/src/akkudoktoreos/optimization/optimization.py +++ b/src/akkudoktoreos/optimization/optimization.py @@ -41,8 +41,11 @@ class GeneticCommonSettings(SettingsBaseModel): }, ) - penalties: Optional[dict[str, Union[float, int, str]]] = Field( - default=None, + penalties: dict[str, Union[float, int, str]] = Field( + default_factory=lambda: { + "ev_soc_miss": 10, + "ac_charge_break_even": 1.0, + }, json_schema_extra={ "description": "A dictionary of penalty function parameters consisting of a penalty function parameter name and the associated value.", "examples": [ diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index b713617..716a9b9 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -300,7 +300,7 @@ def fastapi_admin_database_stats_get() -> dict: except Exception as e: trace = "".join(traceback.TracebackException.from_exception(e).format()) raise HTTPException( - status_code=400, detail=f"Error on database statistic retrieval: {e}\n{trace}" + status_code=400, detail=f"Error on database statistic retrieval:\n{e}\n{trace}" ) return data @@ -538,7 +538,7 @@ def fastapi_config_put_key( trace = "".join(traceback.TracebackException.from_exception(e).format()) raise HTTPException( status_code=400, - detail=f"Error on update of configuration '{path}','{value}': {e}\n{trace}", + detail=f"Error on update of configuration '{path}','{value}':\n{e}\n{trace}", ) return get_config() @@ -937,7 +937,7 @@ async def fastapi_prediction_update( trace = "".join(traceback.TracebackException.from_exception(e).format()) raise HTTPException( status_code=400, - detail=f"Error on prediction update: {e}\n{trace}", + detail=f"Error on prediction update:\n{e}\n{trace}", ) return Response() @@ -972,7 +972,7 @@ async def fastapi_prediction_update_provider( trace = "".join(traceback.TracebackException.from_exception(e).format()) raise HTTPException( status_code=400, - detail=f"Error on prediction update: {e}\n{trace}", + detail=f"Error on prediction update:\n{e}\n{trace}", ) return Response() diff --git a/tests/test_configfile.py b/tests/test_configfile.py new file mode 100644 index 0000000..9b2b0ea --- /dev/null +++ b/tests/test_configfile.py @@ -0,0 +1,99 @@ +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from akkudoktoreos.config.config import ConfigEOS, GeneralSettings + + +class TestConfigEOSToConfigFile: + + def test_to_config_file_writes_file(self, config_eos): + config_path = config_eos.general.config_file_path + + # Remove file to test writing + config_path.unlink(missing_ok=True) + + config_eos.to_config_file() + + assert config_path.exists() + assert config_path.read_text().strip().startswith("{") + + def test_to_config_file_excludes_computed_fields(self, config_eos): + config_path = config_eos.general.config_file_path + + config_eos.to_config_file() + data = json.loads(config_path.read_text()) + + assert "timezone" not in data["general"] + assert "data_output_path" not in data["general"] + assert "config_folder_path" not in data["general"] + assert "config_file_path" not in data["general"] + + def test_to_config_file_excludes_defaults(self, config_eos): + """Ensure fields with default values are excluded when saving config.""" + + # Pick fields that have defaults + default_latitude = GeneralSettings.model_fields["latitude"].default + default_longitude = GeneralSettings.model_fields["longitude"].default + + # Ensure fields are at default values + config_eos.general.latitude = default_latitude + config_eos.general.longitude = default_longitude + + # Save the config using the correct path managed by config_eos + config_eos.to_config_file() + + # Read back JSON from the correct path + config_file_path = config_eos.general.config_file_path + content = json.loads(config_file_path.read_text(encoding="utf-8")) + + # Default fields should not appear + assert "latitude" not in content["general"] + assert "longitude" not in content["general"] + + # Non-default value should appear + config_eos.general.latitude = 48.0 + config_eos.to_config_file() + content = json.loads(config_file_path.read_text(encoding="utf-8")) + assert content["general"]["latitude"] == 48.0 + + def test_to_config_file_excludes_none_fields(self, config_eos): + config_eos.general.latitude = None + + config_path = config_eos.general.config_file_path + config_eos.to_config_file() + + data = json.loads(config_path.read_text()) + + assert "latitude" not in data["general"] + + def test_to_config_file_includes_version(tmp_path, config_eos): + """Ensure general.version is always included.""" + # Save config + config_eos.to_config_file() + + # Read back JSON + config_file_path = config_eos.general.config_file_path + content = json.loads(config_file_path.read_text(encoding="utf-8")) + + # Assert 'version' is included even if default + assert content["general"]["version"] == config_eos.general.version + + def test_to_config_file_roundtrip(self, config_eos): + config_eos.merge_settings_from_dict( + { + "general": {"latitude": 48.0}, + "server": {"port": 9000}, + } + ) + + config_path = config_eos.general.config_file_path + config_eos.to_config_file() + + raw_data = json.loads(config_path.read_text()) + reloaded = ConfigEOS.model_validate(raw_data) + + assert reloaded.general.latitude == 48.0 + assert reloaded.server.port == 9000 diff --git a/tests/test_configmigrate.py b/tests/test_configmigrate.py index 825f30c..154b66a 100644 --- a/tests/test_configmigrate.py +++ b/tests/test_configmigrate.py @@ -22,6 +22,10 @@ MIGRATION_PAIRS = [ DIR_TESTDATA / "eos_config_andreas_0_1_0.json", DIR_TESTDATA / "eos_config_andreas_now.json", ), + ( + DIR_TESTDATA / "eos_config_unstripped.json", + DIR_TESTDATA / "eos_config_stripped.json", + ), # Add more pairs here: # (DIR_TESTDATA / "old_config_X.json", DIR_TESTDATA / "expected_config_X.json"), ] @@ -124,16 +128,18 @@ class TestConfigMigration: new_model = SettingsEOSDefaults(**migrated_data) assert isinstance(new_model, SettingsEOSDefaults) - def test_migrate_config_file_already_current(self, tmp_path: Path): + def test_migrate_config_file_already_current(self, tmp_config_file: Path): """Test that a current config file returns True immediately.""" - config_path = tmp_path / "EOS_current.json" - default = SettingsEOSDefaults() - with config_path.open("w", encoding="utf-8") as f: - f.write(default.model_dump_json(indent=4)) + backup_file = tmp_config_file.with_suffix(".bak") - backup_file = config_path.with_suffix(".bak") + # Run migration + result = configmigrate.migrate_config_file(tmp_config_file, backup_file) + assert result is True, "Migration should succeed even from invalid version." - result = configmigrate.migrate_config_file(config_path, backup_file) + backup_file.unlink() + assert not backup_file.exists() + + result = configmigrate.migrate_config_file(tmp_config_file, backup_file) assert result is True assert not backup_file.exists(), "No backup should be made if config is already current." diff --git a/tests/testdata/eos_config_andreas_now.json b/tests/testdata/eos_config_andreas_now.json index 9401e9e..bd0f77b 100644 --- a/tests/testdata/eos_config_andreas_now.json +++ b/tests/testdata/eos_config_andreas_now.json @@ -1,98 +1,83 @@ { - "general": { - "data_folder_path": "__ANY__", - "data_output_subpath": "output", - "latitude": 52.5, - "longitude": 13.4 - }, - "cache": { - "subpath": "cache", - "cleanup_interval": 300.0 - }, - "ems": { - "startup_delay": 5.0, - "interval": 300.0 - }, - "logging": { - "console_level": "INFO" - }, - "devices": { - "batteries": [ - { - "device_id": "pv_akku", - "capacity_wh": 30000, - "charging_efficiency": 0.88, - "discharging_efficiency": 0.88, - "max_charge_power_w": 5000, - "min_soc_percentage": 0, - "max_soc_percentage": 100 - } - ], - "electric_vehicles": [ - { - "charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0] - } - ], - "inverters": [], - "home_appliances": [] - }, - "measurement": { - "load_emr_keys": ["Household"] - }, - "optimization": { - "horizon_hours": 48, - "genetic": { - "penalties": { - "ev_soc_miss": 10 - } + "general": { + "version": "__ANY__", + "data_output_subpath": "output", + "latitude": 52.5, + "longitude": 13.4 + }, + "cache": { + "subpath": "cache" + }, + "logging": { + "console_level": "INFO" + }, + "devices": { + "batteries": [ + { + "device_id": "pv_akku", + "capacity_wh": 30000 + } + ], + "electric_vehicles": [ + { + "charge_rates": [ + 0.0, + 0.375, + 0.5, + 0.625, + 0.75, + 0.875, + 1.0 + ] + } + ] + }, + "measurement": { + "load_emr_keys": [ + "Household" + ] + }, + "optimization": { + "horizon_hours": 48 + }, + "elecprice": { + "provider": "ElecPriceAkkudoktor", + "charges_kwh": 0.21 + }, + "load": { + "loadakkudoktor": { + "loadakkudoktor_year_energy_kwh": 13000.0 + } + }, + "pvforecast": { + "provider": "PVForecastAkkudoktor", + "planes": [ + { + "surface_tilt": 87.907, + "surface_azimuth": 175.0, + "userhorizon": [ + 28.0, + 34.0, + 32.0, + 60.0 + ], + "peakpower": 13.11, + "loss": 18.6, + "trackingtype": 0, + "albedo": 0.25, + "inverter_paco": 15000, + "modules_per_string": 20, + "strings_per_inverter": 2 + } + ] + }, + "weather": { + "provider": "WeatherImport" + }, + "server": { + "host": "0.0.0.0", + "verbose": true, + "eosdash_host": "0.0.0.0", + "eosdash_port": 8504 } - }, - "prediction": { - "hours": 48, - "historic_hours": 48 - }, - "elecprice": { - "provider": "ElecPriceAkkudoktor", - "charges_kwh": 0.21 - }, - "load": { - "loadakkudoktor": { - "loadakkudoktor_year_energy_kwh": 13000 - } - }, - "pvforecast": { - "provider": "PVForecastAkkudoktor", - "planes": [ - { - "surface_tilt": 87.907, - "surface_azimuth": 175.0, - "userhorizon": [28.0, 34.0, 32.0, 60.0], - "peakpower": 13.110, - "pvtechchoice": "crystSi", - "mountingplace": "free", - "loss": 18.6, - "trackingtype": 0, - "optimal_surface_tilt": false, - "optimalangles": false, - "albedo": 0.25, - "module_model": null, - "inverter_model": null, - "inverter_paco": 15000, - "modules_per_string": 20, - "strings_per_inverter": 2 - } - ] - }, - "weather": { - "provider": "WeatherImport" - }, - "server": { - "host": "0.0.0.0", - "port": 8503, - "verbose": true, - "startup_eosdash": true, - "eosdash_host": "0.0.0.0", - "eosdash_port": 8504 - }, - "utils": {} } diff --git a/tests/testdata/eos_config_minimal_now.json b/tests/testdata/eos_config_minimal_now.json index 3caecb2..5833ee8 100644 --- a/tests/testdata/eos_config_minimal_now.json +++ b/tests/testdata/eos_config_minimal_now.json @@ -1,29 +1,20 @@ { - "elecprice": { - "charges_kwh": 0.21, - "provider": "ElecPriceImport" - }, - "prediction": { - "historic_hours": 48, - "hours": 48 - }, - "optimization": { - "horizon_hours": 48, - "algorithm": "GENETIC", - "genetic": { - "individuals": 300, - "generations": 400 + "general": { + "version": "__ANY__", + "data_output_subpath": "output", + "latitude": 52.5, + "longitude": 13.4 + }, + "optimization": { + "horizon_hours": 48 + }, + "elecprice": { + "provider": "ElecPriceImport", + "charges_kwh": 0.21 + }, + "server": { + "host": "0.0.0.0", + "eosdash_host": "0.0.0.0", + "eosdash_port": 8504 } - }, - "general": { - "latitude": 52.5, - "longitude": 13.4 - }, - "server": { - "startup_eosdash": true, - "host": "0.0.0.0", - "port": 8503, - "eosdash_host": "0.0.0.0", - "eosdash_port": 8504 - } -} +} \ No newline at end of file diff --git a/tests/testdata/eos_config_stripped.json b/tests/testdata/eos_config_stripped.json new file mode 100644 index 0000000..9f50e14 --- /dev/null +++ b/tests/testdata/eos_config_stripped.json @@ -0,0 +1,134 @@ +{ + "general": { + "version": "__ANY__", + "data_output_subpath": "output" + }, + "cache": { + "subpath": "cache" + }, + "ems": { + "mode": "OPTIMIZATION" + }, + "devices": { + "batteries": [ + { + "device_id": "battery1" + } + ], + "max_batteries": 1, + "electric_vehicles": [ + { + "device_id": "ev11", + "capacity_wh": 50000, + "min_soc_percentage": 70 + } + ], + "max_electric_vehicles": 1, + "inverters": [ + { + "device_id": "inverter1", + "max_power_w": 10000.0, + "battery_id": "battery1" + } + ], + "max_inverters": 1, + "home_appliances": [ + { + "device_id": "dishwasher1", + "consumption_wh": 2000, + "duration_h": 3, + "time_windows": { + "windows": [ + { + "start_time": "08:00:00.000000 Europe/Berlin", + "duration": "5 hours" + }, + { + "start_time": "15:00:00.000000 Europe/Berlin", + "duration": "3 hours" + } + ] + } + } + ], + "max_home_appliances": 1 + }, + "elecprice": { + "provider": "ElecPriceAkkudoktor" + }, + "feedintariff": { + "provider": "FeedInTariffFixed", + "provider_settings": { + "FeedInTariffFixed": { + "feed_in_tariff_kwh": 0.078 + } + } + }, + "load": { + "provider": "LoadAkkudoktorAdjusted", + "loadakkudoktor": { + "loadakkudoktor_year_energy_kwh": 3000.0 + } + }, + "pvforecast": { + "provider": "PVForecastAkkudoktor", + "planes": [ + { + "surface_tilt": 7.0, + "surface_azimuth": 170.0, + "userhorizon": [ + 20.0, + 27.0, + 22.0, + 20.0 + ], + "peakpower": 5.0, + "inverter_paco": 10000 + }, + { + "surface_tilt": 7.0, + "surface_azimuth": 90.0, + "userhorizon": [ + 30.0, + 30.0, + 30.0, + 50.0 + ], + "peakpower": 4.8, + "inverter_paco": 10000 + }, + { + "surface_tilt": 60.0, + "surface_azimuth": 140.0, + "userhorizon": [ + 60.0, + 30.0, + 0.0, + 30.0 + ], + "peakpower": 1.4, + "inverter_paco": 2000 + }, + { + "surface_tilt": 45.0, + "surface_azimuth": 185.0, + "userhorizon": [ + 45.0, + 25.0, + 30.0, + 60.0 + ], + "peakpower": 1.6, + "inverter_paco": 1400 + } + ], + "max_planes": 4 + }, + "weather": { + "provider": "BrightSky" + }, + "server": { + "eosdash_host": "127.0.0.1", + "eosdash_port": 8504 + } +} diff --git a/tests/testdata/eos_config_unstripped.json b/tests/testdata/eos_config_unstripped.json new file mode 100644 index 0000000..cba188b --- /dev/null +++ b/tests/testdata/eos_config_unstripped.json @@ -0,0 +1,596 @@ +{ + "general": { + "version": "0.2.0.dev2603031877440961", + "data_folder_path": "/home/bobby/.local/share/net.akkudoktor.eos", + "data_output_subpath": "output", + "latitude": 52.52, + "longitude": 13.405, + "timezone": "Europe/Berlin", + "data_output_path": "/home/bobby/.local/share/net.akkudoktor.eos/output", + "config_folder_path": "/home/bobby/.config/net.akkudoktor.eos", + "config_file_path": "/home/bobby/.config/net.akkudoktor.eos/EOS.config.json" + }, + "cache": { + "subpath": "cache", + "cleanup_interval": 300 + }, + "database": { + "provider": null, + "compression_level": 9, + "initial_load_window_h": null, + "keep_duration_h": null, + "autosave_interval_sec": 10, + "compaction_interval_sec": 604800, + "batch_size": 100, + "providers": [ + "LMDB", + "SQLite" + ] + }, + "ems": { + "startup_delay": 5, + "interval": 300, + "mode": "OPTIMIZATION" + }, + "logging": { + "console_level": null, + "file_level": null, + "file_path": "/home/bobby/.local/share/net.akkudoktor.eos/output/eos.log" + }, + "devices": { + "batteries": [ + { + "device_id": "battery1", + "capacity_wh": 8000, + "charging_efficiency": 0.88, + "discharging_efficiency": 0.88, + "levelized_cost_of_storage_kwh": 0, + "max_charge_power_w": 5000, + "min_charge_power_w": 50, + "charge_rates": [ + 0, + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + 0.7, + 0.8, + 0.9, + 1 + ], + "min_soc_percentage": 0, + "max_soc_percentage": 100, + "measurement_key_soc_factor": "battery1-soc-factor", + "measurement_key_power_l1_w": "battery1-power-l1-w", + "measurement_key_power_l2_w": "battery1-power-l2-w", + "measurement_key_power_l3_w": "battery1-power-l3-w", + "measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w", + "measurement_keys": [ + "battery1-soc-factor", + "battery1-power-l1-w", + "battery1-power-l2-w", + "battery1-power-l3-w", + "battery1-power-3-phase-sym-w" + ] + } + ], + "max_batteries": 1, + "electric_vehicles": [ + { + "device_id": "ev11", + "capacity_wh": 50000, + "charging_efficiency": 0.88, + "discharging_efficiency": 0.88, + "levelized_cost_of_storage_kwh": 0, + "max_charge_power_w": 5000, + "min_charge_power_w": 50, + "charge_rates": [ + 0, + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + 0.7, + 0.8, + 0.9, + 1 + ], + "min_soc_percentage": 70, + "max_soc_percentage": 100, + "measurement_key_soc_factor": "ev11-soc-factor", + "measurement_key_power_l1_w": "ev11-power-l1-w", + "measurement_key_power_l2_w": "ev11-power-l2-w", + "measurement_key_power_l3_w": "ev11-power-l3-w", + "measurement_key_power_3_phase_sym_w": "ev11-power-3-phase-sym-w", + "measurement_keys": [ + "ev11-soc-factor", + "ev11-power-l1-w", + "ev11-power-l2-w", + "ev11-power-l3-w", + "ev11-power-3-phase-sym-w" + ] + } + ], + "max_electric_vehicles": 1, + "inverters": [ + { + "device_id": "inverter1", + "max_power_w": 10000, + "battery_id": "battery1", + "ac_to_dc_efficiency": 1, + "dc_to_ac_efficiency": 1, + "max_ac_charge_power_w": null, + "measurement_keys": [] + } + ], + "max_inverters": 1, + "home_appliances": [ + { + "device_id": "dishwasher1", + "consumption_wh": 2000, + "duration_h": 3, + "time_windows": { + "windows": [ + { + "start_time": "08:00:00.000000 Europe/Berlin", + "duration": "5 hours", + "day_of_week": null, + "date": null, + "locale": null + }, + { + "start_time": "15:00:00.000000 Europe/Berlin", + "duration": "3 hours", + "day_of_week": null, + "date": null, + "locale": null + } + ] + }, + "measurement_keys": [] + } + ], + "max_home_appliances": 1, + "measurement_keys": [ + "battery1-soc-factor", + "battery1-power-l1-w", + "battery1-power-l2-w", + "battery1-power-l3-w", + "battery1-power-3-phase-sym-w", + "ev11-soc-factor", + "ev11-power-l1-w", + "ev11-power-l2-w", + "ev11-power-l3-w", + "ev11-power-3-phase-sym-w" + ] + }, + "measurement": { + "historic_hours": 17520, + "load_emr_keys": null, + "grid_export_emr_keys": null, + "grid_import_emr_keys": null, + "pv_production_emr_keys": null, + "keys": [] + }, + "optimization": { + "horizon_hours": 24, + "interval": 3600, + "algorithm": "GENETIC", + "genetic": { + "individuals": 300, + "generations": 400, + "seed": null, + "penalties": { + "ev_soc_miss": 10, + "ac_charge_break_even": 1 + } + }, + "keys": [ + "battery1_fault_op_factor", + "battery1_fault_op_mode", + "battery1_forced_charge_op_factor", + "battery1_forced_charge_op_mode", + "battery1_forced_discharge_op_factor", + "battery1_forced_discharge_op_mode", + "battery1_frequency_regulation_op_factor", + "battery1_frequency_regulation_op_mode", + "battery1_grid_support_export_op_factor", + "battery1_grid_support_export_op_mode", + "battery1_grid_support_import_op_factor", + "battery1_grid_support_import_op_mode", + "battery1_idle_op_factor", + "battery1_idle_op_mode", + "battery1_non_export_op_factor", + "battery1_non_export_op_mode", + "battery1_outage_supply_op_factor", + "battery1_outage_supply_op_mode", + "battery1_peak_shaving_op_factor", + "battery1_peak_shaving_op_mode", + "battery1_ramp_rate_control_op_factor", + "battery1_ramp_rate_control_op_mode", + "battery1_reserve_backup_op_factor", + "battery1_reserve_backup_op_mode", + "battery1_self_consumption_op_factor", + "battery1_self_consumption_op_mode", + "battery1_soc_factor", + "costs_amt", + "date_time", + "ev11_fault_op_factor", + "ev11_fault_op_mode", + "ev11_forced_charge_op_factor", + "ev11_forced_charge_op_mode", + "ev11_forced_discharge_op_factor", + "ev11_forced_discharge_op_mode", + "ev11_frequency_regulation_op_factor", + "ev11_frequency_regulation_op_mode", + "ev11_grid_support_export_op_factor", + "ev11_grid_support_export_op_mode", + "ev11_grid_support_import_op_factor", + "ev11_grid_support_import_op_mode", + "ev11_idle_op_factor", + "ev11_idle_op_mode", + "ev11_non_export_op_factor", + "ev11_non_export_op_mode", + "ev11_outage_supply_op_factor", + "ev11_outage_supply_op_mode", + "ev11_peak_shaving_op_factor", + "ev11_peak_shaving_op_mode", + "ev11_ramp_rate_control_op_factor", + "ev11_ramp_rate_control_op_mode", + "ev11_reserve_backup_op_factor", + "ev11_reserve_backup_op_mode", + "ev11_self_consumption_op_factor", + "ev11_self_consumption_op_mode", + "ev11_soc_factor", + "genetic_ac_charge_factor", + "genetic_dc_charge_factor", + "genetic_discharge_allowed_factor", + "genetic_ev_charge_factor", + "grid_consumption_energy_wh", + "grid_feedin_energy_wh", + "homeappliance1_energy_wh", + "homeappliance1_off_op_factor", + "homeappliance1_off_op_mode", + "homeappliance1_run_op_factor", + "homeappliance1_run_op_mode", + "load_energy_wh", + "losses_energy_wh", + "revenue_amt" + ] + }, + "prediction": { + "hours": 48, + "historic_hours": 48 + }, + "elecprice": { + "provider": "ElecPriceAkkudoktor", + "charges_kwh": null, + "vat_rate": 1.19, + "elecpriceimport": { + "import_file_path": null, + "import_json": null + }, + "energycharts": { + "bidding_zone": "DE-LU" + }, + "providers": [ + "ElecPriceAkkudoktor", + "ElecPriceEnergyCharts", + "ElecPriceImport" + ] + }, + "feedintariff": { + "provider": "FeedInTariffFixed", + "provider_settings": { + "FeedInTariffFixed": { + "feed_in_tariff_kwh": 0.078 + }, + "FeedInTariffImport": null + }, + "providers": [ + "FeedInTariffFixed", + "FeedInTariffImport" + ] + }, + "load": { + "provider": "LoadAkkudoktorAdjusted", + "loadakkudoktor": { + "loadakkudoktor_year_energy_kwh": 3000 + }, + "loadvrm": { + "load_vrm_token": "your-token", + "load_vrm_idsite": 12345 + }, + "loadimport": { + "import_file_path": null, + "import_json": null + }, + "providers": [ + "LoadAkkudoktor", + "LoadAkkudoktorAdjusted", + "LoadVrm", + "LoadImport" + ] + }, + "pvforecast": { + "provider": "PVForecastAkkudoktor", + "provider_settings": { + "PVForecastImport": null, + "PVForecastVrm": null + }, + "planes": [ + { + "surface_tilt": 7, + "surface_azimuth": 170, + "userhorizon": [ + 20, + 27, + 22, + 20 + ], + "peakpower": 5, + "pvtechchoice": "crystSi", + "mountingplace": "free", + "loss": 14, + "trackingtype": null, + "optimal_surface_tilt": false, + "optimalangles": false, + "albedo": null, + "module_model": null, + "inverter_model": null, + "inverter_paco": 10000, + "modules_per_string": null, + "strings_per_inverter": null + }, + { + "surface_tilt": 7, + "surface_azimuth": 90, + "userhorizon": [ + 30, + 30, + 30, + 50 + ], + "peakpower": 4.8, + "pvtechchoice": "crystSi", + "mountingplace": "free", + "loss": 14, + "trackingtype": null, + "optimal_surface_tilt": false, + "optimalangles": false, + "albedo": null, + "module_model": null, + "inverter_model": null, + "inverter_paco": 10000, + "modules_per_string": null, + "strings_per_inverter": null + }, + { + "surface_tilt": 60, + "surface_azimuth": 140, + "userhorizon": [ + 60, + 30, + 0, + 30 + ], + "peakpower": 1.4, + "pvtechchoice": "crystSi", + "mountingplace": "free", + "loss": 14, + "trackingtype": null, + "optimal_surface_tilt": false, + "optimalangles": false, + "albedo": null, + "module_model": null, + "inverter_model": null, + "inverter_paco": 2000, + "modules_per_string": null, + "strings_per_inverter": null + }, + { + "surface_tilt": 45, + "surface_azimuth": 185, + "userhorizon": [ + 45, + 25, + 30, + 60 + ], + "peakpower": 1.6, + "pvtechchoice": "crystSi", + "mountingplace": "free", + "loss": 14, + "trackingtype": null, + "optimal_surface_tilt": false, + "optimalangles": false, + "albedo": null, + "module_model": null, + "inverter_model": null, + "inverter_paco": 1400, + "modules_per_string": null, + "strings_per_inverter": null + } + ], + "max_planes": 4, + "providers": [ + "PVForecastAkkudoktor", + "PVForecastVrm", + "PVForecastImport" + ], + "planes_peakpower": [ + 5, + 4.8, + 1.4, + 1.6 + ], + "planes_azimuth": [ + 170, + 90, + 140, + 185 + ], + "planes_tilt": [ + 7, + 7, + 60, + 45 + ], + "planes_userhorizon": [ + [ + 20, + 27, + 22, + 20 + ], + [ + 30, + 30, + 30, + 50 + ], + [ + 60, + 30, + 0, + 30 + ], + [ + 45, + 25, + 30, + 60 + ] + ], + "planes_inverter_paco": [ + 10000, + 10000, + 2000, + 1400 + ] + }, + "weather": { + "provider": "BrightSky", + "provider_settings": { + "WeatherImport": null + }, + "providers": [ + "BrightSky", + "ClearOutside", + "WeatherImport" + ] + }, + "server": { + "host": "127.0.0.1", + "port": 8503, + "verbose": false, + "startup_eosdash": true, + "eosdash_host": "127.0.0.1", + "eosdash_port": 8504, + "eosdash_supervise_interval_sec": 10 + }, + "utils": {}, + "adapter": { + "provider": null, + "homeassistant": { + "config_entity_ids": null, + "load_emr_entity_ids": null, + "grid_export_emr_entity_ids": null, + "grid_import_emr_entity_ids": null, + "pv_production_emr_entity_ids": null, + "device_measurement_entity_ids": null, + "device_instruction_entity_ids": null, + "solution_entity_ids": null, + "homeassistant_entity_ids": [], + "eos_solution_entity_ids": [ + "sensor.eos_battery1_fault_op_factor", + "sensor.eos_battery1_fault_op_mode", + "sensor.eos_battery1_forced_charge_op_factor", + "sensor.eos_battery1_forced_charge_op_mode", + "sensor.eos_battery1_forced_discharge_op_factor", + "sensor.eos_battery1_forced_discharge_op_mode", + "sensor.eos_battery1_frequency_regulation_op_factor", + "sensor.eos_battery1_frequency_regulation_op_mode", + "sensor.eos_battery1_grid_support_export_op_factor", + "sensor.eos_battery1_grid_support_export_op_mode", + "sensor.eos_battery1_grid_support_import_op_factor", + "sensor.eos_battery1_grid_support_import_op_mode", + "sensor.eos_battery1_idle_op_factor", + "sensor.eos_battery1_idle_op_mode", + "sensor.eos_battery1_non_export_op_factor", + "sensor.eos_battery1_non_export_op_mode", + "sensor.eos_battery1_outage_supply_op_factor", + "sensor.eos_battery1_outage_supply_op_mode", + "sensor.eos_battery1_peak_shaving_op_factor", + "sensor.eos_battery1_peak_shaving_op_mode", + "sensor.eos_battery1_ramp_rate_control_op_factor", + "sensor.eos_battery1_ramp_rate_control_op_mode", + "sensor.eos_battery1_reserve_backup_op_factor", + "sensor.eos_battery1_reserve_backup_op_mode", + "sensor.eos_battery1_self_consumption_op_factor", + "sensor.eos_battery1_self_consumption_op_mode", + "sensor.eos_battery1_soc_factor", + "sensor.eos_costs_amt", + "sensor.eos_date_time", + "sensor.eos_ev11_fault_op_factor", + "sensor.eos_ev11_fault_op_mode", + "sensor.eos_ev11_forced_charge_op_factor", + "sensor.eos_ev11_forced_charge_op_mode", + "sensor.eos_ev11_forced_discharge_op_factor", + "sensor.eos_ev11_forced_discharge_op_mode", + "sensor.eos_ev11_frequency_regulation_op_factor", + "sensor.eos_ev11_frequency_regulation_op_mode", + "sensor.eos_ev11_grid_support_export_op_factor", + "sensor.eos_ev11_grid_support_export_op_mode", + "sensor.eos_ev11_grid_support_import_op_factor", + "sensor.eos_ev11_grid_support_import_op_mode", + "sensor.eos_ev11_idle_op_factor", + "sensor.eos_ev11_idle_op_mode", + "sensor.eos_ev11_non_export_op_factor", + "sensor.eos_ev11_non_export_op_mode", + "sensor.eos_ev11_outage_supply_op_factor", + "sensor.eos_ev11_outage_supply_op_mode", + "sensor.eos_ev11_peak_shaving_op_factor", + "sensor.eos_ev11_peak_shaving_op_mode", + "sensor.eos_ev11_ramp_rate_control_op_factor", + "sensor.eos_ev11_ramp_rate_control_op_mode", + "sensor.eos_ev11_reserve_backup_op_factor", + "sensor.eos_ev11_reserve_backup_op_mode", + "sensor.eos_ev11_self_consumption_op_factor", + "sensor.eos_ev11_self_consumption_op_mode", + "sensor.eos_ev11_soc_factor", + "sensor.eos_genetic_ac_charge_factor", + "sensor.eos_genetic_dc_charge_factor", + "sensor.eos_genetic_discharge_allowed_factor", + "sensor.eos_genetic_ev_charge_factor", + "sensor.eos_grid_consumption_energy_wh", + "sensor.eos_grid_feedin_energy_wh", + "sensor.eos_homeappliance1_energy_wh", + "sensor.eos_homeappliance1_off_op_factor", + "sensor.eos_homeappliance1_off_op_mode", + "sensor.eos_homeappliance1_run_op_factor", + "sensor.eos_homeappliance1_run_op_mode", + "sensor.eos_load_energy_wh", + "sensor.eos_losses_energy_wh", + "sensor.eos_revenue_amt" + ], + "eos_device_instruction_entity_ids": [ + "sensor.eos_battery1", + "sensor.eos_ev11", + "sensor.eos_homeappliance1" + ] + }, + "nodered": { + "host": "127.0.0.1", + "port": 1880 + }, + "providers": [ + "HomeAssistant", + "NodeRED" + ] + } +} \ No newline at end of file diff --git a/uv.lock b/uv.lock index 4274333..9919caf 100644 --- a/uv.lock +++ b/uv.lock @@ -84,10 +84,10 @@ requires-dist = [ { name = "deap", specifier = "==1.4.3" }, { name = "deprecated", marker = "extra == 'dev'", specifier = "==1.3.1" }, { name = "docutils", marker = "extra == 'dev'", specifier = "==0.21.2" }, - { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = "==0.134.0" }, + { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = "==0.135.1" }, { name = "fastapi-cli", specifier = "==0.0.24" }, { name = "gitpython", marker = "extra == 'dev'", specifier = "==3.1.46" }, - { name = "linkify-it-py", specifier = "==2.0.3" }, + { name = "linkify-it-py", specifier = "==2.1.0" }, { name = "lmdb", specifier = "==1.7.5" }, { name = "loguru", specifier = "==0.7.3" }, { name = "markdown-it-py", specifier = "==4.0.0" }, @@ -112,7 +112,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "pytest-xprocess", marker = "extra == 'dev'", specifier = "==1.0.2" }, - { name = "python-fasthtml", specifier = "==0.12.47" }, + { name = "python-fasthtml", specifier = "==0.12.48" }, { name = "requests", specifier = "==2.32.5" }, { name = "rich-toolkit", specifier = "==0.19.7" }, { name = "scipy", specifier = "==1.17.1" }, @@ -828,7 +828,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.134.0" +version = "0.135.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -837,9 +837,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/15/647ea81cb73b55b48fb095158a9cd64e42e9e4f1d34dbb5cc4a4939779d6/fastapi-0.134.0.tar.gz", hash = "sha256:3122b1ea0dbeaab48b5976e80b99ca7eda02be154bf03e126a33220e73255a9a", size = 385667, upload-time = "2026-02-27T21:18:12.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/e6/fd49c28a54b7d6f5c64045155e40f6cff9ed4920055043fb5ac7969f7f2f/fastapi-0.134.0-py3-none-any.whl", hash = "sha256:f4e7214f24b2262258492e05c48cf21125e4ffc427e30dd32fb4f74049a3d56a", size = 110404, upload-time = "2026-02-27T21:18:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, ] [package.optional-dependencies] @@ -1315,14 +1315,14 @@ wheels = [ [[package]] name = "linkify-it-py" -version = "2.0.3" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uc-micro-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, ] [[package]] @@ -2449,7 +2449,7 @@ wheels = [ [[package]] name = "python-fasthtml" -version = "0.12.47" +version = "0.12.48" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -2463,9 +2463,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/47/afd5be266a7215921495553ad7afa26fa4c4a4b6e2f0d8f076f6098dfc1a/python_fasthtml-0.12.47.tar.gz", hash = "sha256:9efa6e1ff846e34889fcc4cbbab0b33b9e4d12c6a5d12aa1b8cf613675b7cee5", size = 71755, upload-time = "2026-02-21T02:20:52.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/20/545f9219e6212cb9813016dafc4f65de8f90f3b0b7113df0936b834bbecb/python_fasthtml-0.12.48.tar.gz", hash = "sha256:89b86391bd30bbd0edacc6806ceb9946442440e5fa0949d302232749ce3385d5", size = 71794, upload-time = "2026-03-02T17:59:49.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/47/cdd57c2283f1072671d0ccf602d65564b289e92fba2edecb12541c830701/python_fasthtml-0.12.47-py3-none-any.whl", hash = "sha256:2189dfa0ec4bd04e01c1ad28d7ecf7b17ec50a1167f63fdbc9b398d4ad6b6145", size = 75500, upload-time = "2026-02-21T02:20:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/24/97/4b98c5560342700b82588c5b670acb041842f9a7d7bf429e849a8b847251/python_fasthtml-0.12.48-py3-none-any.whl", hash = "sha256:124d3524005d3bf159880f26e6b7a6b998213c06c8af763801556b3a4612080d", size = 75512, upload-time = "2026-03-02T17:59:47.305Z" }, ] [[package]]