Skip to content

Commit 107ce00

Browse files
committed
Add system test for KPI integration in simulator workflow
- Add test__simulator__kpis_present_in_output asserting KPIs are present in instance[0].area after a full pipeline run, with CAPEX verified against the ESDL costInformation and system_lifetime exercised with a non-default value (25 years) - Add scripts/test_system_local.sh for running system tests with a locally built simulator-worker image via docker-compose.override.local.yml - Gitignore docker-compose.override.local.yml - Update docker-compose.yml to use simulator-worker 0.0.29beta (kpi-calculator integration) - Exclude KPIs from snapshot comparison in existing simulator tests to avoid snapshot drift; KPI correctness is covered by the dedicated test
1 parent fe48e28 commit 107ce00

4 files changed

Lines changed: 111 additions & 9 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,5 @@ fabric.properties
219219

220220
unit_test_coverage/
221221
.env.test
222-
gurobi/
222+
gurobi/
223+
docker-compose.override.local.yml

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ services:
215215
- "./gurobi/gurobi.lic:/app/gurobi/gurobi.lic"
216216

217217
omotes_simulator_worker:
218-
image: ghcr.io/project-omotes/omotes-simulator-worker:0.0.28
218+
image: ghcr.io/project-omotes/omotes-simulator-worker:0.0.29beta
219219
restart: unless-stopped
220220
deploy:
221221
replicas: 2

scripts/test_system_local.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/bash
2+
# Run system tests with a locally built simulator-worker image.
3+
# The image is built from ../../simulator-worker-OMOTES/simulator-worker
4+
# via docker-compose.override.local.yml (gitignored).
5+
. scripts/_select_docker_compose.sh
6+
7+
export COMPOSE_PROJECT_NAME=omotes_system_tests
8+
9+
ENV_FILE=".env.test"
10+
DOCKER_COMPOSE_FILE="./docker-compose.yml -f docker-compose.override.local.yml -f system_tests/docker-compose.override.yml"
11+
12+
cp .env.template ${ENV_FILE}
13+
sed -i 's/LOG_LEVEL=[a-z]*/LOG_LEVEL=WARNING/gi' ${ENV_FILE}
14+
15+
$DOCKER_COMPOSE --env-file ${ENV_FILE} -f $DOCKER_COMPOSE_FILE down -v
16+
./scripts/setup.sh $ENV_FILE "./docker-compose.yml -f ./docker-compose.override.setup.yml"
17+
$DOCKER_COMPOSE --env-file ${ENV_FILE} -f $DOCKER_COMPOSE_FILE build
18+
$DOCKER_COMPOSE --env-file ${ENV_FILE} -f $DOCKER_COMPOSE_FILE up --abort-on-container-exit

system_tests/src/test_workflows_steps.py

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from pathlib import Path
1010
from pprint import pformat
1111
from time import sleep
12-
from typing import Any
1312
import psycopg
1413
from psycopg.rows import dict_row
1514
from influxdb import InfluxDBClient
@@ -37,6 +36,12 @@
3736
port=int(os.environ.get("RABBITMQ_PORT", "5672")),
3837
)
3938

