Skip to content

Commit 5ee1199

Browse files
committed
feat(kpi): integrate KPI calculation into simulator worker task
After create_output_esdl() writes InfluxDB profile references, call KpiManager.load_from_simulator() on the output ESDL so that profiles and KPIs coexist in the final result. System lifetime is read from workflow_config with a safe float() fallback. KpiValidationError is caught as a warning (simulation still returned without KPIs); unexpected failures are logged at ERROR with full traceback. Bump kpi-calculator dependency to >=0.3.0 / ==0.3.0 in requirements.txt. Add end-to-end integration test gated on INFLUXDB_HOSTNAME. Remove no-op test__hello_world cookiecutter artifact.
1 parent d30f10d commit 5ee1199

6 files changed

Lines changed: 137 additions & 7 deletions

File tree

dev-requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ tzdata==2025.2
281281
# -c ..\..\requirements.txt
282282
# kombu
283283
# pandas
284-
urllib3==2.5.0
284+
urllib3==2.6.3
285285
# via
286286
# -c ..\..\requirements.txt
287287
# requests

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ dependencies = [
2424
"omotes-sdk-python ~= 4.3.2",
2525
"omotes-simulator-core==0.0.28",
2626
"pyesdl==25.7",
27-
"pandas ~= 2.2.2"
27+
"pandas ~= 2.2.2",
28+
"kpi-calculator>=0.3.0",
2829
]
2930

3031
[project.optional-dependencies]
@@ -72,6 +73,7 @@ starting_version = "0.0.1"
7273
[tool.pytest.ini_options]
7374
addopts = "--cov=simulator_worker --cov-report html --cov-report term-missing --cov-fail-under 20"
7475
testpaths = ["unit_test"]
76+
python_files = ["test_*.py"]
7577

7678
[tool.coverage.run]
7779
source = ["src"]

requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ influxdb==5.3.2
4646
# via omotes-simulator-core
4747
kombu==5.5.4
4848
# via celery
49+
kpi-calculator==0.3.0
50+
# via simulator-worker (..\..\pyproject.toml)
4951
lxml==6.0.2
5052
# via pyecore
5153
msgpack==1.1.2
@@ -121,7 +123,7 @@ tzdata==2025.2
121123
# via
122124
# kombu
123125
# pandas
124-
urllib3==2.5.0
126+
urllib3==2.6.3
125127
# via requests
126128
vine==5.1.0
127129
# via

src/simulator_worker/simulator_worker.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from uuid import uuid4
2222

2323
import dotenv
24+
from esdl.esdl_handler import EnergySystemHandler
2425
from omotes_sdk.internal.orchestrator_worker_events.esdl_messages import EsdlMessage
2526
from omotes_sdk.internal.worker.worker import UpdateProgressHandler, initialize_worker
2627
from omotes_sdk.types import ProtobufDict
@@ -36,7 +37,14 @@
3637
from omotes_simulator_core.infrastructure.simulation_manager import SimulationManager
3738
from omotes_simulator_core.infrastructure.utils import pyesdl_from_string
3839

39-
from simulator_worker.utils import add_datetime_index, create_output_esdl
40+
from kpicalculator import KpiManager
41+
from kpicalculator.common.constants import DEFAULT_SYSTEM_LIFETIME_YEARS
42+
from kpicalculator.exceptions import ValidationError as KpiValidationError
43+
44+
from simulator_worker.utils import (
45+
add_datetime_index,
46+
create_output_esdl,
47+
)
4048

4149
dotenv.load_dotenv()
4250

@@ -111,8 +119,39 @@ def simulator_worker_task(
111119
len(result_indexed.columns),
112120
result_indexed.shape,
113121
)
122+
114123
output_esdl = create_output_esdl(input_esdl, result_indexed)
115124

125+
try:
126+
system_lifetime = float(
127+
workflow_config.get("system_lifetime", DEFAULT_SYSTEM_LIFETIME_YEARS)
128+
)
129+
except (TypeError, ValueError):
130+
system_lifetime = DEFAULT_SYSTEM_LIFETIME_YEARS
131+
132+
try:
133+
kpi_manager = KpiManager()
134+
kpi_manager.load_from_simulator(result_indexed, esdl_string=output_esdl)
135+
kpi_results = kpi_manager.calculate_all_kpis(system_lifetime=system_lifetime)
136+
137+
esh = EnergySystemHandler()
138+
esh.energy_system = kpi_manager.get_esdl_with_kpis(kpi_results, level="system")
139+
output_esdl = esh.to_string()
140+
logger.info("KPI calculation completed and added to output ESDL")
141+
142+
except KpiValidationError as e:
143+
# Expected failure: invalid ESDL structure or missing cost data.
144+
# Simulation result is still valid — return it without KPIs.
145+
logger.warning("KPI calculation skipped due to invalid input data: %s", e)
146+
except Exception:
147+
# Unexpected failure: log full traceback at ERROR so it is visible in
148+
# monitoring, but keep the simulation result intact.
149+
logger.error(
150+
"KPI calculation failed unexpectedly. "
151+
"Simulation will continue and return results without KPIs.\n%s",
152+
traceback.format_exc(),
153+
)
154+
116155
# Write output_esdl to file for debugging
117156
# with open(f"result_{simulation_id}.esdl", "w") as file:
118157
# file.writelines(output_esdl)

