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}"