perf: import heavy optional dependencies on first use (#1377)

A GENETIC server with external (import) providers and EOSdash disabled
loads several large packages at start that it never uses, which costs
a noticeable part of the memory on 512 MB boards:

- matplotlib via the GENETIC/GENETIC0 PDF report modules (server/eos.py)
- statsmodels via the Holt-Winters fallback in prediction/priceabc.py
- pvlib via the weather base class and the pvlib-based providers
- bs4/lxml via the ClearOutside provider
- scipy via the self-consumption interpolator singletons, which were
  loaded at import time
- the GENETIC0 optimizer, imported by core/ems.py
- tzfpy, whose polygon data the computed general.timezone loads

Import these where they are used. The interpolator tables are loaded
on first use (thread-safe). Nothing changes in what is computed.

general.timezone is computed from latitude/longitude and cannot be set.
Add general.timezone_override (IANA name, validated with pendulum):
when set, general.timezone returns it and the lookup is skipped. When
unset (default) the lookup is unchanged.

Tests that patched the moved names now patch them at their source.
A new test starts the server module in a subprocess and checks that
none of these modules is loaded.

Co-authored-by: Christin <christin@BigBrother.bikini.bottom.zone>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Christin
2026-10-11 08:40:43 +02:00
committed by GitHub
co-authored by Christin Claude Opus 5.5
parent 6d26252c6a
commit 3651d33915
21 changed files with 295 additions and 47 deletions
+16 -6
View File
@@ -9,6 +9,7 @@ import pytest
from akkudoktoreos.core import ems as ems_module
from akkudoktoreos.core.emsettings import EnergyManagementMode
from akkudoktoreos.optimization.genetic0 import genetic0 as genetic0_module
from akkudoktoreos.optimization.genetic0.genetic0params import (
Genetic0OptimizationParameters,
)
@@ -16,6 +17,15 @@ from akkudoktoreos.optimization.optimization import OptimizationAlgorithm
from akkudoktoreos.utils.datetimeutil import to_datetime
def _patch_optimizer(monkeypatch, prefix: str, constructor: Any) -> None:
"""Replace the optimizer class that EnergyManagement.run() instantiates.
GENETIC0 is imported on first use inside run(), so it is patched at its source.
"""
module = genetic0_module if prefix == "Genetic0" else ems_module
monkeypatch.setattr(module, prefix + "Optimization", constructor)
@pytest.fixture
def offline_ems(monkeypatch):
"""Run the actual EMS orchestration with no real adapters or prediction IO."""
@@ -110,7 +120,7 @@ async def test_optimization_routes_only_selected_algorithm(
constructor = Mock()
constructor.return_value.optimize_ems.return_value = solution
constructors[prefix] = constructor
monkeypatch.setattr(ems_module, prefix + "Optimization", constructor)
_patch_optimizer(monkeypatch, prefix, constructor)
prepare = AsyncMock(return_value=sentinel_parameters)
preparers[prefix] = prepare
monkeypatch.setattr(
@@ -170,8 +180,8 @@ async def test_optimization_routes_only_selected_algorithm(
@pytest.mark.parametrize("mode", [EnergyManagementMode.DISABLED, EnergyManagementMode.PREDICTION])
async def test_non_optimization_modes_never_optimize(monkeypatch, offline_ems, mode):
constructors = [Mock(), Mock()]
monkeypatch.setattr(ems_module, "GeneticOptimization", constructors[0])
monkeypatch.setattr(ems_module, "Genetic0Optimization", constructors[1])
_patch_optimizer(monkeypatch, "Genetic", constructors[0])
_patch_optimizer(monkeypatch, "Genetic0", constructors[1])
offline_ems.config.ems.mode = mode
await ems_module.EnergyManagement.run(offline_ems)
for constructor in constructors:
@@ -187,7 +197,7 @@ async def test_non_optimization_modes_never_optimize(monkeypatch, offline_ems, m
@pytest.mark.parametrize("prefix", ["Genetic", "Genetic0"])
async def test_missing_preparation_does_not_dispatch_controls(monkeypatch, offline_ems, prefix):
constructor = Mock()
monkeypatch.setattr(ems_module, prefix + "Optimization", constructor)
_patch_optimizer(monkeypatch, prefix, constructor)
prepare = AsyncMock(return_value=None)
monkeypatch.setattr(getattr(ems_module, prefix + "OptimizationParameters"), "prepare", prepare)
await ems_module.EnergyManagement.run(
@@ -273,7 +283,7 @@ async def test_failed_legacy_http_run_never_reports_previous_solution(
native.optimization_solution.side_effect = error
else:
native.energy_management_plan.side_effect = error
monkeypatch.setattr(ems_module, "Genetic0Optimization", constructor)
_patch_optimizer(monkeypatch, "Genetic0", constructor)
offline_ems.run = MethodType(cls.run, offline_ems)
offline_ems.genetic0_solution = cls.genetic0_solution
monkeypatch.setattr(eos, "get_ems", lambda: offline_ems)
@@ -311,7 +321,7 @@ async def test_conversion_failure_preserves_consistent_previous_results(
solution.energy_management_plan.side_effect = RuntimeError("synthetic plan failure")
constructor = Mock()
constructor.return_value.optimize_ems.return_value = solution
monkeypatch.setattr(ems_module, prefix + "Optimization", constructor)
_patch_optimizer(monkeypatch, prefix, constructor)
supplied_parameters: dict[str, Any] = {suffix + "_parameters": object()}
result = await cls.run(
offline_ems,