fix: prevent exception when load prediction data is missing (#925)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled

Validate solution prediction data before processing.
If required prediction data is missing, the prediction is skipped
instead of raising an exception.

Introduce a new configuration file saving policy to improve loading robustness:
- Exclude computed fields
- Exclude fields set to their default values
- Exclude fields with value None
- Use field aliases
- Recursively remove empty dictionaries and lists
- Ensure general.version is always present and correctly set

When loading older configuration files, computed fields are now stripped
before migration. This further improves backward compatibility and loading
robustness.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-03-07 14:46:30 +01:00
committed by GitHub
parent 36e2e4c15b
commit 997e7646e9
21 changed files with 1282 additions and 299 deletions

View File

@@ -6,7 +6,7 @@
# the root directory (no add-on folder as usual). # the root directory (no add-on folder as usual).
name: "Akkudoktor-EOS" name: "Akkudoktor-EOS"
version: "0.2.0.dev2602281697121815" version: "0.2.0.dev2603032000228213"
slug: "eos" slug: "eos"
description: "Akkudoktor-EOS add-on" description: "Akkudoktor-EOS add-on"
url: "https://github.com/Akkudoktor-EOS/EOS" url: "https://github.com/Akkudoktor-EOS/EOS"

View File

@@ -120,7 +120,7 @@
} }
}, },
"general": { "general": {
"version": "0.2.0.dev2602281697121815", "version": "0.0.0",
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net", "data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
"data_output_subpath": "output", "data_output_subpath": "output",
"latitude": 52.52, "latitude": 52.52,

View File

@@ -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) (°) | | 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 (°) | | 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. | | 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. |
::: :::
<!-- pyml enable line-length --> <!-- pyml enable line-length -->
@@ -28,7 +28,7 @@
```json ```json
{ {
"general": { "general": {
"version": "0.2.0.dev2602281697121815", "version": "0.0.0",
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net", "data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
"data_output_subpath": "output", "data_output_subpath": "output",
"latitude": 52.52, "latitude": 52.52,
@@ -46,7 +46,7 @@
```json ```json
{ {
"general": { "general": {
"version": "0.2.0.dev2602281697121815", "version": "0.0.0",
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net", "data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
"data_output_subpath": "output", "data_output_subpath": "output",
"latitude": 52.52, "latitude": 52.52,

View File

@@ -75,7 +75,7 @@
| ---- | ---- | --------- | ------- | ----------- | | ---- | ---- | --------- | ------- | ----------- |
| generations | `Optional[int]` | `rw` | `400` | Number of generations to evaluate the optimal solution [>= 10]. Defaults to 400. | | 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. | | 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. | | seed | `Optional[int]` | `rw` | `None` | Fixed seed for genetic algorithm. Defaults to 'None' which means random seed. |
::: :::
<!-- pyml enable line-length --> <!-- pyml enable line-length -->

View File

@@ -1,6 +1,6 @@
# Akkudoktor-EOS # Akkudoktor-EOS
**Version**: `v0.2.0.dev2602281697121815` **Version**: `v0.2.0.dev2603032000228213`
<!-- pyml disable line-length --> <!-- pyml disable line-length -->
**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. **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.

View File

@@ -8,7 +8,7 @@
"name": "Apache 2.0", "name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html" "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
}, },
"version": "v0.2.0.dev2602281697121815" "version": "v0.2.0.dev2603032000228213"
}, },
"paths": { "paths": {
"/v1/admin/cache/clear": { "/v1/admin/cache/clear": {
@@ -4448,10 +4448,19 @@
"description": "EOS is running as home assistant add-on." "description": "EOS is running as home assistant add-on."
}, },
"version": { "version": {
"type": "string", "anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version", "title": "Version",
"description": "Configuration file version. Used to check compatibility.", "description": "Configuration file version.",
"default": "0.2.0.dev2602281697121815" "examples": [
"0.0.0"
]
}, },
"data_folder_path": { "data_folder_path": {
"type": "string", "type": "string",
@@ -4511,10 +4520,19 @@
"GeneralSettings-Output": { "GeneralSettings-Output": {
"properties": { "properties": {
"version": { "version": {
"type": "string", "anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version", "title": "Version",
"description": "Configuration file version. Used to check compatibility.", "description": "Configuration file version.",
"default": "0.2.0.dev2602281697121815" "examples": [
"0.0.0"
]
}, },
"data_folder_path": { "data_folder_path": {
"type": "string", "type": "string",
@@ -4685,27 +4703,20 @@
] ]
}, },
"penalties": { "penalties": {
"anyOf": [ "additionalProperties": {
{ "anyOf": [
"additionalProperties": { {
"anyOf": [ "type": "number"
{
"type": "number"
},
{
"type": "integer"
},
{
"type": "string"
}
]
}, },
"type": "object" {
}, "type": "integer"
{ },
"type": "null" {
} "type": "string"
], }
]
},
"type": "object",
"title": "Penalties", "title": "Penalties",
"description": "A dictionary of penalty function parameters consisting of a penalty function parameter name and the associated value.", "description": "A dictionary of penalty function parameters consisting of a penalty function parameter name and the associated value.",
"examples": [ "examples": [

View File

@@ -129,11 +129,10 @@ class GeneralSettings(SettingsBaseModel):
exclude=True, exclude=True,
) )
version: str = Field( version: Optional[str] = Field(
default=__version__, default=None, # keep None here, will be set elsewhere
json_schema_extra={ json_schema_extra={"description": "Configuration file version."},
"description": "Configuration file version. Used to check compatibility." examples=["0.0.0"],
},
) )
data_folder_path: Path = Field( data_folder_path: Path = Field(
@@ -195,18 +194,6 @@ class GeneralSettings(SettingsBaseModel):
compatible_versions: ClassVar[list[str]] = [__version__] 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") @field_validator("data_folder_path", mode="after")
@classmethod @classmethod
def validate_data_folder_path(cls, value: Optional[Union[str, Path]]) -> Path: def validate_data_folder_path(cls, value: Optional[Union[str, Path]]) -> Path:
@@ -886,6 +873,73 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
return config_file_path 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: def to_config_file(self) -> None:
"""Saves the current configuration to the configuration file. """Saves the current configuration to the configuration file.
@@ -897,8 +951,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
if not self.general.config_file_path: if not self.general.config_file_path:
raise ValueError("Configuration file path unknown.") raise ValueError("Configuration file path unknown.")
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out: 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(self.to_config_json())
f_out.write(json_str)
def update(self) -> None: def update(self) -> None:
"""Updates all configuration fields. """Updates all configuration fields.

View File

@@ -2,11 +2,25 @@
import json import json
import shutil import shutil
import types
from pathlib import Path 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 loguru import logger
from akkudoktoreos.core.pydantic import BaseModel
from akkudoktoreos.core.version import __version__ from akkudoktoreos.core.version import __version__
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -173,13 +187,27 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
return new_config 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: def migrate_config_file(config_file: Path, backup_file: Path) -> bool:
"""Migrate configuration file to the current version. """Migrate configuration file to the current version.
Returns: Returns:
bool: True if up-to-date or successfully migrated, False on failure. 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 # Reset globals at the start of each migration
migrated_source_paths = set() 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." 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 # Migrate config data
new_config = migrate_config_data(config_data) new_config = migrate_config_data(config_data)
# Write migrated configuration # Write migrated configuration
try: try:
with config_file.open("w", encoding="utf-8", newline=None) as f_out: 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) f_out.write(json_str)
except Exception as e_write: except Exception as e_write:
logger.error(f"Failed to write migrated configuration to '{config_file}': {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) skipped_paths.append(full_path)
continue continue
return count 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

View File

@@ -10,6 +10,7 @@ and manipulation of configuration and generic data in a clear, scalable, and str
import difflib import difflib
import json import json
import traceback
from abc import abstractmethod from abc import abstractmethod
from collections.abc import KeysView, MutableMapping from collections.abc import KeysView, MutableMapping
from itertools import chain from itertools import chain
@@ -1980,8 +1981,15 @@ class DataContainer(SingletonMixin, DataABC, MutableMapping):
for provider in self.providers: for provider in self.providers:
try: try:
provider.update_data(force_enable=force_enable, force_update=force_update) provider.update_data(force_enable=force_enable, force_update=force_update)
except Exception as ex: except Exception as e:
error = f"Provider {provider.provider_id()} fails on update - enabled={provider.enabled()}, force_enable={force_enable}, force_update={force_update}: {ex}" 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(): if provider.enabled():
# The active provider failed — this is a real error worth propagating. # The active provider failed — this is a real error worth propagating.
logger.error(error) logger.error(error)

View File

@@ -215,7 +215,7 @@ class EnergyManagement(
cls.adapter.update_data(force_enable) cls.adapter.update_data(force_enable)
except Exception as e: except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format()) 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) logger.error(error_msg)
cls._stage = EnergyManagementStage.FORECAST_RETRIEVAL cls._stage = EnergyManagementStage.FORECAST_RETRIEVAL
@@ -292,7 +292,7 @@ class EnergyManagement(
cls.adapter.update_data(force_enable) cls.adapter.update_data(force_enable)
except Exception as e: except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format()) 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) logger.error(error_msg)
# Remember energy run datetime. # Remember energy run datetime.

View File

@@ -218,17 +218,6 @@ class GeneticOptimizationParameters(
if cls.config.optimization.genetic.generations is None: if cls.config.optimization.genetic.generations is None:
logger.info("Genetic generations unknown - defaulting to 400.") logger.info("Genetic generations unknown - defaulting to 400.")
cls.config.optimization.genetic.generations = 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 # Get start solution from last run
start_solution = None start_solution = None

View File

@@ -2,6 +2,7 @@
from typing import Any, Optional from typing import Any, Optional
import numpy as np
import pandas as pd import pandas as pd
from loguru import logger from loguru import logger
from pydantic import Field, field_validator from pydantic import Field, field_validator
@@ -460,91 +461,67 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
) )
pred = get_prediction() pred = get_prediction()
if "pvforecast_ac_power" in pred.record_keys: for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in [
prediction["pvforecast_ac_energy_wh"] = ( (
pred.key_to_array( "pvforecast_ac_power",
key="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, start_datetime=start_datetime,
end_datetime=end_datetime, end_datetime=end_datetime,
interval=to_duration(f"{interval_hours} hours"), interval=to_duration(f"{interval_hours} hours"),
fill_method="linear", fill_method=pred_fill_method,
) )
* power_to_energy_per_interval_factor # 'key_to_array()' creates None values array if no data records are available.
).tolist() if array is not None and array.size > 0 and not np.any(pd.isna(array)):
if "pvforecast_dc_power" in pred.record_keys: prediction[pred_solution_key] = (array * pred_solution_factor).tolist()
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()
optimization_solution = OptimizationSolution( optimization_solution = OptimizationSolution(
id=f"optimization-genetic@{to_datetime(as_string=True)}", id=f"optimization-genetic@{to_datetime(as_string=True)}",

View File

@@ -41,8 +41,11 @@ class GeneticCommonSettings(SettingsBaseModel):
}, },
) )
penalties: Optional[dict[str, Union[float, int, str]]] = Field( penalties: dict[str, Union[float, int, str]] = Field(
default=None, default_factory=lambda: {
"ev_soc_miss": 10,
"ac_charge_break_even": 1.0,
},
json_schema_extra={ json_schema_extra={
"description": "A dictionary of penalty function parameters consisting of a penalty function parameter name and the associated value.", "description": "A dictionary of penalty function parameters consisting of a penalty function parameter name and the associated value.",
"examples": [ "examples": [

View File

@@ -300,7 +300,7 @@ def fastapi_admin_database_stats_get() -> dict:
except Exception as e: except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format()) trace = "".join(traceback.TracebackException.from_exception(e).format())
raise HTTPException( 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 return data
@@ -538,7 +538,7 @@ def fastapi_config_put_key(
trace = "".join(traceback.TracebackException.from_exception(e).format()) trace = "".join(traceback.TracebackException.from_exception(e).format())
raise HTTPException( raise HTTPException(
status_code=400, 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() return get_config()
@@ -937,7 +937,7 @@ async def fastapi_prediction_update(
trace = "".join(traceback.TracebackException.from_exception(e).format()) trace = "".join(traceback.TracebackException.from_exception(e).format())
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Error on prediction update: {e}\n{trace}", detail=f"Error on prediction update:\n{e}\n{trace}",
) )
return Response() return Response()
@@ -972,7 +972,7 @@ async def fastapi_prediction_update_provider(
trace = "".join(traceback.TracebackException.from_exception(e).format()) trace = "".join(traceback.TracebackException.from_exception(e).format())
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Error on prediction update: {e}\n{trace}", detail=f"Error on prediction update:\n{e}\n{trace}",
) )
return Response() return Response()

99
tests/test_configfile.py Normal file
View File

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

View File

@@ -22,6 +22,10 @@ MIGRATION_PAIRS = [
DIR_TESTDATA / "eos_config_andreas_0_1_0.json", DIR_TESTDATA / "eos_config_andreas_0_1_0.json",
DIR_TESTDATA / "eos_config_andreas_now.json", DIR_TESTDATA / "eos_config_andreas_now.json",
), ),
(
DIR_TESTDATA / "eos_config_unstripped.json",
DIR_TESTDATA / "eos_config_stripped.json",
),
# Add more pairs here: # Add more pairs here:
# (DIR_TESTDATA / "old_config_X.json", DIR_TESTDATA / "expected_config_X.json"), # (DIR_TESTDATA / "old_config_X.json", DIR_TESTDATA / "expected_config_X.json"),
] ]
@@ -124,16 +128,18 @@ class TestConfigMigration:
new_model = SettingsEOSDefaults(**migrated_data) new_model = SettingsEOSDefaults(**migrated_data)
assert isinstance(new_model, SettingsEOSDefaults) 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.""" """Test that a current config file returns True immediately."""
config_path = tmp_path / "EOS_current.json" backup_file = tmp_config_file.with_suffix(".bak")
default = SettingsEOSDefaults()
with config_path.open("w", encoding="utf-8") as f:
f.write(default.model_dump_json(indent=4))
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 result is True
assert not backup_file.exists(), "No backup should be made if config is already current." assert not backup_file.exists(), "No backup should be made if config is already current."

View File

@@ -1,98 +1,83 @@
{ {
"general": { "general": {
"data_folder_path": "__ANY__", "version": "__ANY__",
"data_output_subpath": "output", "data_output_subpath": "output",
"latitude": 52.5, "latitude": 52.5,
"longitude": 13.4 "longitude": 13.4
}, },
"cache": { "cache": {
"subpath": "cache", "subpath": "cache"
"cleanup_interval": 300.0 },
}, "logging": {
"ems": { "console_level": "INFO"
"startup_delay": 5.0, },
"interval": 300.0 "devices": {
}, "batteries": [
"logging": { {
"console_level": "INFO" "device_id": "pv_akku",
}, "capacity_wh": 30000
"devices": { }
"batteries": [ ],
{ "electric_vehicles": [
"device_id": "pv_akku", {
"capacity_wh": 30000, "charge_rates": [
"charging_efficiency": 0.88, 0.0,
"discharging_efficiency": 0.88, 0.375,
"max_charge_power_w": 5000, 0.5,
"min_soc_percentage": 0, 0.625,
"max_soc_percentage": 100 0.75,
} 0.875,
], 1.0
"electric_vehicles": [ ]
{ }
"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0] ]
} },
], "measurement": {
"inverters": [], "load_emr_keys": [
"home_appliances": [] "Household"
}, ]
"measurement": { },
"load_emr_keys": ["Household"] "optimization": {
}, "horizon_hours": 48
"optimization": { },
"horizon_hours": 48, "elecprice": {
"genetic": { "provider": "ElecPriceAkkudoktor",
"penalties": { "charges_kwh": 0.21
"ev_soc_miss": 10 },
} "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": {}
} }

View File

@@ -1,29 +1,20 @@
{ {
"elecprice": { "general": {
"charges_kwh": 0.21, "version": "__ANY__",
"provider": "ElecPriceImport" "data_output_subpath": "output",
}, "latitude": 52.5,
"prediction": { "longitude": 13.4
"historic_hours": 48, },
"hours": 48 "optimization": {
}, "horizon_hours": 48
"optimization": { },
"horizon_hours": 48, "elecprice": {
"algorithm": "GENETIC", "provider": "ElecPriceImport",
"genetic": { "charges_kwh": 0.21
"individuals": 300, },
"generations": 400 "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
}
}

134
tests/testdata/eos_config_stripped.json vendored Normal file
View File

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

View File

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

24
uv.lock generated
View File

@@ -84,10 +84,10 @@ requires-dist = [
{ name = "deap", specifier = "==1.4.3" }, { name = "deap", specifier = "==1.4.3" },
{ name = "deprecated", marker = "extra == 'dev'", specifier = "==1.3.1" }, { name = "deprecated", marker = "extra == 'dev'", specifier = "==1.3.1" },
{ name = "docutils", marker = "extra == 'dev'", specifier = "==0.21.2" }, { 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 = "fastapi-cli", specifier = "==0.0.24" },
{ name = "gitpython", marker = "extra == 'dev'", specifier = "==3.1.46" }, { 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 = "lmdb", specifier = "==1.7.5" },
{ name = "loguru", specifier = "==0.7.3" }, { name = "loguru", specifier = "==0.7.3" },
{ name = "markdown-it-py", specifier = "==4.0.0" }, { 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-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.0.0" },
{ name = "pytest-xprocess", marker = "extra == 'dev'", specifier = "==1.0.2" }, { 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 = "requests", specifier = "==2.32.5" },
{ name = "rich-toolkit", specifier = "==0.19.7" }, { name = "rich-toolkit", specifier = "==0.19.7" },
{ name = "scipy", specifier = "==1.17.1" }, { name = "scipy", specifier = "==1.17.1" },
@@ -828,7 +828,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.134.0" version = "0.135.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -837,9 +837,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { 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 = [ 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] [package.optional-dependencies]
@@ -1315,14 +1315,14 @@ wheels = [
[[package]] [[package]]
name = "linkify-it-py" name = "linkify-it-py"
version = "2.0.3" version = "2.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "uc-micro-py" }, { 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 = [ 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]] [[package]]
@@ -2449,7 +2449,7 @@ wheels = [
[[package]] [[package]]
name = "python-fasthtml" name = "python-fasthtml"
version = "0.12.47" version = "0.12.48"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
@@ -2463,9 +2463,9 @@ dependencies = [
{ name = "starlette" }, { name = "starlette" },
{ name = "uvicorn", extra = ["standard"] }, { 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 = [ 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]] [[package]]