mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-04-23 14:16:19 +00:00
fix: optimization fail after restart (#1007)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (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
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (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
Fix documentation for the loadforecast_power_w key. Fix documentation to explain the usage of import file/ JSON string to primarily initialise prediction data. Fix code scanning alert no. 6: URL redirection from remote source Enable to automatically save the configuration to the configuration file by default, which is a widespread user expectation. Make the genetic parameters non optional for better pydantic compliance. Update: - bump pytest to 9.0.3 - bump pillow to 12.2.0 - bump platformdirs to 4.9.6 - bump typespyyaml to 6.0.12.20260408 - bump tzfpy to 1.2.0 - bump pydantic to 2.13.0 - bump types-requests to 2.33.0.20260408 Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> Co-authored-by: Normann <github@koldrack.com>
This commit is contained in:
@@ -23,7 +23,11 @@ from pydantic import Field, computed_field, field_validator
|
||||
|
||||
# settings
|
||||
from akkudoktoreos.adapter.adapter import AdapterCommonSettings
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel, is_home_assistant_addon
|
||||
from akkudoktoreos.config.configabc import (
|
||||
ConfigSaveMode,
|
||||
SettingsBaseModel,
|
||||
is_home_assistant_addon,
|
||||
)
|
||||
from akkudoktoreos.config.configmigrate import migrate_config_data, migrate_config_file
|
||||
from akkudoktoreos.core.cachesettings import CacheCommonSettings
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
@@ -117,6 +121,26 @@ def default_data_folder_path() -> Path:
|
||||
class GeneralSettings(SettingsBaseModel):
|
||||
"""General settings."""
|
||||
|
||||
config_save_mode: ConfigSaveMode = Field(
|
||||
default=ConfigSaveMode.AUTOMATIC,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Configuration file save mode for configuration changes "
|
||||
"['MANUAL', 'AUTOMATIC']. Defaults to 'AUTOMATIC'."
|
||||
),
|
||||
},
|
||||
examples=["AUTOMATIC", "MANUAL"],
|
||||
)
|
||||
|
||||
config_save_interval_sec: int = Field(
|
||||
default=60,
|
||||
ge=5,
|
||||
json_schema_extra={
|
||||
"description": ("Automatic configuration file saving interval [seconds]."),
|
||||
"examples": [60],
|
||||
},
|
||||
)
|
||||
|
||||
home_assistant_addon: bool = Field(
|
||||
default_factory=is_home_assistant_addon,
|
||||
json_schema_extra={"description": "EOS is running as home assistant add-on."},
|
||||
@@ -359,6 +383,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
"with_file_secret_settings": True,
|
||||
}
|
||||
_config_file_path: ClassVar[Optional[Path]] = None
|
||||
_config_autosave: ClassVar[str] = ""
|
||||
_force_documentation_mode = False
|
||||
|
||||
def __hash__(self) -> int:
|
||||
@@ -1003,6 +1028,20 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
raise ValueError("Configuration file path unknown.")
|
||||
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out:
|
||||
f_out.write(self.to_config_json())
|
||||
logger.info(f"Saved configuration to '{self.general.config_file_path}'.")
|
||||
|
||||
def autosave(self) -> None:
|
||||
"""Saves the current configuration if AUTOMATIC save mode is configured.
|
||||
|
||||
Tries to avoid save operation if there are no changes between last and actual save.
|
||||
"""
|
||||
if self.general.config_save_mode != ConfigSaveMode.AUTOMATIC:
|
||||
return
|
||||
|
||||
config_str = self.to_config_json()
|
||||
if config_str != ConfigEOS._config_autosave:
|
||||
self.to_config_file()
|
||||
ConfigEOS._config_autosave = config_str
|
||||
|
||||
def update(self) -> None:
|
||||
"""Updates all configuration fields.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import calendar
|
||||
import os
|
||||
import sys
|
||||
from enum import StrEnum
|
||||
from typing import Any, ClassVar, Iterator, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
@@ -20,6 +21,13 @@ from akkudoktoreos.utils.datetimeutil import (
|
||||
)
|
||||
|
||||
|
||||
class ConfigSaveMode(StrEnum):
|
||||
"""Configuration file save mode."""
|
||||
|
||||
AUTOMATIC = "AUTOMATIC"
|
||||
MANUAL = "MANUAL"
|
||||
|
||||
|
||||
def is_home_assistant_addon() -> bool:
|
||||
"""Detect Home Assistant add-on environment.
|
||||
|
||||
|
||||
@@ -200,24 +200,15 @@ class GeneticOptimizationParameters(
|
||||
)
|
||||
cls.config.optimization.interval = 3600
|
||||
# Check genetic algorithm definitions
|
||||
if cls.config.optimization.genetic is None:
|
||||
logger.info(
|
||||
"Genetic optimization configuration not configured - defaulting to demo config."
|
||||
)
|
||||
cls.config.optimization.genetic = {
|
||||
"individuals": 300,
|
||||
"generations": 400,
|
||||
"seed": None,
|
||||
"penalties": {
|
||||
"ev_soc_miss": 10,
|
||||
},
|
||||
}
|
||||
if cls.config.optimization.genetic.individuals is None:
|
||||
logger.info("Genetic individuals unknown - defaulting to 300.")
|
||||
cls.config.optimization.genetic.individuals = 300
|
||||
if cls.config.optimization.genetic.generations is None:
|
||||
logger.info("Genetic generations unknown - defaulting to 400.")
|
||||
cls.config.optimization.genetic.generations = 400
|
||||
if "ev_soc_miss" not in cls.config.optimization.genetic.penalties:
|
||||
logger.info("Genetic penalties unknown - defaulting to ev_soc_miss = 10.")
|
||||
cls.config.optimization.genetic.penalties["ev_soc_miss"] = 10
|
||||
|
||||
# Get start solution from last run
|
||||
start_solution = None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import Field, computed_field, model_validator
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
@@ -18,7 +18,7 @@ class GeneticCommonSettings(SettingsBaseModel):
|
||||
default=300,
|
||||
ge=10,
|
||||
json_schema_extra={
|
||||
"description": "Number of individuals (solutions) to generate for the (initial) generation [>= 10]. Defaults to 300.",
|
||||
"description": "Number of individuals (solutions) in the population [>= 10]. Defaults to 300.",
|
||||
"examples": [300],
|
||||
},
|
||||
)
|
||||
@@ -27,7 +27,7 @@ class GeneticCommonSettings(SettingsBaseModel):
|
||||
default=400,
|
||||
ge=10,
|
||||
json_schema_extra={
|
||||
"description": "Number of generations to evaluate the optimal solution [>= 10]. Defaults to 400.",
|
||||
"description": "Number of generations to evolve [>= 10]. Defaults to 400.",
|
||||
"examples": [400],
|
||||
},
|
||||
)
|
||||
@@ -36,21 +36,21 @@ class GeneticCommonSettings(SettingsBaseModel):
|
||||
default=None,
|
||||
ge=0,
|
||||
json_schema_extra={
|
||||
"description": "Fixed seed for genetic algorithm. Defaults to 'None' which means random seed.",
|
||||
"examples": [None],
|
||||
"description": "Random seed for reproducibility. None = random.",
|
||||
"examples": [None, 42],
|
||||
},
|
||||
)
|
||||
|
||||
# --- Penalties (existing) -------------------------------------------------
|
||||
|
||||
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": [
|
||||
{"ev_soc_miss": 10},
|
||||
],
|
||||
"description": "Penalty parameters used in fitness evaluation.",
|
||||
"examples": [{"ev_soc_miss": 10}],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -58,7 +58,7 @@ class GeneticCommonSettings(SettingsBaseModel):
|
||||
class OptimizationCommonSettings(SettingsBaseModel):
|
||||
"""General Optimization Configuration."""
|
||||
|
||||
horizon_hours: Optional[int] = Field(
|
||||
horizon_hours: int = Field(
|
||||
default=24,
|
||||
ge=0,
|
||||
json_schema_extra={
|
||||
@@ -67,23 +67,26 @@ class OptimizationCommonSettings(SettingsBaseModel):
|
||||
},
|
||||
)
|
||||
|
||||
interval: Optional[int] = Field(
|
||||
interval: int = Field(
|
||||
default=3600,
|
||||
ge=15 * 60,
|
||||
le=60 * 60,
|
||||
json_schema_extra={
|
||||
"description": "The optimization interval [sec].",
|
||||
"description": "The optimization interval [sec]. Defaults to 3600 seconds (1 hour)",
|
||||
"examples": [60 * 60, 15 * 60],
|
||||
},
|
||||
)
|
||||
|
||||
algorithm: Optional[str] = Field(
|
||||
algorithm: str = Field(
|
||||
default="GENETIC",
|
||||
json_schema_extra={"description": "The optimization algorithm.", "examples": ["GENETIC"]},
|
||||
json_schema_extra={
|
||||
"description": "The optimization algorithm. Defaults to GENETIC",
|
||||
"examples": ["GENETIC"],
|
||||
},
|
||||
)
|
||||
|
||||
genetic: Optional[GeneticCommonSettings] = Field(
|
||||
default=None,
|
||||
genetic: GeneticCommonSettings = Field(
|
||||
default_factory=GeneticCommonSettings,
|
||||
json_schema_extra={
|
||||
"description": "Genetic optimization algorithm configuration.",
|
||||
"examples": [{"individuals": 400, "seed": None, "penalties": {"ev_soc_miss": 10}}],
|
||||
@@ -109,14 +112,14 @@ class OptimizationCommonSettings(SettingsBaseModel):
|
||||
key_list = df.columns.tolist()
|
||||
return sorted(set(key_list))
|
||||
|
||||
# Validators
|
||||
@model_validator(mode="after")
|
||||
def _enforce_algorithm_configuration(self) -> "OptimizationCommonSettings":
|
||||
"""Ensure algorithm default configuration is set."""
|
||||
if self.algorithm is not None:
|
||||
if self.algorithm.lower() == "genetic" and self.genetic is None:
|
||||
self.genetic = GeneticCommonSettings()
|
||||
return self
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def horizon(self) -> int:
|
||||
"""Number of optimization steps."""
|
||||
if self.interval is None or self.interval == 0 or self.horizon_hours is None:
|
||||
return 0
|
||||
num_steps = int(float(self.horizon_hours * 3600) / self.interval)
|
||||
return num_steps
|
||||
|
||||
|
||||
class OptimizationSolution(PydanticBaseModel):
|
||||
|
||||
@@ -30,7 +30,7 @@ class LoadImportCommonSettings(SettingsBaseModel):
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "JSON string, dictionary of load forecast value lists.",
|
||||
"examples": ['{"load0_mean": [676.71, 876.19, 527.13]}'],
|
||||
"examples": ['{"loadforecast_power_w": [676.71, 876.19, 527.13]}'],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -114,6 +114,13 @@ def compact_eos_database() -> None:
|
||||
get_measurement().db_vacuum()
|
||||
|
||||
|
||||
def autosave_config() -> None:
|
||||
"""Save config in automatic mode."""
|
||||
config = get_config()
|
||||
if config:
|
||||
config.autosave()
|
||||
|
||||
|
||||
async def server_shutdown_task() -> None:
|
||||
"""One-shot task for shutting down the EOS server.
|
||||
|
||||
@@ -168,6 +175,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
interval_attr="server/eosdash_supervise_interval_sec",
|
||||
fallback_interval=5.0,
|
||||
)
|
||||
manager.register(
|
||||
name="autosave_config",
|
||||
func=autosave_config,
|
||||
interval_attr="general/config_save_interval_sec",
|
||||
)
|
||||
manager.register("cache_clear", cache_clear, interval_attr="cache/cleanup_interval")
|
||||
manager.register(
|
||||
"save_eos_database", save_eos_database, interval_attr="database/autosave_interval_sec"
|
||||
@@ -1454,6 +1466,32 @@ async def redirect_put(request: Request, path: str) -> Response:
|
||||
return redirect(request, path)
|
||||
|
||||
|
||||
def _sanitize_redirect_path(path: str) -> Optional[str]:
|
||||
"""Sanitize user-controlled redirect path to ensure it is a safe relative path.
|
||||
|
||||
Returns a normalized path segment without scheme/host information, or None if unsafe.
|
||||
"""
|
||||
if path is None:
|
||||
return ""
|
||||
# Normalize backslashes and strip leading separators/spaces
|
||||
cleaned = path.replace("\\", "/").lstrip(" /")
|
||||
# Disallow obvious attempts to inject a new scheme/host
|
||||
lowered = cleaned.lower()
|
||||
if lowered.startswith(("http://", "https://", "//")) or "://" in lowered:
|
||||
return None
|
||||
# Prevent directory traversal outside the intended root
|
||||
parts = [p for p in cleaned.split("/") if p not in ("", ".")]
|
||||
depth = 0
|
||||
for p in parts:
|
||||
if p == "..":
|
||||
depth -= 1
|
||||
else:
|
||||
depth += 1
|
||||
if depth < 0:
|
||||
return None
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectResponse]:
|
||||
# Path is not for EOSdash
|
||||
if not (path.startswith("eosdash") or path == ""):
|
||||
@@ -1485,8 +1523,9 @@ Did you want to connect to <a href="{url}" class="back-button">EOSdash</a>?
|
||||
# Use IP of EOS host
|
||||
host = get_host_ip()
|
||||
if host and get_config().server.eosdash_port:
|
||||
# Redirect to EOSdash server
|
||||
url = f"http://{host}:{get_config().server.eosdash_port}/{path}"
|
||||
base_url = f"http://{host}:{get_config().server.eosdash_port}"
|
||||
safe_path = _sanitize_redirect_path(path) or ""
|
||||
url = f"{base_url}/{safe_path}"
|
||||
return RedirectResponse(url=url, status_code=303)
|
||||
|
||||
# Redirect the root URL to the site map
|
||||
|
||||
Reference in New Issue
Block a user