Files
EOS/tests/test_lazy_imports.py
T
3651d33915 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>
2026-10-11 08:40:43 +02:00

112 lines
3.6 KiB
Python

"""Heavy optional dependencies must not be imported by a plain server start.
A GENETIC server with external (import) providers and EOSdash disabled should not
load the PDF report stack, the price fallback forecast, pvlib-based providers,
the HTML scraper, the GENETIC0 optimizer or the timezone polygon lookup. Each of
these costs several to tens of MB of resident memory on small devices.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
from akkudoktoreos.config.config import GeneralSettings
LAZY_MODULES = [
"matplotlib",
"statsmodels",
"pvlib",
"bs4",
"lxml",
"scipy",
"tzfpy",
"akkudoktoreos.optimization.genetic0.genetic0",
"akkudoktoreos.optimization.genetic0.genetic0visualize",
"akkudoktoreos.optimization.genetic.geneticvisualize",
]
PROBE = """
import json
import sys
import akkudoktoreos.server.eos # noqa: F401
from akkudoktoreos.core.coreabc import get_config, get_prediction, singletons_init
config = get_config(init=True)
singletons_init()
get_prediction()
assert config.general.timezone == "Europe/Berlin", config.general.timezone
loaded = sorted(name for name in {modules} if name in sys.modules)
print("LOADED_MODULES=" + json.dumps(loaded))
"""
def _loaded_modules(tmp_path: Path, extra_env: dict[str, str]) -> list[str]:
env = {key: value for key, value in os.environ.items() if not key.startswith("EOS_")}
env.update(
{
"EOS_DIR": str(tmp_path),
"EOS_CONFIG_DIR": str(tmp_path),
"EOS_GENERAL__DATA_FOLDER_PATH": str(tmp_path / "data"),
"EOS_SERVER__STARTUP_EOSDASH": "false",
"EOS_OPTIMIZATION__ALGORITHM": "GENETIC",
"EOS_LOGGING__CONSOLE_LEVEL": "ERROR",
}
)
env.update(extra_env)
result = subprocess.run( # noqa: S603
[sys.executable, "-c", PROBE.format(modules=LAZY_MODULES)],
cwd=tmp_path,
env=env,
capture_output=True,
text=True,
timeout=120,
check=False,
)
assert result.returncode == 0, result.stderr[-4000:]
marker = "LOADED_MODULES="
lines = [line for line in result.stdout.splitlines() if line.startswith(marker)]
assert len(lines) == 1, result.stdout[-4000:]
return json.loads(lines[0][len(marker) :])
def test_server_start_does_not_import_heavy_optional_modules(tmp_path: Path):
loaded = _loaded_modules(tmp_path, {"EOS_GENERAL__TIMEZONE_OVERRIDE": "Europe/Berlin"})
assert loaded == []
def test_timezone_lookup_still_used_without_override(tmp_path: Path):
# Default latitude/longitude are in Berlin, so the lookup gives the same name.
loaded = _loaded_modules(tmp_path, {})
assert loaded == ["tzfpy"]
def test_timezone_override_semantics():
general = GeneralSettings()
assert general.timezone_override is None
assert general.timezone == "Europe/Berlin"
general = GeneralSettings(latitude=40.7128, longitude=-74.0060)
assert general.timezone == "America/New_York"
general = GeneralSettings(
latitude=40.7128, longitude=-74.0060, timezone_override="Europe/Berlin"
)
assert general.timezone == "Europe/Berlin"
# Without coordinates the override still applies.
general = GeneralSettings(latitude=None, longitude=None, timezone_override="Asia/Tokyo")
assert general.timezone == "Asia/Tokyo"
assert GeneralSettings(latitude=None, longitude=None).timezone is None
assert GeneralSettings(timezone_override="Etc/UTC").timezone == "UTC"
def test_timezone_override_rejects_unknown_names():
with pytest.raises(ValueError):
GeneralSettings(timezone_override="Mars/Olympus_Mons")