unit_test/test_hello.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@
2424

2525

2626
class TestHelloWorld(unittest.TestCase):
27-
def test__hello_world(self) -> None:
28-
print("Hello world!")
29-
3027
def test__add_datetime_index__happy_path(self) -> None:
3128
# Arrange
3229
df = pandas.DataFrame()

unit_test/test_kpi_integration.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Test KPI integration with simulator-worker."""
2+
3+
import datetime
4+
import os
5+
from pathlib import Path
6+
from unittest.mock import MagicMock
7+
8+
import esdl
9+
import pytest
10+
11+
# Check if full simulator worker can be imported
12+
SIMULATOR_AVAILABLE = False
13+
try:
14+
from simulator_worker.simulator_worker import simulator_worker_task
15+
from omotes_simulator_core.infrastructure.utils import pyesdl_from_string
16+
17+
SIMULATOR_AVAILABLE = True
18+
except ImportError:
19+
simulator_worker_task = None # type: ignore[assignment, misc]
20+
pyesdl_from_string = None # type: ignore[assignment, misc]
21+
22+
23+
@pytest.mark.skipif(
24+
not SIMULATOR_AVAILABLE or os.getenv("INFLUXDB_HOSTNAME") is None,
25+
reason="simulator-worker or InfluxDB not available"
26+
)
27+
class TestKPIEndToEndIntegration:
28+
"""Integration tests for end-to-end KPI calculation in simulator workflow."""
29+
30+
def test_kpis_included_in_output_esdl(self) -> None:
31+
"""Test that KPIs are calculated and included in output ESDL."""
32+
# Load test ESDL
33+
test_esdl_path = Path(__file__).parent.parent / "testdata" / "test1.esdl"
34+
with open(test_esdl_path, "r") as f:
35+
input_esdl = f.read()
36+
37+
# Configure workflow - use Unix timestamps (seconds since epoch)
38+
start_time = datetime.datetime(2019, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
39+
end_time = datetime.datetime(2019, 1, 1, 2, 0, tzinfo=datetime.timezone.utc)
40+
41+
workflow_config: dict[str, list[float] | float | str | bool] = {
42+
"timestep": 3600.0, # 1 hour in seconds
43+
"start_time": start_time.timestamp(), # Unix timestamp
44+
"end_time": end_time.timestamp(), # Unix timestamp
45+
"system_lifetime": 30.0,
46+
}
47+
48+
# Mock progress handler
49+
mock_progress = MagicMock()
50+
51+
# Run simulation with KPI calculation
52+
output_esdl, _ = simulator_worker_task(
53+
input_esdl, workflow_config, mock_progress, "simulator"
54+
)
55+
56+
# Verify output is not None
57+
assert output_esdl is not None
58+
assert len(output_esdl) > 0
59+
60+
# Parse output ESDL
61+
esh = pyesdl_from_string(output_esdl)
62+
energy_system = esh.energy_system
63+
64+
assert hasattr(energy_system, "KPIs"), "Energy system should have KPIs attribute"
65+
kpis = energy_system.KPIs
66+
assert kpis is not None, "KPIs should be calculated and present in output ESDL"
67+
68+
assert hasattr(kpis, "kpi"), "KPIs should have kpi collection"
69+
kpi_list = list(kpis.kpi)
70+
assert len(kpi_list) > 0, "At least one KPI should be present in output ESDL"
71+
72+
for kpi in kpi_list:
73+
assert isinstance(kpi, esdl.DistributionKPI), f"Expected DistributionKPI, got {type(kpi)}"
74+
assert kpi.name, "KPI should have a non-empty name"
75+
assert kpi.distribution is not None, f"KPI '{kpi.name}' has no distribution"
76+
items = list(kpi.distribution.stringItem)
77+
assert len(items) > 0, f"KPI '{kpi.name}' distribution has no items"
78+
for item in items:
79+
assert item.value is not None, f"KPI '{kpi.name}' item '{item.label}' has no value"
80+
81+
# InfluxDB profile references must survive the KPI enrichment step
82+
all_ports = [
83+
port
84+
for asset in energy_system.eAllContents()
85+
if isinstance(asset, esdl.Asset)
86+
for port in asset.port
87+
]
88+
assert any(
89+
len(port.profile) > 0 for port in all_ports
90+
), "Output ESDL should retain InfluxDB profile references from simulation results"

0 commit comments

Comments
 (0)