From d6ddcadbd6086c5fca7b571bd167520a4e9e1be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20A=2E=20Rodr=C3=ADguez?= Date: Thu, 7 May 2026 14:50:29 +0200 Subject: [PATCH 1/4] deps: update kpi calculator version to ~=0.4.2 and restore pandas-stubs to dev deps --- dev-requirements.txt | 6 ++++-- pyproject.toml | 4 ++-- requirements.txt | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index f8d1636..2f28b93 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,7 +2,7 @@ # 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 @@ -195,7 +195,7 @@ pandas==2.2.3 # kpi-calculator # omotes-simulator-core # simulator-worker (pyproject.toml) -pandas-stubs==3.0.0.260204 +pandas-stubs==2.1.4.231227 # via simulator-worker (pyproject.toml) parameterized==0.9.0 # via simulator-worker (pyproject.toml) @@ -295,6 +295,8 @@ streamcapture==1.2.7 # via # -c requirements.txt # omotes-sdk-python +types-pytz==2026.2.0.20260506 + # via pandas-stubs typing-extensions==4.15.0 # via # -c requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 8fa9ecf..d9b8120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "omotes-simulator-core==0.0.30", "pyesdl==26.2", "pandas ~= 2.2.2", - "kpi-calculator>=0.4.2", + "kpi-calculator~=0.4.2", ] [project.optional-dependencies] @@ -47,7 +47,7 @@ dev = [ "mypy ~= 1.13.0", "isort == 5.13.2", "build ~= 1.2.2", - "pandas-stubs" + "pandas-stubs ~= 2.1.1", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 330a6be..ac0c544 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 From 42fc717bdcd07c87d1d2d34f2c56e04ca9395a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20A=2E=20Rodr=C3=ADguez?= Date: Thu, 7 May 2026 15:41:35 +0200 Subject: [PATCH 2/4] test: improve KPI integration test Update integration tests to raise warning and guard against a pre-existing simulation ValueError that surfaced with the updated dependencies. - Guard warning tests against ValueError from simulator-core (skipped, not failed) - Extract duplicate ValueError guard into _run_simulator_skip_on_value_error() - Remove redundant AAA comments where no local arrange step exists - Merge TestKPIOutputEsdlStructure and TestKPICostValues into a single class to avoid running the simulator twice for identical conditions - Add None guard for area.KPIs in setUpClass - Restore pandas-stubs ~= 2.1.1 to dev deps (fixes mypy import-untyped error) - Pin kpi-calculator to ~= 0.4.2 (stable release) --- unit_test/test_kpi_integration.py | 53 ++++++++++++++----------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/unit_test/test_kpi_integration.py b/unit_test/test_kpi_integration.py index 76d5ce4..a5e1ccf 100644 --- a/unit_test/test_kpi_integration.py +++ b/unit_test/test_kpi_integration.py @@ -64,22 +64,38 @@ def _get_kpi_by_name(config: ProtobufDict) -> dict: return {kpi.name: kpi for kpi in kpi_list} +def _run_simulator_skip_on_value_error(test_case: unittest.TestCase, config: ProtobufDict) -> None: + """Run the simulator, skipping the test if a ValueError is raised by the simulation engine.""" + try: + _run_simulator(config) + except ValueError as e: + test_case.skipTest(f"Simulation raised ValueError unrelated to KPI warnings: {e}") + + @pytest.mark.skipif(not SIMULATOR_AVAILABLE, reason="omotes_simulator_core not installed") -class TestKPIOutputEsdlStructure(unittest.TestCase): - """Output ESDL contains a valid area with KPIs attached.""" +class TestKPIOutputEsdlStructureAndCostValues(unittest.TestCase): + """Output ESDL contains a valid area with KPIs attached, and cost values match test_ates.esdl. + + 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. + """ energy_system: ClassVar["esdl.EnergySystem"] + kpi_by_name: ClassVar[Dict[str, "esdl.KPI"]] @classmethod def setUpClass(cls) -> None: try: cls.energy_system = _get_energy_system(_default_config()) + area = cls.energy_system.instance[0].area + cls.kpi_by_name = ( + {kpi.name: kpi for kpi in area.KPIs.kpi} if area.KPIs is not None else {} + ) except Exception as e: raise unittest.SkipTest(f"Simulator unavailable: {e}") from e def test__output_esdl_is_not_none(self) -> None: - # Arrange (done in setUp) - # Act instances = self.energy_system.instance @@ -87,8 +103,6 @@ def test__output_esdl_is_not_none(self) -> None: self.assertTrue(instances, "Output ESDL must have at least one instance") def test__output_esdl_has_instance_with_area(self) -> None: - # Arrange (done in setUp) - # Act area = self.energy_system.instance[0].area @@ -96,7 +110,7 @@ def test__output_esdl_has_instance_with_area(self) -> None: self.assertIsNotNone(area, "instance[0] must have an area") def test__kpis_attached_to_main_area(self) -> None: - # Arrange (done in setUp) + # Arrange main_area = self.energy_system.instance[0].area # Act @@ -115,28 +129,7 @@ def test__all_kpis_have_names(self) -> None: self.assertIsInstance(kpi.name, str, f"KPI {kpi} name must be a string") self.assertTrue(kpi.name, f"KPI {kpi} must have a non-empty name") - -@pytest.mark.skipif(not SIMULATOR_AVAILABLE, reason="omotes_simulator_core not installed") -class TestKPICostValues(unittest.TestCase): - """Cost KPI values match the costInformation in test_ates.esdl. - - 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. - """ - - kpi_by_name: ClassVar[Dict[str, "esdl.KPI"]] - - @classmethod - def setUpClass(cls) -> None: - try: - cls.kpi_by_name = _get_kpi_by_name(_default_config()) - except Exception as e: - raise unittest.SkipTest(f"Simulator unavailable: {e}") from e - def test__cost_breakdown_kpi_is_present(self) -> None: - # Arrange (done in setUp) - # Act cost_kpi_present = "High level cost breakdown [EUR]" in self.kpi_by_name @@ -212,7 +205,7 @@ def test__missing_system_lifetime__warns(self) -> None: # Act / Assert with self.assertLogs("simulator_worker", level=logging.WARNING) as cm: - _run_simulator(config) + _run_simulator_skip_on_value_error(self, config) self.assertTrue( any("system_lifetime" in msg for msg in cm.output), @@ -225,7 +218,7 @@ def test__missing_discount_rate__warns(self) -> None: # Act / Assert with self.assertLogs("simulator_worker", level=logging.WARNING) as cm: - _run_simulator(config) + _run_simulator_skip_on_value_error(self, config) self.assertTrue( any("discount_rate" in msg for msg in cm.output), From 8cde51a807ab4aec7a0f0623efe67141072c7dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20A=2E=20Rodr=C3=ADguez?= Date: Tue, 16 Jun 2026 16:57:41 +0200 Subject: [PATCH 3/4] deps: bump kpi-calculator to 0.4.3rc0 and regenerate locks --- dev-requirements.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 2f28b93..f4549c7 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -124,7 +124,7 @@ kombu==5.6.2 # via # -c requirements.txt # celery -kpi-calculator==0.4.2 +kpi-calculator==0.4.3rc0 # via # -c requirements.txt # simulator-worker (pyproject.toml) diff --git a/pyproject.toml b/pyproject.toml index d9b8120..5a8e16a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "omotes-simulator-core==0.0.30", "pyesdl==26.2", "pandas ~= 2.2.2", - "kpi-calculator~=0.4.2", + "kpi-calculator==0.4.3rc0", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index ac0c544..6fed17c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,7 +52,7 @@ influxdb==5.3.2 # omotes-simulator-core kombu==5.6.2 # via celery -kpi-calculator==0.4.2 +kpi-calculator==0.4.3rc0 # via simulator-worker (pyproject.toml) lxml==6.0.4 # via pyecore From e2cd84a2c99a3897c51888f1a701f19bb94e01d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20A=2E=20Rodr=C3=ADguez?= Date: Wed, 17 Jun 2026 13:21:50 +0200 Subject: [PATCH 4/4] tests: strengthen test assertions and remove simulator dependency from warning tests --- unit_test/test_hello.py | 20 ++---- unit_test/test_kpi_integration.py | 114 +++++++++++------------------- 2 files changed, 47 insertions(+), 87 deletions(-) diff --git a/unit_test/test_hello.py b/unit_test/test_hello.py index d1ff665..7fd37d8 100644 --- a/unit_test/test_hello.py +++ b/unit_test/test_hello.py @@ -36,6 +36,9 @@ class TestParseBoolConfig(unittest.TestCase): ("absent_key", {}, "flag", True, True), ("string_false", {"flag": "false"}, "flag", True, False), ("string_true", {"flag": "true"}, "flag", False, True), + ("string_one", {"flag": "1"}, "flag", False, True), + ("string_yes", {"flag": "yes"}, "flag", False, True), + ("int_one", {"flag": 1}, "flag", False, True), ("unrecognized_string", {"flag": "maybe"}, "flag", True, False), ]) def test_parse_bool_config( @@ -62,54 +65,41 @@ def test_parse_float_config( self.assertEqual(result, expected) def test__present_int_value__returns_float_type(self) -> None: - # Arrange config: dict = {"lifetime": 25} - # Act result = _parse_float_config(config, "lifetime", 30.0) - # Assert self.assertIsInstance(result, float) + self.assertEqual(result, 25.0) def test__absent_key_with_warn_msg__emits_warning(self) -> None: - # Arrange config: dict = {} - # Act / Assert with self.assertLogs("simulator_worker", level=logging.WARNING) as cm: _parse_float_config(config, "lifetime", 30.0, warn_msg="missing 'lifetime'.") - self.assertTrue(any("missing" in msg for msg in cm.output)) + self.assertIn("missing 'lifetime'.", " ".join(cm.output)) def test__present_key_with_warn_msg__no_warning(self) -> None: - # Arrange config: dict = {"lifetime": 25.0} - # Act — no warning should be emitted with self.assertNoLogs("simulator_worker", level=logging.WARNING): result = _parse_float_config( config, "lifetime", 30.0, warn_msg="missing 'lifetime'." ) - # Assert self.assertEqual(result, 25.0) class TestHelloWorld(unittest.TestCase): - def test__hello_world(self) -> None: - print("Hello world!") - def test__add_datetime_index__happy_path(self) -> None: - # Arrange df = pandas.DataFrame() start_time = datetime.datetime.now() end_time = start_time + datetime.timedelta(hours=1) timestep = datetime.timedelta(minutes=30) - # Act result_df = add_datetime_index( df, start_time, end_time, math.floor(timestep.total_seconds()) ) - # Assert self.assertEqual(len(result_df), 2) diff --git a/unit_test/test_kpi_integration.py b/unit_test/test_kpi_integration.py index a5e1ccf..123fa2d 100644 --- a/unit_test/test_kpi_integration.py +++ b/unit_test/test_kpi_integration.py @@ -27,6 +27,9 @@ simulator_worker_task = None # type: ignore[assignment, misc] pyesdl_from_string = None # type: ignore[assignment, misc] +from kpicalculator import DEFAULT_DISCOUNT_RATE_PERCENT, DEFAULT_SYSTEM_LIFETIME_YEARS # noqa: E402 +from simulator_worker.utils import _parse_float_config # noqa: E402 + def _run_simulator(workflow_config: ProtobufDict) -> tuple: test_esdl_path = Path(__file__).parent.parent / "testdata" / "test_ates.esdl" @@ -64,14 +67,6 @@ def _get_kpi_by_name(config: ProtobufDict) -> dict: return {kpi.name: kpi for kpi in kpi_list} -def _run_simulator_skip_on_value_error(test_case: unittest.TestCase, config: ProtobufDict) -> None: - """Run the simulator, skipping the test if a ValueError is raised by the simulation engine.""" - try: - _run_simulator(config) - except ValueError as e: - test_case.skipTest(f"Simulation raised ValueError unrelated to KPI warnings: {e}") - - @pytest.mark.skipif(not SIMULATOR_AVAILABLE, reason="omotes_simulator_core not installed") class TestKPIOutputEsdlStructureAndCostValues(unittest.TestCase): """Output ESDL contains a valid area with KPIs attached, and cost values match test_ates.esdl. @@ -92,56 +87,41 @@ def setUpClass(cls) -> None: cls.kpi_by_name = ( {kpi.name: kpi for kpi in area.KPIs.kpi} if area.KPIs is not None else {} ) - except Exception as e: - raise unittest.SkipTest(f"Simulator unavailable: {e}") from e - - def test__output_esdl_is_not_none(self) -> None: - # Act - instances = self.energy_system.instance + except RuntimeError as e: + raise unittest.SkipTest(f"Simulator infrastructure unavailable: {e}") from e - # Assert - self.assertTrue(instances, "Output ESDL must have at least one instance") - - def test__output_esdl_has_instance_with_area(self) -> None: - # Act - area = self.energy_system.instance[0].area - - # Assert - self.assertIsNotNone(area, "instance[0] must have an area") + def test__output_esdl_has_area_with_kpis_container(self) -> None: + self.assertIsNotNone( + self.energy_system.instance[0].area.KPIs, + "instance[0].area must have a KPIs container", + ) def test__kpis_attached_to_main_area(self) -> None: - # Arrange main_area = self.energy_system.instance[0].area - - # Act - kpi_list = list(main_area.KPIs.kpi) - - # Assert 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") def test__all_kpis_have_names(self) -> None: - # Arrange kpi_list = list(self.energy_system.instance[0].area.KPIs.kpi) - - # Act / Assert + self.assertGreater(len(kpi_list), 0, "KPI list must not be empty") for kpi in kpi_list: self.assertIsInstance(kpi.name, str, f"KPI {kpi} name must be a string") self.assertTrue(kpi.name, f"KPI {kpi} must have a non-empty name") def test__cost_breakdown_kpi_is_present(self) -> None: - # Act cost_kpi_present = "High level cost breakdown [EUR]" in self.kpi_by_name - - # Assert self.assertTrue(cost_kpi_present, "Cost breakdown KPI missing from output") def test__capex_matches_investment_costs(self) -> None: - # Arrange + self.assertIn( + "High level cost breakdown [EUR]", + self.kpi_by_name, + f"Cost breakdown KPI missing; got {list(self.kpi_by_name)}", + ) cost_kpi = self.kpi_by_name["High level cost breakdown [EUR]"] cost_items = {item.label: item.value for item in cost_kpi.distribution.stringItem} - # Act / Assert self.assertIn("CAPEX (total)", cost_items, f"CAPEX key missing; got {cost_items}") self.assertAlmostEqual( cost_items["CAPEX (total)"], @@ -151,11 +131,14 @@ def test__capex_matches_investment_costs(self) -> None: ) def test__opex_matches_fixed_maintenance_costs(self) -> None: - # Arrange + self.assertIn( + "High level cost breakdown [EUR]", + self.kpi_by_name, + f"Cost breakdown KPI missing; got {list(self.kpi_by_name)}", + ) cost_kpi = self.kpi_by_name["High level cost breakdown [EUR]"] cost_items = {item.label: item.value for item in cost_kpi.distribution.stringItem} - # Act / Assert self.assertIn("OPEX (yearly)", cost_items, f"OPEX key missing; got {cost_items}") self.assertAlmostEqual( cost_items["OPEX (yearly)"], @@ -177,15 +160,15 @@ class TestAllKPICategories(unittest.TestCase): @classmethod def setUpClass(cls) -> None: + debug_dir = tempfile.mkdtemp(prefix="debug_esdl_") + cls.addClassCleanup(shutil.rmtree, debug_dir, True) config = _default_config() config["debug_esdl"] = True - debug_dir = tempfile.mkdtemp() config["debug_esdl_dir"] = debug_dir - cls.addClassCleanup(shutil.rmtree, debug_dir) try: cls.kpi_by_name = _get_kpi_by_name(config) - except Exception as e: - raise unittest.SkipTest(f"Simulator unavailable: {e}") from e + except RuntimeError as e: + raise unittest.SkipTest(f"Simulator infrastructure unavailable: {e}") from e def test__net_present_value_kpi_is_present(self) -> None: self.assertIn("Net Present Value [EUR]", self.kpi_by_name) @@ -194,33 +177,20 @@ def test__energy_breakdown_kpi_is_present(self) -> None: self.assertIn("Energy breakdown [Wh]", self.kpi_by_name) -@pytest.mark.skipif(not SIMULATOR_AVAILABLE, reason="omotes_simulator_core not installed") class TestKPIDefaultWarnings(unittest.TestCase): - """Warnings are emitted when KPI config keys are absent from workflow_config.""" - - def test__missing_system_lifetime__warns(self) -> None: - # Arrange - config = _default_config() - del config["system_lifetime"] - - # Act / Assert - with self.assertLogs("simulator_worker", level=logging.WARNING) as cm: - _run_simulator_skip_on_value_error(self, config) - - self.assertTrue( - any("system_lifetime" in msg for msg in cm.output), - f"Expected 'system_lifetime' warning; got: {cm.output}", - ) - - def test__missing_discount_rate__warns(self) -> None: - # Arrange — discount_rate is not in the default config, so the warning fires - config = _default_config() - - # Act / Assert - with self.assertLogs("simulator_worker", level=logging.WARNING) as cm: - _run_simulator_skip_on_value_error(self, config) - - self.assertTrue( - any("discount_rate" in msg for msg in cm.output), - f"Expected 'discount_rate' warning; got: {cm.output}", - ) + """_parse_float_config emits a warning when a KPI config key is absent.""" + + def test__missing_kpi_config_key__warns(self) -> None: + cases = [ + ("system_lifetime", DEFAULT_SYSTEM_LIFETIME_YEARS), + ("discount_rate", DEFAULT_DISCOUNT_RATE_PERCENT), + ] + for key, default in cases: + with self.subTest(key=key): + sentinel = f"missing-{key}-sentinel" + + with self.assertLogs("simulator_worker", level=logging.WARNING) as cm: + result = _parse_float_config({}, key, default, warn_msg=sentinel) + + self.assertEqual(result, default) + self.assertIn(sentinel, " ".join(cm.output))