diff --git a/docker-compose.yml b/docker-compose.yml index 4c70b3d..183d77e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -215,7 +215,7 @@ services: - "./gurobi/gurobi.lic:/app/gurobi/gurobi.lic" omotes_simulator_worker: - image: ghcr.io/project-omotes/omotes-simulator-worker:0.0.28 + image: ghcr.io/project-omotes/omotes-simulator-worker:0.0.30 restart: unless-stopped deploy: replicas: 2 diff --git a/system_tests/src/test_esdl/input/simulator_ates_short_run.esdl b/system_tests/src/test_esdl/input/simulator_ates_short_run.esdl index ea0eac1..bb39034 100644 --- a/system_tests/src/test_esdl/input/simulator_ates_short_run.esdl +++ b/system_tests/src/test_esdl/input/simulator_ates_short_run.esdl @@ -1,73 +1,73 @@ - + - - - - - - - + + + + + + + - - + + - - + + - + - + - - + + - + - + - - + + - + - - - - + + + + - + - - + + - + - - + + - - - - + + + + - + @@ -75,10 +75,10 @@ - - + + - + @@ -89,71 +89,71 @@ - - + + - - - - + + + + - - - - + + + + - + - - - + + + - - + + - + - - + + - - + + - + - - - - - + + + + + - - + + - + - - - - - - - - + + + + + + + + - - + + - + - - + + - + @@ -161,84 +161,84 @@ - - + + - - - - + + + + - + - - - + + + - - + + - + - - + + - - + + - + - - + + - - - + + + - - + + - + - + - - + + - + + + + + + + + + + + + - + - + - - - - + - - - - - - - - - - - - + + + + - \ No newline at end of file + diff --git a/system_tests/src/test_esdl/output/test__simulator__multiple_ates_run.esdl b/system_tests/src/test_esdl/output/test__simulator__multiple_ates_run.esdl index 2409ade..aec94fb 100644 --- a/system_tests/src/test_esdl/output/test__simulator__multiple_ates_run.esdl +++ b/system_tests/src/test_esdl/output/test__simulator__multiple_ates_run.esdl @@ -1,307 +1,307 @@ - + - - - - - - - + + - - + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - - - + + + + - + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - + + + + - + @@ -309,64 +309,64 @@ - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + @@ -377,395 +377,395 @@ - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - + + + + - - - - + + + + - + - - - + + + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - - - - + + + + + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - - - - - - - + + + + + + + + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + @@ -773,429 +773,463 @@ - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - + + + + - + - - - + + + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - - + + + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - - - - - - - - - - - - - - + + + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + - - - - - + + + + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/system_tests/src/test_workflows_steps.py b/system_tests/src/test_workflows_steps.py index 8edbe4f..935b296 100644 --- a/system_tests/src/test_workflows_steps.py +++ b/system_tests/src/test_workflows_steps.py @@ -37,6 +37,12 @@ port=int(os.environ.get("RABBITMQ_PORT", "5672")), ) +# KPI content is tested separately in test__simulator__kpis_present_in_output +# and changes with each kpi-calculator release, so exclude it from snapshot comparisons. +# Note: this path assumes xmltodict parses a single element (dict, not list). +# If multi-instance ESDLs are introduced, this path will need to be updated. +EXCLUDE_KPI_PATHS: set[str] = {"root['esdl:EnergySystem']['instance']['area']['KPIs']"} + SQL_CONFIG = { "host": os.environ.get("POSTGRES_HOST", "localhost"), "port": int(os.environ.get("POSTGRES_PORT", "6432")), @@ -225,10 +231,19 @@ def expect_a_result( f"The job did not finish as {expected_result}. Found {result.result_type}" ) - def compare_esdl(self, expected_esdl: str, result_esdl: str) -> None: + def compare_esdl( + self, + expected_esdl: str, + result_esdl: str, + exclude_paths: set[str] | None = None, + ) -> None: + """Compare two ESDL strings for equality after normalization. + + :param exclude_paths: Optional DeepDiff paths to ignore (e.g. EXCLUDE_KPI_PATHS). + """ expected = normalize_esdl(expected_esdl) result = normalize_esdl(result_esdl) - diff = DeepDiff(expected, result, math_epsilon=ESDL_VALUES_PRECISION) + diff = DeepDiff(expected, result, math_epsilon=ESDL_VALUES_PRECISION, exclude_paths=exclude_paths) if diff: diff_msg = pformat(diff) @@ -283,7 +298,7 @@ def test__simulator__happy_path(self) -> None: result_handler = OmotesJobHandler() esdl_file = retrieve_esdl_file("./test_esdl/input/simulator_tutorial.esdl") workflow_type = "simulator" - timeout_seconds = 60.0 + timeout_seconds = 120.0 params_dict = { "timestep": datetime.timedelta(hours=1), "start_time": datetime.datetime(2019, 1, 1, 0, 0, 0, tzinfo=datetime.UTC), @@ -302,7 +317,9 @@ def test__simulator__happy_path(self) -> None: expected_esdl = retrieve_esdl_file( "./test_esdl/output/test__simulator__happy_path.esdl" ) - self.compare_esdl(expected_esdl, result_handler.result.output_esdl) + self.compare_esdl( + expected_esdl, result_handler.result.output_esdl, exclude_paths=EXCLUDE_KPI_PATHS + ) # assert time series data created assert_influxdb_database_existence(result_handler.result.output_esdl, True) @@ -322,7 +339,7 @@ def test__simulator__multiple_ates_run(self) -> None: "./test_esdl/input/simulator_ates_short_run.esdl" ) workflow_type = "simulator" - timeout_seconds = 60.0 + timeout_seconds = 120.0 params_dict = { "timestep": datetime.timedelta(hours=1), "start_time": datetime.datetime(2019, 1, 1, 0, 0, 0, tzinfo=datetime.UTC), @@ -350,7 +367,9 @@ def test__simulator__multiple_ates_run(self) -> None: for result_handler in result_handlers: self.expect_a_result(result_handler, JobResult.SUCCEEDED) - self.compare_esdl(expected_esdl, result_handler.result.output_esdl) + self.compare_esdl( + expected_esdl, result_handler.result.output_esdl, exclude_paths=EXCLUDE_KPI_PATHS + ) def test__grow_optimizer_default__happy_path_1source(self) -> None: # Arrange @@ -431,7 +450,7 @@ def test__simulator__job_reference_is_set(self) -> None: result_handler = OmotesJobHandler() esdl_file = retrieve_esdl_file("./test_esdl/input/simulator_tutorial.esdl") workflow_type = "simulator" - timeout_seconds = 60.0 + timeout_seconds = 120.0 params_dict = { "timestep": datetime.timedelta(hours=1), "start_time": datetime.datetime(2019, 1, 1, 0, 0, 0), @@ -459,7 +478,7 @@ def test__simulator__check_if_progress_updates_are_received(self) -> None: result_handler = OmotesJobHandler() esdl_file = retrieve_esdl_file("./test_esdl/input/simulator_tutorial.esdl") workflow_type = "simulator" - timeout_seconds = 60.0 + timeout_seconds = 120.0 params_dict = { "timestep": datetime.timedelta(hours=1), "start_time": datetime.datetime(2019, 1, 1, 0, 0, 0), @@ -612,7 +631,7 @@ def test__simulator__delete_time_series_data_after_run(self) -> None: result_handler = OmotesJobHandler() esdl_file = retrieve_esdl_file("./test_esdl/input/simulator_tutorial.esdl") workflow_type = "simulator" - timeout_seconds = 100.0 + timeout_seconds = 160.0 params_dict = { "timestep": datetime.timedelta(hours=1), "start_time": datetime.datetime(2019, 1, 1, 0, 0, 0, tzinfo=datetime.UTC), @@ -722,3 +741,95 @@ def _watch_job(result_handler: OmotesJobHandler): self.assertTrue(str(high_priority_job.id) in ordered_job_result_ids) # check that high priority job result was not last (exact order may vary) self.assertNotEqual(str(high_priority_job.id), ordered_job_result_ids[-1]) + + def test__simulator__kpis_present_in_output(self) -> None: + """Test that KPIs are calculated and stored in the output ESDL. + + Uses simulator_ates_short_run.esdl which contains costInformation on assets, + allowing the kpi-calculator to produce non-trivial KPI results. + """ + # Arrange + result_handler = OmotesJobHandler() + esdl_file = retrieve_esdl_file( + "./test_esdl/input/simulator_ates_short_run.esdl" + ) + workflow_type = "simulator" + timeout_seconds = 120.0 + params_dict = { + "timestep": datetime.timedelta(hours=1), + "start_time": datetime.datetime(2019, 1, 1, 0, 0, 0, tzinfo=datetime.UTC), + "end_time": datetime.datetime(2019, 1, 1, 3, 0, 0, tzinfo=datetime.UTC), + "system_lifetime": 25.0, + } + + # Act + with omotes_client() as omotes_client_: + submit_a_job( + omotes_client_, esdl_file, workflow_type, params_dict, result_handler + ) + result_handler.wait_until_result(timeout_seconds) + + # Assert + self.expect_a_result(result_handler, JobResult.SUCCEEDED) + output_esh = esdl.esdl_handler.EnergySystemHandler() + output_esh.load_from_string(result_handler.result.output_esdl) + energy_system = output_esh.energy_system + + # KPIs are attached to instance[0].area, not energy_system directly + self.assertGreater( + len(energy_system.instance), 0, "Output ESDL must have at least one instance" + ) + main_area = energy_system.instance[0].area + self.assertIsNotNone(main_area, "instance[0] must have an area") + self.assertIsNotNone(main_area.KPIs, "KPIs should be present in the main area") + kpi_list = list(main_area.KPIs.kpi) + self.assertGreater(len(kpi_list), 0, "At least one KPI should be calculated") + for kpi in kpi_list: + self.assertNotEqual(kpi.name, "", "Each KPI should have a name") + + kpi_by_name = {kpi.name: kpi for kpi in kpi_list} + + # Assert all expected KPI categories are present + expected_kpi_names = { + "High level cost breakdown [EUR]", + "Net Present Value [EUR]", + "Equivalent Annual Cost [EUR/yr]", + "Total Cost of Ownership [EUR]", + "Energy breakdown [Wh]", + } + self.assertEqual( + expected_kpi_names, + set(kpi_by_name.keys()), + "KPI names do not match expected set", + ) + + # CAPEX: the ATES asset in simulator_ates_short_run.esdl has + # investmentCosts=2333594.0 EUR — only asset with cost data so total CAPEX equals that. + # OPEX: fixedOperationalCosts(30000) + fixedMaintenanceCosts(115472.22) + # + variableOperationalCosts(69666.67) = 215138.89 EUR/yr — asset-level, time-independent. + cost_items = { + item.label: item.value + for item in kpi_by_name["High level cost breakdown [EUR]"].distribution.stringItem + } + self.assertAlmostEqual( + cost_items.get("CAPEX (total)", 0.0), + 2_333_594.0, + places=1, + msg=f"CAPEX should match investmentCosts in simulator_ates_short_run.esdl; got {cost_items}", + ) + self.assertAlmostEqual( + cost_items.get("OPEX (yearly)", 0.0), + 215_138.89, + places=1, + msg=f"OPEX should match sum of fixed/variable costs in simulator_ates_short_run.esdl; got {cost_items}", + ) + + # Energy breakdown: confirms time-series results flowed through the simulation + energy_items = { + item.label: item.value + for item in kpi_by_name["Energy breakdown [Wh]"].distribution.stringItem + } + self.assertIn("Production", energy_items, "Energy breakdown should contain Production") + self.assertIn("Demand", energy_items, "Energy breakdown should contain Demand") + self.assertGreater(energy_items["Production"], 0.0, "Production energy should be positive") + self.assertGreater(energy_items["Demand"], 0.0, "Demand energy should be positive")