39+
# KPI content is tested separately in test__simulator__kpis_present_in_output
40+
# and changes with each kpi-calculator release, so exclude it from snapshot comparisons.
41+
# Note: this path assumes xmltodict parses a single <instance> element (dict, not list).
42+
# If multi-instance ESDLs are introduced, this path will need to be updated.
43+
EXCLUDE_KPIS: set[str] = {"root['esdl:EnergySystem']['instance']['area']['KPIs']"}
44+
4045
SQL_CONFIG = {
4146
"host": os.environ.get("POSTGRES_HOST", "localhost"),
4247
"port": int(os.environ.get("POSTGRES_PORT", "6432")),
@@ -205,13 +210,21 @@ def expect_a_result(
205210
f"The job did not finish as {expected_result}. Found {result.result_type}"
206211
)
207212

208-
def compare_esdl(self, expected_esdl: str, result_esdl: str) -> None:
213+
def compare_esdl(
214+
self,
215+
expected_esdl: str,
216+
result_esdl: str,
217+
exclude_paths: set[str] | None = None,
218+
) -> None:
219+
"""Compare two ESDL strings for equality after normalization.
220+
221+
:param exclude_paths: Optional DeepDiff paths to ignore (e.g. EXCLUDE_KPIS).
222+
"""
209223
expected = normalize_esdl(expected_esdl)
210224
result = normalize_esdl(result_esdl)
211-
diff_msg = pformat(DeepDiff(expected, result))
212-
225+
diff = DeepDiff(expected, result, exclude_paths=exclude_paths)
213226
self.assertEqual(
214-
expected, result, msg=f"Found the following differences:\n{diff_msg}"
227+
{}, dict(diff), msg=f"Found the following differences:\n{pformat(diff)}"
215228
)
216229

217230
def test__grow_optimizer_default__happy_path(self) -> None:
@@ -282,7 +295,9 @@ def test__simulator__happy_path(self) -> None:
282295
expected_esdl = retrieve_esdl_file(
283296
"./test_esdl/output/test__simulator__happy_path.esdl"
284297
)
285-
self.compare_esdl(expected_esdl, result_handler.result.output_esdl)
298+
self.compare_esdl(
299+
expected_esdl, result_handler.result.output_esdl, exclude_paths=EXCLUDE_KPIS
300+
)
286301

287302
# assert time series data created
288303
assert_influxdb_database_existence(result_handler.result.output_esdl, True)
@@ -330,7 +345,9 @@ def test__simulator__multiple_ates_run(self) -> None:
330345

331346
for result_handler in result_handlers:
332347
self.expect_a_result(result_handler, JobResult.SUCCEEDED)
333-
self.compare_esdl(expected_esdl, result_handler.result.output_esdl)
348+
self.compare_esdl(
349+
expected_esdl, result_handler.result.output_esdl, exclude_paths=EXCLUDE_KPIS
350+
)
334351

335352
def test__grow_optimizer_default__happy_path_1source(self) -> None:
336353
# Arrange
@@ -702,3 +719,69 @@ def _watch_job(result_handler: OmotesJobHandler):
702719
self.assertTrue(str(high_priority_job.id) in ordered_job_result_ids)
703720
# check that high priority job result was not last (exact order may vary)
704721
self.assertNotEqual(str(high_priority_job.id), ordered_job_result_ids[-1])
722+
723+
def test__simulator__kpis_present_in_output(self) -> None:
724+
"""Test that KPIs are calculated and stored in the output ESDL.
725+
726+
Uses simulator_ates_short_run.esdl which contains costInformation on assets,
727+
allowing the kpi-calculator to produce non-trivial KPI results.
728+
"""
729+
# Arrange
730+
result_handler = OmotesJobHandler()
731+
esdl_file = retrieve_esdl_file(
732+
"./test_esdl/input/simulator_ates_short_run.esdl"
733+
)
734+
workflow_type = "simulator"
735+
timeout_seconds = 60.0
736+
params_dict = {
737+
"timestep": datetime.timedelta(hours=1),
738+
"start_time": datetime.datetime(2019, 1, 1, 0, 0, 0, tzinfo=datetime.UTC),
739+
"end_time": datetime.datetime(2019, 1, 1, 3, 0, 0, tzinfo=datetime.UTC),
740+
"system_lifetime": 25.0,
741+
}
742+
743+
# Act
744+
with omotes_client() as omotes_client_:
745+
submit_a_job(
746+
omotes_client_, esdl_file, workflow_type, params_dict, result_handler
747+
)
748+
result_handler.wait_until_result(timeout_seconds)
749+
750+
# Assert
751+
self.expect_a_result(result_handler, JobResult.SUCCEEDED)
752+
output_esh = esdl.esdl_handler.EnergySystemHandler()
753+
output_esh.load_from_string(result_handler.result.output_esdl)
754+
energy_system = output_esh.energy_system
755+
756+
# KPIs are attached to instance[0].area, not energy_system directly
757+
self.assertGreater(
758+
len(energy_system.instance), 0, "Output ESDL must have at least one instance"
759+
)
760+
main_area = energy_system.instance[0].area
761+
self.assertIsNotNone(main_area, "instance[0] must have an area")
762+
self.assertIsNotNone(main_area.KPIs, "KPIs should be present in the main area")
763+
kpi_list = list(main_area.KPIs.kpi)
764+
self.assertGreater(len(kpi_list), 0, "At least one KPI should be calculated")
765+
for kpi in kpi_list:
766+
self.assertNotEqual(kpi.name, "", "Each KPI should have a name")
767+
768+
# CAPEX: the ATES asset in simulator_ates_short_run.esdl has
769+
# investmentCosts=2333594.0 EUR (bare EUR unit, no conversion factor).
770+
# This is the only asset with cost data so total CAPEX equals that value.
771+
expected_capex = 2_333_594.0
772+
kpi_by_name = {kpi.name: kpi for kpi in kpi_list}
773+
self.assertIn(
774+
"High level cost breakdown [EUR]",
775+
kpi_by_name,
776+
"Cost breakdown KPI missing from output",
777+
)
778+
cost_items = {
779+
item.label: item.value
780+
for item in kpi_by_name["High level cost breakdown [EUR]"].distribution.stringItem
781+
}
782+
self.assertAlmostEqual(
783+
cost_items.get("CAPEX (total)", 0.0),
784+
expected_capex,
785+
places=1,
786+
msg=f"CAPEX should match investmentCosts in simulator_ates_short_run.esdl; got {cost_items}",
787+
)

0 commit comments

Comments
 (0)