diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d636636..801cf111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ compatibility changes and a short test procedure. These changes are not yet a ta and coverage information. A capacity estimate does not overwrite configured capacity. - Optional local, calibratable forecasting within `PVForecastAkkudoktor`; the remote backend remains the default. +- Optional `general.timezone_override`: an IANA timezone name used as `general.timezone` + instead of the lookup from latitude/longitude. Unset (default), the lookup is unchanged. - New PV forecast providers giving operators more cloud forecast sources to choose from in addition to Akkudoktor, VRM and Import: - `PVForecastPVNode` — native 15-minute forecasts from the pvnode.com API. @@ -60,6 +62,9 @@ compatibility changes and a short test procedure. These changes are not yet a ta values. Runtime forecast energy is Wh per slot and prices are currency per Wh. - Runtime configuration changes now take priority over environment and file values for explicitly updated keys; command-line settings retain higher priority. +- Lower idle memory: matplotlib (PDF reports), statsmodels (price fallback forecast), pvlib, + bs4/lxml, scipy (self-consumption table), the GENETIC0 optimizer and the timezone lookup + data are imported on first use instead of at server start. Results are unchanged. ### Fixed diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index 71aca761..22aa2c0d 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -210,7 +210,8 @@ "data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net", "data_output_subpath": "output", "latitude": 52.52, - "longitude": 13.405 + "longitude": 13.405, + "timezone_override": null }, "load": { "provider": "LoadAkkudoktor", diff --git a/docs/_generated/configgeneral.md b/docs/_generated/configgeneral.md index 73a55e96..f80d02c5 100644 --- a/docs/_generated/configgeneral.md +++ b/docs/_generated/configgeneral.md @@ -17,7 +17,8 @@ | home_assistant_addon | `EOS_GENERAL__HOME_ASSISTANT_ADDON` | `bool` | `rw` | `required` | EOS is running as home assistant add-on. | | 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. | +| timezone | | `Optional[str]` | `ro` | `N/A` | Timezone: 'timezone_override' if set, else looked up from latitude and longitude. | +| timezone_override | `EOS_GENERAL__TIMEZONE_OVERRIDE` | `Optional[str]` | `rw` | `None` | IANA timezone name, e.g. 'Europe/Berlin'. If set, 'timezone' is this value instead of the timezone looked up from latitude and longitude, and the lookup data (~25 MB) is never loaded. Defaults to None (look up). | | version | `EOS_GENERAL__VERSION` | `Optional[str]` | `rw` | `None` | Configuration file version. | ::: @@ -36,7 +37,8 @@ "data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net", "data_output_subpath": "output", "latitude": 52.52, - "longitude": 13.405 + "longitude": 13.405, + "timezone_override": null } } ``` @@ -57,6 +59,7 @@ "data_output_subpath": "output", "latitude": 52.52, "longitude": 13.405, + "timezone_override": null, "timezone": "Europe/Berlin", "data_output_path": "/home/user/.local/share/net.akkudoktoreos.net/output", "config_folder_path": "/home/user/.config/net.akkudoktoreos.net", diff --git a/openapi.json b/openapi.json index 661705d0..b5a1b6b7 100644 --- a/openapi.json +++ b/openapi.json @@ -6737,6 +6737,22 @@ "title": "Longitude", "description": "Longitude in decimal degrees within -180 to 180 (\u00b0)", "default": 13.405 + }, + "timezone_override": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timezone Override", + "description": "IANA timezone name, e.g. 'Europe/Berlin'. If set, 'timezone' is this value instead of the timezone looked up from latitude and longitude, and the lookup data (~25 MB) is never loaded. Defaults to None (look up).", + "examples": [ + null, + "Europe/Berlin" + ] } }, "type": "object", @@ -6829,6 +6845,22 @@ "description": "Longitude in decimal degrees within -180 to 180 (\u00b0)", "default": 13.405 }, + "timezone_override": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timezone Override", + "description": "IANA timezone name, e.g. 'Europe/Berlin'. If set, 'timezone' is this value instead of the timezone looked up from latitude and longitude, and the lookup data (~25 MB) is never loaded. Defaults to None (look up).", + "examples": [ + null, + "Europe/Berlin" + ] + }, "timezone": { "anyOf": [ { @@ -6839,7 +6871,7 @@ } ], "title": "Timezone", - "description": "Computed timezone based on latitude and longitude.", + "description": "Timezone: 'timezone_override' if set, else looked up from latitude and longitude.", "readOnly": true }, "data_output_path": { diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index 6aacdcb1..90d0fcad 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -17,6 +17,7 @@ from copy import deepcopy from pathlib import Path from typing import Any, Callable, ClassVar, Optional, Type, Union +import pendulum import pydantic_settings from loguru import logger from platformdirs import user_config_dir, user_data_dir @@ -53,7 +54,11 @@ from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.prediction.weather import WeatherCommonSettings from akkudoktoreos.server.rest.cli import cli_argument_parser from akkudoktoreos.server.server import ServerCommonSettings -from akkudoktoreos.utils.datetimeutil import to_datetime, to_timezone +from akkudoktoreos.utils.datetimeutil import ( + _canonicalize_tz_name, + to_datetime, + to_timezone, +) from akkudoktoreos.utils.utils import UtilsCommonSettings @@ -189,11 +194,37 @@ class GeneralSettings(SettingsBaseModel): json_schema_extra={"description": "Longitude in decimal degrees within -180 to 180 (°)"}, ) + timezone_override: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": ( + "IANA timezone name, e.g. 'Europe/Berlin'. If set, 'timezone' is this " + "value instead of the timezone looked up from latitude and longitude, " + "and the lookup data (~25 MB) is never loaded. Defaults to None (look up)." + ), + "examples": [None, "Europe/Berlin"], + }, + ) + + @field_validator("timezone_override", mode="after") + @classmethod + def validate_timezone_override(cls, value: Optional[str]) -> Optional[str]: + """Ensure the timezone override is a known IANA timezone name.""" + if value is None: + return None + try: + name = pendulum.timezone(value).name + except Exception as e: + raise ValueError(f"Unknown timezone '{value}'.") from e + return _canonicalize_tz_name(name) + # Computed fields @computed_field # type: ignore[prop-decorator] @property def timezone(self) -> Optional[str]: - """Computed timezone based on latitude and longitude.""" + """Timezone: 'timezone_override' if set, else looked up from latitude and longitude.""" + if self.timezone_override is not None: + return self.timezone_override if self.latitude and self.longitude: return to_timezone(location=(self.latitude, self.longitude), as_string=True) return None diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index b8e38888..2bff5e2f 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -17,7 +17,6 @@ from akkudoktoreos.core.coreabc import ( from akkudoktoreos.core.emplan import EnergyManagementPlan from akkudoktoreos.core.emsettings import EnergyManagementMode from akkudoktoreos.core.pydantic import PydanticBaseModel -from akkudoktoreos.optimization.genetic0.genetic0 import Genetic0Optimization from akkudoktoreos.optimization.genetic0.genetic0params import ( Genetic0OptimizationParameters, ) @@ -428,6 +427,12 @@ class EnergyManagement( # --- Optimization (CPU-bound → MUST offload) --- try: + # Imported on first use: a server that runs GENETIC never needs + # the GENETIC0 optimizer and its simulation. + from akkudoktoreos.optimization.genetic0.genetic0 import ( + Genetic0Optimization, + ) + genetic0_optimization = Genetic0Optimization( verbose=bool(self.config.server.verbose), fixed_seed=genetic0_seed, diff --git a/src/akkudoktoreos/optimization/genetic0/genetic0loadinterpolator.py b/src/akkudoktoreos/optimization/genetic0/genetic0loadinterpolator.py index 980822ee..a64572f5 100644 --- a/src/akkudoktoreos/optimization/genetic0/genetic0loadinterpolator.py +++ b/src/akkudoktoreos/optimization/genetic0/genetic0loadinterpolator.py @@ -1,9 +1,14 @@ #!/usr/bin/env python import pickle +import threading from pathlib import Path +from typing import TYPE_CHECKING, Optional import numpy as np -from scipy.interpolate import RegularGridInterpolator + +if TYPE_CHECKING: + # Unpickling the table imports scipy when it is first loaded. + from scipy.interpolate import RegularGridInterpolator from akkudoktoreos.core.cache import cache_energy_management from akkudoktoreos.core.coreabc import SingletonMixin @@ -92,9 +97,15 @@ class Genetic0LoadInterpolator(SelfConsumptionProbabilityInterpolator, Singleton super().__init__(filename) -# Initialize the Energy Management System, it is a singleton. -genetic0_load_interpolator = Genetic0LoadInterpolator() +# Created on first use: loading the table imports scipy. +_genetic0_load_interpolator: Optional[Genetic0LoadInterpolator] = None +_genetic0_load_interpolator_lock = threading.Lock() def get_genetic0_load_interpolator() -> Genetic0LoadInterpolator: - return genetic0_load_interpolator + global _genetic0_load_interpolator + if _genetic0_load_interpolator is None: + with _genetic0_load_interpolator_lock: + if _genetic0_load_interpolator is None: + _genetic0_load_interpolator = Genetic0LoadInterpolator() + return _genetic0_load_interpolator diff --git a/src/akkudoktoreos/prediction/interpolator.py b/src/akkudoktoreos/prediction/interpolator.py index 9a204c51..8abb03b5 100644 --- a/src/akkudoktoreos/prediction/interpolator.py +++ b/src/akkudoktoreos/prediction/interpolator.py @@ -1,9 +1,14 @@ #!/usr/bin/env python import pickle +import threading from pathlib import Path +from typing import TYPE_CHECKING, Optional import numpy as np -from scipy.interpolate import RegularGridInterpolator + +if TYPE_CHECKING: + # Unpickling the table imports scipy when it is first loaded. + from scipy.interpolate import RegularGridInterpolator from akkudoktoreos.core.cache import cache_energy_management from akkudoktoreos.core.coreabc import SingletonMixin @@ -170,9 +175,15 @@ class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin super().__init__(filename) -# Initialize the Energy Management System, it is a singleton. -eos_load_interpolator = EOSLoadInterpolator() +# Created on first use: loading the table imports scipy. +_eos_load_interpolator: Optional[EOSLoadInterpolator] = None +_eos_load_interpolator_lock = threading.Lock() def get_eos_load_interpolator() -> EOSLoadInterpolator: - return eos_load_interpolator + global _eos_load_interpolator + if _eos_load_interpolator is None: + with _eos_load_interpolator_lock: + if _eos_load_interpolator is None: + _eos_load_interpolator = EOSLoadInterpolator() + return _eos_load_interpolator diff --git a/src/akkudoktoreos/prediction/priceabc.py b/src/akkudoktoreos/prediction/priceabc.py index 2e30d810..2dd075c2 100644 --- a/src/akkudoktoreos/prediction/priceabc.py +++ b/src/akkudoktoreos/prediction/priceabc.py @@ -6,7 +6,6 @@ from typing import Generic, cast import numpy as np import pandas as pd from loguru import logger -from statsmodels.tsa.holtwinters import ExponentialSmoothing from akkudoktoreos.core.coreabc import PredictionMixin from akkudoktoreos.core.dataabc import DataRecordT @@ -114,6 +113,9 @@ class PricePredictionProviderBase( f"{seasonal_periods}: got {len(history)}, " f"need at least {required_observations}" ) + # statsmodels is only needed for this fallback forecast; import it on use. + from statsmodels.tsa.holtwinters import ExponentialSmoothing + clean_history = self._cap_outliers(history) model = ExponentialSmoothing( clean_history, seasonal="add", seasonal_periods=seasonal_periods diff --git a/src/akkudoktoreos/prediction/pvforecastakkudoktorlocal.py b/src/akkudoktoreos/prediction/pvforecastakkudoktorlocal.py index 3b2d03aa..91f7f944 100644 --- a/src/akkudoktoreos/prediction/pvforecastakkudoktorlocal.py +++ b/src/akkudoktoreos/prediction/pvforecastakkudoktorlocal.py @@ -38,7 +38,6 @@ from typing import Any, Literal, Optional, Self import numpy as np import pandas as pd import pendulum -import pvlib import requests from loguru import logger from pydantic import Field, field_validator, model_validator @@ -525,6 +524,8 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]): @staticmethod def _tracked_orientation(plane: Any, solpos: pd.DataFrame) -> tuple[Any, Any]: """Return (surface_tilt, surface_azimuth) honouring the plane's tracking type.""" + import pvlib + tilt = float(plane.surface_tilt if plane.surface_tilt is not None else 30.0) azimuth = float(plane.surface_azimuth if plane.surface_azimuth is not None else 180.0) tracking = plane.trackingtype @@ -575,6 +576,8 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]): airmass: pd.Series, ) -> tuple[pd.Series, pd.Series]: """Run the pvlib chain for one plane, returning (dc_power_w, ac_power_w).""" + import pvlib + settings = self._settings peakpower_kw = plane.peakpower @@ -673,6 +676,8 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]): fittable. Pass False to obtain the raw model output, which is what the fit itself compares against. """ + import pvlib + settings = self._settings weather = self._weather_frame(data) if weather.empty: diff --git a/src/akkudoktoreos/prediction/pvforecastpvlib.py b/src/akkudoktoreos/prediction/pvforecastpvlib.py index 5a731add..f83d60d9 100644 --- a/src/akkudoktoreos/prediction/pvforecastpvlib.py +++ b/src/akkudoktoreos/prediction/pvforecastpvlib.py @@ -8,11 +8,6 @@ from typing import ClassVar, Literal, Optional, Union import numpy as np import pandas as pd from loguru import logger -from pvlib.location import Location -from pvlib.modelchain import ModelChain -from pvlib.pvsystem import PVSystem, retrieve_sam -from pvlib.solarposition import get_solarposition -from pvlib.temperature import TEMPERATURE_MODEL_PARAMETERS from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.coreabc import PredictionMixin, get_config @@ -57,6 +52,8 @@ def _update_cec_database() -> None: Script taken from https://github.com/davidusb-geek/emhass/blob/master/scripts/save_pvlib_module_inverter_database.py """ + from pvlib.pvsystem import retrieve_sam + data_path = Path(__file__).parent.parent / "data" logger.info("Reading original outdated database bundled with PVLib") @@ -269,6 +266,13 @@ class PVForecastPVLib(PredictionMixin, PVForecastProvider): Note: Taken from emhass """ + # pvlib is imported on first use, so a server without this provider does + # not load it. + from pvlib.location import Location + from pvlib.modelchain import ModelChain + from pvlib.pvsystem import PVSystem + from pvlib.temperature import TEMPERATURE_MODEL_PARAMETERS + # Validate weather data required = [ "temp_air", @@ -418,6 +422,8 @@ class PVForecastPVLib(PredictionMixin, PVForecastProvider): :param longitude: Longitude of the PV system. :return: DataFrame with added solar elevation and azimuth. """ + from pvlib.solarposition import get_solarposition + df = df.copy() solpos = get_solarposition(df.index, latitude, longitude) df["solar_elevation"] = solpos["elevation"] diff --git a/src/akkudoktoreos/prediction/weatherabc.py b/src/akkudoktoreos/prediction/weatherabc.py index 5594d297..e6a8540f 100644 --- a/src/akkudoktoreos/prediction/weatherabc.py +++ b/src/akkudoktoreos/prediction/weatherabc.py @@ -11,7 +11,6 @@ from typing import List, Optional import numpy as np import pandas as pd -import pvlib from pydantic import Field from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord @@ -170,6 +169,8 @@ class WeatherProvider(PredictionProvider[WeatherDataRecord]): This method is based on the implementation from PVLib and is adapted from https://github.com/davidusb-geek/emhass/blob/master/src/emhass/forecast.py (MIT License). """ + import pvlib + # Adjust offset percentage to scaling factor offset_fraction = offset / 100.0 @@ -212,4 +213,6 @@ class WeatherProvider(PredictionProvider[WeatherDataRecord]): def estimate_preciptable_water( cls, temperature: pd.Series, relative_humidity: pd.Series ) -> pd.Series: + import pvlib + return pvlib.atmosphere.gueymard94_pw(temperature, relative_humidity) diff --git a/src/akkudoktoreos/prediction/weatherbrightsky.py b/src/akkudoktoreos/prediction/weatherbrightsky.py index 1524b18f..8cbe834e 100644 --- a/src/akkudoktoreos/prediction/weatherbrightsky.py +++ b/src/akkudoktoreos/prediction/weatherbrightsky.py @@ -11,7 +11,6 @@ from typing import Dict, List, Optional, Tuple, Union import numpy as np import pandas as pd -import pvlib import requests from loguru import logger @@ -166,6 +165,8 @@ class WeatherBrightSky(WeatherProvider): The final mapped and processed data is inserted into the sequence as `WeatherDataRecord`. """ + import pvlib + # Get BrightSky weather data for the given coordinates brightsky_data = self._request_forecast(force_update=force_update) # type: ignore diff --git a/src/akkudoktoreos/prediction/weatherclearoutside.py b/src/akkudoktoreos/prediction/weatherclearoutside.py index 5791a9fe..a71c34ba 100644 --- a/src/akkudoktoreos/prediction/weatherclearoutside.py +++ b/src/akkudoktoreos/prediction/weatherclearoutside.py @@ -17,7 +17,6 @@ from typing import Any, Dict, List, Optional, Tuple import pandas as pd import requests -from bs4 import BeautifulSoup from loguru import logger from akkudoktoreos.core.cache import cache_in_file @@ -140,7 +139,9 @@ class WeatherClearOutside(WeatherProvider): # Get ClearOutside web content - either from site or cached response = self._request_forecast(force_update=force_update) # type: ignore - # Scrape the data + # Scrape the data (bs4/lxml are only needed by this provider) + from bs4 import BeautifulSoup + soup = BeautifulSoup(response.content, "html.parser") # Find generation data diff --git a/src/akkudoktoreos/prediction/weatheropenmeteo.py b/src/akkudoktoreos/prediction/weatheropenmeteo.py index acafde66..341defc1 100644 --- a/src/akkudoktoreos/prediction/weatheropenmeteo.py +++ b/src/akkudoktoreos/prediction/weatheropenmeteo.py @@ -10,7 +10,6 @@ from typing import Dict, List, Optional, Tuple, Union import numpy as np import pandas as pd -import pvlib import requests from loguru import logger @@ -251,6 +250,8 @@ class WeatherOpenMeteo(WeatherProvider): are used directly without additional calculation. The final mapped and processed data is inserted into the sequence as `WeatherDataRecord`. """ + import pvlib + # Retrieve Open-Meteo weather data for the given coordinates openmeteo_data = self._request_forecast(force_update=force_update) # type: ignore diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 621e98dc..f64caf71 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -62,14 +62,8 @@ from akkudoktoreos.optimization.genetic0.genetic0solution import ( Genetic0Solution, Genetic0SolutionLegacy, ) -from akkudoktoreos.optimization.genetic0.genetic0visualize import ( - genetic0_prepare_visualize, -) from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution -from akkudoktoreos.optimization.genetic.geneticvisualize import ( - genetic_prepare_visualize, -) from akkudoktoreos.optimization.optimization import ( OptimizationAlgorithm, OptimizationSolution, @@ -1893,6 +1887,11 @@ async def fastapi_energy_management_optimization_solution_genetic_pdf_get() -> R detail="Can not get the 'GENETIC' optimization solution.", ) snapshot = retained.model_copy(deep=True) + # matplotlib is only needed for the PDF report; import it on first use. + from akkudoktoreos.optimization.genetic.geneticvisualize import ( + genetic_prepare_visualize, + ) + pdf = await asyncio.to_thread(genetic_prepare_visualize, solution=snapshot) return Response( content=pdf, @@ -2314,6 +2313,11 @@ def get_pdf() -> Response: detail="Can not get the 'GENETIC0' optimization solution.", ) + # matplotlib is only needed for the PDF report; import it on first use. + from akkudoktoreos.optimization.genetic0.genetic0visualize import ( + genetic0_prepare_visualize, + ) + pdf = genetic0_prepare_visualize(solution=genetic0_solution) return Response( diff --git a/src/akkudoktoreos/utils/datetimeutil.py b/src/akkudoktoreos/utils/datetimeutil.py index 8c743dd1..2e93956a 100644 --- a/src/akkudoktoreos/utils/datetimeutil.py +++ b/src/akkudoktoreos/utils/datetimeutil.py @@ -67,7 +67,6 @@ from pydantic import ( GetCoreSchemaHandler, ) from pydantic_core import core_schema -from tzfpy import get_tz if TYPE_CHECKING: # The Pydantic adapters validate Pendulum values; arithmetic and factory @@ -1414,6 +1413,10 @@ def to_timezone( lat, lon = location if not (-90 <= lat <= 90 and -180 <= lon <= 180): raise ValueError(f"Invalid latitude/longitude: {lat}, {lon}") + # tzfpy loads its timezone polygons (~25 MB) on first use; import it + # only when a location lookup is actually needed. + from tzfpy import get_tz + tz_name = get_tz(lon, lat) if not tz_name: raise ValueError( diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py new file mode 100644 index 00000000..bb5a8066 --- /dev/null +++ b/tests/test_lazy_imports.py @@ -0,0 +1,111 @@ +"""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") diff --git a/tests/test_optimization_pdf_api.py b/tests/test_optimization_pdf_api.py index de0562dd..236832b5 100644 --- a/tests/test_optimization_pdf_api.py +++ b/tests/test_optimization_pdf_api.py @@ -9,6 +9,7 @@ import pytest from fastapi.testclient import TestClient from pypdf import PdfReader +from akkudoktoreos.optimization.genetic import geneticvisualize from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution from akkudoktoreos.server import eos from akkudoktoreos.utils.datetimeutil import to_datetime @@ -70,7 +71,8 @@ async def test_render_is_offloaded_and_uses_an_owned_copy(config_eos, monkeypatc solution.ac_charge[0] = 0.123 return b"%PDF-isolated-render" - monkeypatch.setattr(eos, "genetic_prepare_visualize", render) + # The PDF stack is imported on first use, so patch it at its source. + monkeypatch.setattr(geneticvisualize, "genetic_prepare_visualize", render) response = await eos.fastapi_energy_management_optimization_solution_genetic_pdf_get() assert response.body == b"%PDF-isolated-render" assert captured[0][0] != caller_thread diff --git a/tests/test_optimize_run_result.py b/tests/test_optimize_run_result.py index 0e90a201..6695db06 100644 --- a/tests/test_optimize_run_result.py +++ b/tests/test_optimize_run_result.py @@ -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, diff --git a/tests/test_pvforecastpvlib.py b/tests/test_pvforecastpvlib.py index b680e4db..9e8107b0 100644 --- a/tests/test_pvforecastpvlib.py +++ b/tests/test_pvforecastpvlib.py @@ -1308,8 +1308,8 @@ class TestCalculation: assert isinstance(result, pd.DataFrame) assert result.empty - @patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain") - @patch("akkudoktoreos.prediction.pvforecastpvlib.Location") + @patch("pvlib.modelchain.ModelChain") + @patch("pvlib.location.Location") def test_location_created( self, mock_location, @@ -1352,7 +1352,7 @@ class TestCalculation: mock_location.assert_called_once() - @patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain") + @patch("pvlib.modelchain.ModelChain") def test_modelchain_run_called( self, mock_modelchain, @@ -1395,7 +1395,7 @@ class TestCalculation: mc.run_model.assert_called_once_with(weather) - @patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain") + @patch("pvlib.modelchain.ModelChain") def test_negative_power_clipped( self, mock_modelchain, @@ -1444,7 +1444,7 @@ class TestCalculation: assert (result["pv_dc_power"] >= 0).all() assert (result["ac_power"] >= 0).all() - @patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain") + @patch("pvlib.modelchain.ModelChain") def test_dataframe_columns( self, mock_modelchain, @@ -1489,7 +1489,7 @@ class TestCalculation: assert "pv_dc_power" in result.columns assert "ac_power" in result.columns - @patch("akkudoktoreos.prediction.pvforecastpvlib.ModelChain") + @patch("pvlib.modelchain.ModelChain") def test_result_index_preserved( self, mock_modelchain,