From 6c6f6e0c9b0a943f023c0ff4f77c9faa8d39e2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20A=2E=20Rodr=C3=ADguez?= Date: Mon, 13 Apr 2026 10:09:30 +0200 Subject: [PATCH] feat: upgrade kpi-calculator to 0.4.1 and integrate KPI output into simulator worker - Bump kpi-calculator to 0.4.1 - Pass simulation results to calculate_kpis_from_simulator - Embed KPI results in output ESDL via build_esdl_string_with_kpis - Support optional system_lifetime, discount_rate, and round_up_replacement config params - Add end-to-end integration test verifying CAPEX, OPEX, and energy KPIs --- .gitignore | 3 +- dev-requirements.txt | 237 +++++++++++++--------- pyproject.toml | 5 +- requirements.txt | 84 +++++--- src/simulator_worker/simulator_worker.py | 48 ++++- src/simulator_worker/utils.py | 42 ++-- testdata/test_ates.esdl | 244 +++++++++++++++++++++++ unit_test/test_kpi_integration.py | 89 +++++++++ 8 files changed, 601 insertions(+), 151 deletions(-) create mode 100644 testdata/test_ates.esdl create mode 100644 unit_test/test_kpi_integration.py 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..1c0307f 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 -attrs==25.4.0 +annotated-types==0.7.0 + # via + # -c requirements.txt + # pydantic +attrs==26.1.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.3 # 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.7 # via - # -c ..\..\requirements.txt + # -c requirements.txt # requests -click==8.3.0 +click==8.3.2 # via - # -c ..\..\requirements.txt + # -c requirements.txt # black # celery # click-didyoumean @@ -48,32 +52,36 @@ 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 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 +89,96 @@ 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 +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 -lxml==6.0.2 +kpi-calculator==0.4.1 + # via + # -c requirements.txt + # simulator-worker (pyproject.toml) +lxml==6.0.4 # 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 +186,161 @@ 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) + # simulator-worker (pyproject.toml) pandas-stubs==2.1.4.231227 - # via simulator-worker (..\..\pyproject.toml) -pathspec==0.12.1 + # via simulator-worker (pyproject.toml) +pathspec==1.0.4 # via black -platformdirs==4.5.0 +platformdirs==4.9.6 # 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 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 +requests==2.33.1 # 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 +types-pytz==2026.1.1.20260408 # via pandas-stubs 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 + # -c requirements.txt + # pydantic +tzdata==2026.1 + # via + # -c requirements.txt # kombu # pandas -urllib3==2.5.0 + # tzlocal +tzlocal==5.3.1 + # via + # -c requirements.txt + # celery +urllib3==2.6.3 # via - # -c ..\..\requirements.txt + # -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..907dd7d 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.4.1", ] [project.optional-dependencies] @@ -72,6 +73,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 +116,4 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = "esdl.*" ignore_missing_imports = true + diff --git a/requirements.txt b/requirements.txt index 2f0d178..8441b7c 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.3 # via omotes-sdk-python -certifi==2025.10.5 +certifi==2026.2.25 # via requests -charset-normalizer==3.4.3 +charset-normalizer==3.4.7 # via requests -click==8.3.0 +click==8.3.2 # via # celery # click-didyoumean @@ -36,38 +38,45 @@ 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 +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 -lxml==6.0.2 +kpi-calculator==0.4.1 + # via simulator-worker (pyproject.toml) +lxml==6.0.4 # 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 # 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,21 +84,27 @@ 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) 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 python-dateutil==2.9.0.post0 @@ -98,14 +113,14 @@ python-dateutil==2.9.0.post0 # 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 +requests==2.33.1 # via influxdb -restrictedpython==8.0 +restrictedpython==8.1 # via pyecore scipy==1.14.1 # via omotes-simulator-core @@ -113,24 +128,37 @@ six==1.17.0 # via # influxdb # python-dateutil -streamcapture==1.2.5 +streamcapture==1.2.7 # via omotes-sdk-python 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==2026.1 # 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..44cd0be 100644 --- a/src/simulator_worker/simulator_worker.py +++ b/src/simulator_worker/simulator_worker.py @@ -21,6 +21,8 @@ from uuid import uuid4 import dotenv + +from kpicalculator import build_esdl_string_with_kpis, calculate_kpis_from_simulator from omotes_sdk.internal.orchestrator_worker_events.esdl_messages import EsdlMessage from omotes_sdk.internal.worker.worker import UpdateProgressHandler, initialize_worker from omotes_sdk.types import ProtobufDict @@ -43,6 +45,18 @@ logger = logging.getLogger("simulator_worker") +def _parse_bool_config(config: ProtobufDict, key: str, default: bool) -> bool: + """Read a bool parameter from workflow config, with Protobuf-safe string handling.""" + value = config.get(key, default) + return value if isinstance(value, bool) else str(value).lower() != "false" + + +def _parse_float_config(config: ProtobufDict, key: str) -> float | None: + """Read a float parameter from workflow config; returns None if absent or non-numeric.""" + value = config.get(key) + return float(value) if isinstance(value, (int, float, str)) else None + + def simulator_worker_task( input_esdl: str, workflow_config: ProtobufDict, @@ -57,9 +71,12 @@ def simulator_worker_task( in this task by the subprocess. Expected contents of workflow_config: - - start_time_unix_s: int (float with .0), seconds since epoch - - end_time_unix_s: int (float with .0), seconds since epoch - - timestep_s: int (float with .0) seconds + - start_time: float, seconds since epoch + - end_time: float, seconds since epoch + - timestep: float, seconds + - system_lifetime: float (optional), system lifetime in years for KPI calculation + - discount_rate: float (optional), discount rate in % for NPV/LCOE/EAC calculation + - round_up_replacement: bool (optional), set False for MESIDO optimizer compatibility :param input_esdl: The input ESDL XML string. :param workflow_config: Extra parameters to configure this run. @@ -111,8 +128,31 @@ 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: + system_lifetime = _parse_float_config(workflow_config, "system_lifetime") + discount_rate = _parse_float_config(workflow_config, "discount_rate") + round_up_replacement = _parse_bool_config(workflow_config, "round_up_replacement", True) + kpi_results = calculate_kpis_from_simulator( + result_indexed, + input_esdl, + **({"system_lifetime": system_lifetime} if system_lifetime is not None else {}), + **({"discount_rate": discount_rate} if discount_rate is not None else {}), + round_up_replacement=round_up_replacement, + ) + logger.info( + "KPI calculation completed for %d assets.", + len(kpi_results["asset_financials"]), + ) + output_esdl = build_esdl_string_with_kpis(output_esdl, kpi_results) + except Exception: + logger.exception("KPI calculation failed. Results will be returned without KPIs.") + # Write output_esdl to file for debugging # with open(f"result_{simulation_id}.esdl", "w") as file: # file.writelines(output_esdl) @@ -124,7 +164,7 @@ def start_app() -> None: try: initialize_worker(["simulator"], simulator_worker_task) except Exception as error: - logger.error("Error occured: %s at: %s", error, traceback.format_exc(limit=-1)) + logger.error("Error occurred: %s at: %s", error, traceback.format_exc(limit=-1)) logger.debug(traceback.format_exc()) raise error 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..1f25900 --- /dev/null +++ b/unit_test/test_kpi_integration.py @@ -0,0 +1,89 @@ +"""Test KPI integration with simulator-worker.""" + +import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from omotes_sdk.types import ProtobufDict + +# 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: ProtobufDict = { + "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}" + + assert "Energy breakdown [Wh]" in kpi_by_name, "Energy breakdown KPI missing"