diff --git a/.gitignore b/.gitignore index e43956d..8a3fd96 100644 --- a/.gitignore +++ b/.gitignore @@ -222,4 +222,5 @@ unit_test_coverage/ test-results.xml .env.* -!.env.template* \ No newline at end of file +.env.template* +.claude/ diff --git a/dev-requirements.txt b/dev-requirements.txt index 36672d2..f6c5ac0 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,45 +2,49 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --constraint='..\..\requirements.txt' --extra=dev --output-file='..\..\dev-requirements.txt' '..\..\pyproject.toml' +# pip-compile --constraint=requirements.txt --extra=dev --output-file=dev-requirements.txt pyproject.toml # aio-pika==9.4.3 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-sdk-python aiormq==6.8.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # aio-pika amqp==5.3.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # kombu +annotated-types==0.7.0 + # via + # -c requirements.txt + # pydantic attrs==25.4.0 # via flake8-bugbear -billiard==4.2.2 +billiard==4.2.4 # via - # -c ..\..\requirements.txt + # -c requirements.txt # celery black==24.10.0 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) build==1.2.2.post1 - # via simulator-worker (..\..\pyproject.toml) -celery==5.5.3 + # via simulator-worker (pyproject.toml) +celery==5.6.2 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-sdk-python -certifi==2025.10.5 +certifi==2026.2.25 # via - # -c ..\..\requirements.txt + # -c requirements.txt # requests -charset-normalizer==3.4.3 +charset-normalizer==3.4.6 # via - # -c ..\..\requirements.txt + # -c requirements.txt # requests -click==8.3.0 +click==8.3.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # black # celery # click-didyoumean @@ -48,32 +52,40 @@ click==8.3.0 # click-repl click-didyoumean==0.3.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # celery click-plugins==1.1.1.2 # via - # -c ..\..\requirements.txt + # -c requirements.txt # celery click-repl==0.3.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # celery colorama==0.4.6 # via - # -c ..\..\requirements.txt + # -c requirements.txt # build # click # pytest +coloredlogs==15.0.1 + # via + # -c requirements.txt + # kpi-calculator coolprop==6.6.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-simulator-core -coverage[toml]==7.10.7 +coverage[toml]==7.13.5 # via pytest-cov dataclass-wizard==0.22.3 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-simulator-core +filelock==3.25.2 + # via + # -c requirements.txt + # kpi-calculator flake8==7.1.1 # via # flake8-bugbear @@ -81,90 +93,100 @@ flake8==7.1.1 # flake8-pyproject # flake8-quotes # flake8-tuple - # simulator-worker (..\..\pyproject.toml) + # simulator-worker (pyproject.toml) flake8-bugbear==24.10.31 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) flake8-docstrings==1.7.0 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) flake8-mock==0.4 - # via simulator-worker (..\..\pyproject.toml) -flake8-pyproject==1.2.3 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) +flake8-pyproject==1.2.4 + # via simulator-worker (pyproject.toml) flake8-quotes==3.4.0 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) flake8-tuple==0.4.1 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) future-fstrings==1.2.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # pyecore -idna==3.10 +humanfriendly==10.0 + # via + # -c requirements.txt + # coloredlogs +idna==3.11 # via - # -c ..\..\requirements.txt + # -c requirements.txt # requests # yarl influxdb==5.3.2 # via - # -c ..\..\requirements.txt + # -c requirements.txt + # kpi-calculator # omotes-simulator-core -iniconfig==2.1.0 +iniconfig==2.3.0 # via pytest isort==5.13.2 - # via simulator-worker (..\..\pyproject.toml) -kombu==5.5.4 + # via simulator-worker (pyproject.toml) +kombu==5.6.2 # via - # -c ..\..\requirements.txt + # -c requirements.txt # celery +kpi-calculator==0.3.0 + # via + # -c requirements.txt + # simulator-worker (pyproject.toml) lxml==6.0.2 # via - # -c ..\..\requirements.txt + # -c requirements.txt # pyecore mccabe==0.7.0 # via flake8 msgpack==1.1.2 # via - # -c ..\..\requirements.txt + # -c requirements.txt # influxdb -multidict==6.7.0 +multidict==6.7.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # yarl mypy==1.13.0 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) mypy-extensions==1.1.0 # via # black # mypy networkx==2.7.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-simulator-core numpy==2.1.3 # via - # -c ..\..\requirements.txt + # -c requirements.txt + # kpi-calculator # omotes-simulator-core # pandas # pandas-stubs # scipy omotes-sdk-protocol==1.2.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-sdk-python omotes-sdk-python==4.3.2 # via - # -c ..\..\requirements.txt - # simulator-worker (..\..\pyproject.toml) + # -c requirements.txt + # simulator-worker (pyproject.toml) omotes-simulator-core==0.0.28 # via - # -c ..\..\requirements.txt - # simulator-worker (..\..\pyproject.toml) + # -c requirements.txt + # simulator-worker (pyproject.toml) ordered-set==4.1.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # pyecore -packaging==25.0 +packaging==26.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # black # build # kombu @@ -172,134 +194,170 @@ packaging==25.0 # setuptools-git-versioning pamqp==3.3.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # aiormq # omotes-sdk-python pandas==2.2.3 # via - # -c ..\..\requirements.txt + # -c requirements.txt + # kpi-calculator # omotes-simulator-core - # simulator-worker (..\..\pyproject.toml) -pandas-stubs==2.1.4.231227 - # via simulator-worker (..\..\pyproject.toml) -pathspec==0.12.1 + # simulator-worker (pyproject.toml) +pandas-stubs==3.0.0.260204 + # via + # -c requirements.txt + # kpi-calculator + # simulator-worker (pyproject.toml) +pathspec==1.0.4 # via black -platformdirs==4.5.0 +platformdirs==4.9.4 # via black pluggy==1.6.0 # via pytest prompt-toolkit==3.0.52 # via - # -c ..\..\requirements.txt + # -c requirements.txt # click-repl propcache==0.4.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # yarl -protobuf==5.29.5 +protobuf==5.29.6 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-sdk-protocol pycodestyle==2.12.1 # via flake8 +pydantic==2.12.5 + # via + # -c requirements.txt + # kpi-calculator +pydantic-core==2.41.5 + # via + # -c requirements.txt + # pydantic pydocstyle==6.3.0 # via flake8-docstrings pyecore==0.13.2 # via - # -c ..\..\requirements.txt + # -c requirements.txt # pyesdl pyesdl==25.7 # via - # -c ..\..\requirements.txt + # -c requirements.txt + # kpi-calculator # omotes-sdk-python # omotes-simulator-core - # simulator-worker (..\..\pyproject.toml) + # simulator-worker (pyproject.toml) pyflakes==3.2.0 # via flake8 pyjnius==1.6.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-simulator-core pyproject-hooks==1.2.0 # via build +pyreadline3==3.5.4 + # via + # -c requirements.txt + # humanfriendly pytest==8.3.5 # via # pytest-cov - # simulator-worker (..\..\pyproject.toml) + # simulator-worker (pyproject.toml) pytest-cov==6.0.0 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) python-dateutil==2.9.0.post0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # celery # influxdb # pandas python-dotenv==1.0.1 # via - # -c ..\..\requirements.txt - # simulator-worker (..\..\pyproject.toml) -pytz==2025.2 + # -c requirements.txt + # simulator-worker (pyproject.toml) +pytz==2026.1.post1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # influxdb # pandas requests==2.32.5 # via - # -c ..\..\requirements.txt + # -c requirements.txt # influxdb -restrictedpython==8.0 +restrictedpython==8.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # pyecore scipy==1.14.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-simulator-core setuptools-git-versioning==2.1.0 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) six==1.17.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # flake8-tuple # influxdb # python-dateutil snowballstemmer==3.0.1 # via pydocstyle -streamcapture==1.2.5 +streamcapture==1.2.7 # via - # -c ..\..\requirements.txt + # -c requirements.txt # omotes-sdk-python -types-pytz==2025.2.0.20250809 - # via pandas-stubs +types-xmltodict==1.0.1.20260113 + # via + # -c requirements.txt + # kpi-calculator typing-extensions==4.15.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # mypy # omotes-sdk-python -tzdata==2025.2 + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.2 + # via + # -c requirements.txt + # pydantic +tzdata==2025.3 # via - # -c ..\..\requirements.txt + # -c requirements.txt # kombu # pandas -urllib3==2.5.0 + # tzlocal +tzlocal==5.3.1 # via - # -c ..\..\requirements.txt + # -c requirements.txt + # celery +urllib3==2.6.3 + # via + # -c requirements.txt + # kpi-calculator # requests vine==5.1.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # amqp # celery # kombu -wcwidth==0.2.14 +wcwidth==0.6.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # prompt-toolkit wheel==0.45.1 - # via simulator-worker (..\..\pyproject.toml) -yarl==1.22.0 + # via simulator-worker (pyproject.toml) +xmltodict==0.14.2 + # via + # -c requirements.txt + # kpi-calculator +yarl==1.23.0 # via - # -c ..\..\requirements.txt + # -c requirements.txt # aio-pika # aiormq diff --git a/pyproject.toml b/pyproject.toml index 20e3cd4..74ec7fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,8 @@ dependencies = [ "omotes-sdk-python ~= 4.3.2", "omotes-simulator-core==0.0.28", "pyesdl==25.7", - "pandas ~= 2.2.2" + "pandas ~= 2.2.2", + "kpi-calculator==0.3.0", ] [project.optional-dependencies] @@ -45,7 +46,10 @@ dev = [ "mypy ~= 1.13.0", "isort == 5.13.2", "build ~= 1.2.2", - "pandas-stubs ~= 2.1.1" + # TODO: pandas-stubs is temporarily pinned to 3.0.0 (via kpi-calculator) for integration testing. + # Revert to ~= 2.2.0 once kpi-calculator moves pandas-stubs and types-xmltodict + # from runtime dependencies to the dev dependency group. + "pandas-stubs ~= 3.0.0" ] [project.urls] @@ -72,6 +76,7 @@ starting_version = "0.0.1" [tool.pytest.ini_options] addopts = "--cov=simulator_worker --cov-report html --cov-report term-missing --cov-fail-under 20" testpaths = ["unit_test"] +python_files = ["test_*.py"] [tool.coverage.run] source = ["src"] @@ -114,3 +119,7 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = "esdl.*" ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "kpicalculator.*" +ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt index 2f0d178..e651b6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --output-file='..\..\requirements.txt' '..\..\pyproject.toml' +# pip-compile --output-file=requirements.txt pyproject.toml # aio-pika==9.4.3 # via omotes-sdk-python @@ -10,15 +10,17 @@ aiormq==6.8.1 # via aio-pika amqp==5.3.1 # via kombu -billiard==4.2.2 +annotated-types==0.7.0 + # via pydantic +billiard==4.2.4 # via celery -celery==5.5.3 +celery==5.6.2 # via omotes-sdk-python -certifi==2025.10.5 +certifi==2026.2.25 # via requests -charset-normalizer==3.4.3 +charset-normalizer==3.4.6 # via requests -click==8.3.0 +click==8.3.1 # via # celery # click-didyoumean @@ -32,42 +34,54 @@ click-repl==0.3.0 # via celery colorama==0.4.6 # via click +coloredlogs==15.0.1 + # via kpi-calculator coolprop==6.6.0 # via omotes-simulator-core dataclass-wizard==0.22.3 # via omotes-simulator-core +filelock==3.25.2 + # via kpi-calculator future-fstrings==1.2.0 # via pyecore -idna==3.10 +humanfriendly==10.0 + # via coloredlogs +idna==3.11 # via # requests # yarl influxdb==5.3.2 - # via omotes-simulator-core -kombu==5.5.4 + # via + # kpi-calculator + # omotes-simulator-core +kombu==5.6.2 # via celery +kpi-calculator==0.3.0 + # via simulator-worker (pyproject.toml) lxml==6.0.2 # via pyecore msgpack==1.1.2 # via influxdb -multidict==6.7.0 +multidict==6.7.1 # via yarl networkx==2.7.1 # via omotes-simulator-core numpy==2.1.3 # via + # kpi-calculator # omotes-simulator-core # pandas + # pandas-stubs # scipy omotes-sdk-protocol==1.2.0 # via omotes-sdk-python omotes-sdk-python==4.3.2 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) omotes-simulator-core==0.0.28 - # via simulator-worker (..\..\pyproject.toml) + # via simulator-worker (pyproject.toml) ordered-set==4.1.0 # via pyecore -packaging==25.0 +packaging==26.0 # via kombu pamqp==3.3.0 # via @@ -75,37 +89,47 @@ pamqp==3.3.0 # omotes-sdk-python pandas==2.2.3 # via + # kpi-calculator # omotes-simulator-core - # simulator-worker (..\..\pyproject.toml) + # simulator-worker (pyproject.toml) +pandas-stubs==3.0.0.260204 + # via kpi-calculator prompt-toolkit==3.0.52 # via click-repl propcache==0.4.1 # via yarl -protobuf==5.29.5 +protobuf==5.29.6 # via omotes-sdk-protocol +pydantic==2.12.5 + # via kpi-calculator +pydantic-core==2.41.5 + # via pydantic pyecore==0.13.2 # via pyesdl pyesdl==25.7 # via + # kpi-calculator # omotes-sdk-python # omotes-simulator-core - # simulator-worker (..\..\pyproject.toml) + # simulator-worker (pyproject.toml) pyjnius==1.6.1 # via omotes-simulator-core +pyreadline3==3.5.4 + # via humanfriendly python-dateutil==2.9.0.post0 # via # celery # influxdb # pandas python-dotenv==1.0.1 - # via simulator-worker (..\..\pyproject.toml) -pytz==2025.2 + # via simulator-worker (pyproject.toml) +pytz==2026.1.post1 # via # influxdb # pandas requests==2.32.5 # via influxdb -restrictedpython==8.0 +restrictedpython==8.1 # via pyecore scipy==1.14.1 # via omotes-simulator-core @@ -113,24 +137,39 @@ six==1.17.0 # via # influxdb # python-dateutil -streamcapture==1.2.5 +streamcapture==1.2.7 # via omotes-sdk-python +types-xmltodict==1.0.1.20260113 + # via kpi-calculator typing-extensions==4.15.0 - # via omotes-sdk-python -tzdata==2025.2 + # via + # omotes-sdk-python + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.2 + # via pydantic +tzdata==2025.3 # via # kombu # pandas -urllib3==2.5.0 - # via requests + # tzlocal +tzlocal==5.3.1 + # via celery +urllib3==2.6.3 + # via + # kpi-calculator + # requests vine==5.1.0 # via # amqp # celery # kombu -wcwidth==0.2.14 +wcwidth==0.6.0 # via prompt-toolkit -yarl==1.22.0 +xmltodict==0.14.2 + # via kpi-calculator +yarl==1.23.0 # via # aio-pika # aiormq diff --git a/src/simulator_worker/simulator_worker.py b/src/simulator_worker/simulator_worker.py index 502d314..2388bd2 100644 --- a/src/simulator_worker/simulator_worker.py +++ b/src/simulator_worker/simulator_worker.py @@ -111,8 +111,67 @@ def simulator_worker_task( len(result_indexed.columns), result_indexed.shape, ) + + # ===== Create output ESDL with simulation results ===== output_esdl = create_output_esdl(input_esdl, result_indexed) + # ===== KPI Calculation ===== + logger.info("Calculating KPIs from simulation results...") + + try: + from kpicalculator import KpiManager + from kpicalculator.common.constants import DEFAULT_SYSTEM_LIFETIME_YEARS + + # Get system lifetime from workflow config + system_lifetime_value = workflow_config.get( + "system_lifetime", DEFAULT_SYSTEM_LIFETIME_YEARS + ) + if isinstance(system_lifetime_value, (int, float, str)): + system_lifetime = float(system_lifetime_value) + else: + system_lifetime = DEFAULT_SYSTEM_LIFETIME_YEARS + + # Load simulator results and ESDL cost data in one step + kpi_manager = KpiManager() + kpi_manager.load_from_simulator(result_indexed, input_esdl) + + # Calculate KPIs + kpi_results = kpi_manager.calculate_all_kpis(system_lifetime=system_lifetime) + logger.info("KPI calculation completed successfully") + capex_value = kpi_results.get("costs", {}).get("capex", {}).get("All", 0) + logger.debug(f"KPI results: CAPEX={capex_value:.2f} EUR") + + # Add KPIs to output ESDL and serialize to string. + # + # TODO: replace this workaround once kpi-calculator provides a + # build_esdl_string_with_kpis(esdl_string, results, level) method + # (tracked in kpi-calculator roadmap). + # + # Workaround: get_esdl_with_kpis() applies KPIs to the esdl_energy_system + # object that was parsed from input_esdl, but we need them in output_esdl + # (which carries the simulation-result profiles). We redirect + # esdl_energy_system to the output_esdl parse tree so the exporter + # modifies it in-place, then serialize via the same pyecore resource. + esh_with_kpis = pyesdl_from_string(output_esdl) + kpi_manager.energy_system.esdl_energy_system = ( # type: ignore[union-attr] + esh_with_kpis.energy_system + ) + kpi_manager.get_esdl_with_kpis(kpi_results, level="system") + output_esdl = esh_with_kpis.to_string() + logger.info("KPIs added to output ESDL successfully") + + except Exception as e: + logger.error( + ( + f"KPI calculation failed: {e}. " + f"Simulation will continue and return results without KPIs. " + f"Common causes: missing cost data in ESDL, invalid time series data, " + f"or kpi-calculator dependency issues. Check logs for details." + ) + ) + logger.debug(f"Stack trace: {traceback.format_exc()}") + # Continue without KPIs - don't fail the entire workflow + # Write output_esdl to file for debugging # with open(f"result_{simulation_id}.esdl", "w") as file: # file.writelines(output_esdl) diff --git a/src/simulator_worker/utils.py b/src/simulator_worker/utils.py index 0a865b9..4bb1426 100644 --- a/src/simulator_worker/utils.py +++ b/src/simulator_worker/utils.py @@ -14,13 +14,13 @@ # along with this program. If not, see . """utility functions for simulator-worker.""" import logging -import omotes_simulator_core import os import uuid from datetime import datetime -from typing import Dict, List, Tuple, Type, TypeVar, cast +from typing import Dict, List, Optional, Tuple, Type, TypeVar, cast import esdl +import omotes_simulator_core import pandas as pd from esdl.profiles.influxdbprofilemanager import ( ConnectionSettings, @@ -85,11 +85,11 @@ def add_datetime_index( return df -def get_profileQuantityAndUnit(property_name: str) -> esdl.esdl.QuantityAndUnitType: +def get_profileQuantityAndUnit(property_name: str) -> Optional[esdl.esdl.QuantityAndUnitType]: """Get the profile quantity and unit. :param property_name: The name of the property to get the quantity and unit for. - :return: The quantity and unit for the given property name. + :return: The quantity and unit for the given property name, or None if unknown. """ if property_name.startswith("mass_flow"): return esdl.esdl.QuantityAndUnitType( @@ -183,6 +183,7 @@ def get_profileQuantityAndUnit(property_name: str) -> esdl.esdl.QuantityAndUnitT ) else: logger.info(f"Unknown property name: {property_name}") + return None def create_output_esdl(input_esdl: str, simulation_result: pd.DataFrame) -> str: @@ -242,19 +243,21 @@ def create_output_esdl(input_esdl: str, simulation_result: pd.DataFrame) -> str: series_for_asset_id_for_carrier = series_per_asset_id_for_carrier.setdefault(asset_id, []) series_for_asset_id_for_carrier.append((series_name, port)) - datasource = esdl.esdl.DataSource(name="Omotes simulator core run", - id=str(uuid.uuid4()), - description="This profile is a simulation results obtained " - "with the Omotes simulator core", - reference="https://simulator-core.readthedocs.io/en/latest/", - releaseDate=datetime.now(), - version=omotes_simulator_core.__version__, - license="GNU GENERAL PUBLIC LICENSE", - author="Deltares/TNO", - contactDetails="https://github.com/Project-OMOTES") - esh.energy_system.energySystemInformation.dataSources = esdl.DataSources(id=str(uuid.uuid4()), - dataSource=[ - datasource]) + datasource = esdl.esdl.DataSource( + name="Omotes simulator core run", + id=str(uuid.uuid4()), + description="This profile is a simulation results obtained " + "with the Omotes simulator core", + reference="https://simulator-core.readthedocs.io/en/latest/", + releaseDate=datetime.now(), + version=omotes_simulator_core.__version__, + license="GNU GENERAL PUBLIC LICENSE", + author="Deltares/TNO", + contactDetails="https://github.com/Project-OMOTES", + ) + esh.energy_system.energySystemInformation.dataSources = esdl.DataSources( + id=str(uuid.uuid4()), dataSource=[datasource] + ) capabilities = [esdl.Transport, esdl.Conversion, esdl.Consumer, esdl.Producer] for carrier_id in series_per_asset_id_per_carrier_id: @@ -284,10 +287,11 @@ def create_output_esdl(input_esdl: str, simulation_result: pd.DataFrame) -> str: id=str(uuid.uuid4()), filters=f"\"assetId\"='{asset_id}'", profileType=esdl.ProfileTypeEnum.OUTPUT, - dataSource=reference + dataSource=reference, ) - profile_attributes.profileQuantityAndUnit = get_profileQuantityAndUnit(profile_name) + if (quantity_and_unit := get_profileQuantityAndUnit(profile_name)) is not None: + profile_attributes.profileQuantityAndUnit = quantity_and_unit port.profile.append(profile_attributes) for index, row in simulation_result.loc[ diff --git a/testdata/test_ates.esdl b/testdata/test_ates.esdl new file mode 100644 index 0000000..ea0eac1 --- /dev/null +++ b/testdata/test_ates.esdl @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/unit_test/test_kpi_integration.py b/unit_test/test_kpi_integration.py new file mode 100644 index 0000000..3ae8b65 --- /dev/null +++ b/unit_test/test_kpi_integration.py @@ -0,0 +1,100 @@ +"""Test KPI integration with simulator-worker.""" + +import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Check if full simulator worker can be imported +SIMULATOR_AVAILABLE = False +try: + from omotes_simulator_core.infrastructure.utils import pyesdl_from_string + + from simulator_worker.simulator_worker import simulator_worker_task + + SIMULATOR_AVAILABLE = True +except ImportError: + simulator_worker_task = None # type: ignore[assignment, misc] + pyesdl_from_string = None # type: ignore[assignment, misc] + + +@pytest.mark.skipif(not SIMULATOR_AVAILABLE, reason="omotes_simulator_core not installed") +class TestKPIEndToEndIntegration: + """Integration tests for end-to-end KPI calculation in simulator workflow.""" + + def test_kpis_calculated_and_stored_in_output_esdl(self) -> None: + """Test that KPIs are calculated from simulation and stored in output ESDL.""" + test_esdl_path = Path(__file__).parent.parent / "testdata" / "test_ates.esdl" + with open(test_esdl_path, "r") as f: + input_esdl = f.read() + + start_time = datetime.datetime(2019, 1, 1, 0, 0, tzinfo=datetime.timezone.utc) + end_time = datetime.datetime(2019, 1, 1, 2, 0, tzinfo=datetime.timezone.utc) + + workflow_config: dict[str, list[float] | float | str | bool] = { + "timestep": 3600.0, + "start_time": start_time.timestamp(), + "end_time": end_time.timestamp(), + "system_lifetime": 30.0, + } + + mock_progress = MagicMock() + + # Mock InfluxDB so simulation results are not written to a real database + with patch("simulator_worker.utils.InfluxDBProfileManager"): + output_esdl, _ = simulator_worker_task( + input_esdl, workflow_config, mock_progress, "simulator" + ) + + # Verify output ESDL structure + assert output_esdl is not None + esh = pyesdl_from_string(output_esdl) + energy_system = esh.energy_system + + assert energy_system.instance, "Output ESDL must have at least one instance" + main_area = energy_system.instance[0].area + assert main_area is not None, "instance[0] must have an area" + + # KPIs are attached to the main area, not energy_system directly + assert main_area.KPIs is not None, "KPIs should be present in the main area" + kpi_list = list(main_area.KPIs.kpi) + assert len(kpi_list) > 0, "At least one KPI should be calculated" + + # Verify each KPI has a name and a non-negative value + for kpi in kpi_list: + assert kpi.name, f"KPI {kpi} should have a name" + + kpi_by_name = {kpi.name: kpi for kpi in kpi_list} + + # --- Cost KPIs: exact values from test_ates.esdl costInformation --- + # The ATES asset has investmentCosts=2333594.0 EUR and fixedMaintenanceCosts + # that produce OPEX=215138.89 EUR/year. These derive purely from the ESDL + # cost data and are deterministic regardless of simulation time series. + assert ( + "High level cost breakdown [EUR]" in kpi_by_name + ), "Cost breakdown KPI missing from output" + cost_items = { + item.label: item.value + for item in kpi_by_name["High level cost breakdown [EUR]"].distribution.stringItem + } + assert cost_items.get("CAPEX (total)") == pytest.approx( + 2_333_594.0 + ), f"CAPEX should match investmentCosts in test_ates.esdl; got {cost_items}" + assert cost_items.get("OPEX (yearly)") == pytest.approx( + 215_138.89 + ), f"OPEX should match fixedMaintenanceCosts in test_ates.esdl; got {cost_items}" + + # --- Energy/emission KPIs: zero because InfluxDB is mocked --- + # The simulator produces 'heat_demand' columns, but kpi-calculator's energy + # calculator looks for 'ThermalConsumption'/'Consumption'/'ThermalDemand' — the + # column names don't match so no time series is consumed. This is a known gap + # tracked in the kpi-calculator roadmap (Flexible Time Series Column Mapping). + assert "Energy breakdown [Wh]" in kpi_by_name, "Energy breakdown KPI missing" + energy_items = { + item.label: item.value + for item in kpi_by_name["Energy breakdown [Wh]"].distribution.stringItem + } + assert all( + v == 0.0 for v in energy_items.values() + ), f"Energy KPIs should be zero without matching time series fields; got {energy_items}"