|
9 | 9 | from pathlib import Path |
10 | 10 | from pprint import pformat |
11 | 11 | from time import sleep |
12 | | -from typing import Any |
13 | 12 | import psycopg |
14 | 13 | from psycopg.rows import dict_row |
15 | 14 | from influxdb import InfluxDBClient |
|
37 | 36 | port=int(os.environ.get("RABBITMQ_PORT", "5672")), |
38 | 37 | ) |
39 | 38 |
|
| 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 | + |
40 | 45 | SQL_CONFIG = { |
41 | 46 | "host": os.environ.get("POSTGRES_HOST", "localhost"), |
42 | 47 | "port": int(os.environ.get("POSTGRES_PORT", "6432")), |
@@ -205,13 +210,21 @@ def expect_a_result( |
205 | 210 | f"The job did not finish as {expected_result}. Found {result.result_type}" |
206 | 211 | ) |
207 | 212 |
|
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 | + """ |
209 | 223 | expected = normalize_esdl(expected_esdl) |
210 | 224 | result = normalize_esdl(result_esdl) |
211 | | - diff_msg = pformat(DeepDiff(expected, result)) |
212 | | - |
| 225 | + diff = DeepDiff(expected, result, exclude_paths=exclude_paths) |
213 | 226 | 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)}" |
215 | 228 | ) |
216 | 229 |
|
217 | 230 | def test__grow_optimizer_default__happy_path(self) -> None: |
@@ -282,7 +295,9 @@ def test__simulator__happy_path(self) -> None: |
282 | 295 | expected_esdl = retrieve_esdl_file( |
283 | 296 | "./test_esdl/output/test__simulator__happy_path.esdl" |
284 | 297 | ) |
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 | + ) |
286 | 301 |
|
287 | 302 | # assert time series data created |
288 | 303 | assert_influxdb_database_existence(result_handler.result.output_esdl, True) |
@@ -330,7 +345,9 @@ def test__simulator__multiple_ates_run(self) -> None: |
330 | 345 |
|
331 | 346 | for result_handler in result_handlers: |
332 | 347 | 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 | + ) |
334 | 351 |
|
335 | 352 | def test__grow_optimizer_default__happy_path_1source(self) -> None: |
336 | 353 | # Arrange |
@@ -702,3 +719,69 @@ def _watch_job(result_handler: OmotesJobHandler): |
702 | 719 | self.assertTrue(str(high_priority_job.id) in ordered_job_result_ids) |
703 | 720 | # check that high priority job result was not last (exact order may vary) |
704 | 721 | 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