Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Comment thread
jarsarasty marked this conversation as resolved.
# via simulator-worker (pyproject.toml)
parameterized==0.9.0
# via simulator-worker (pyproject.toml)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 5 additions & 15 deletions unit_test/test_hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
147 changes: 55 additions & 92 deletions unit_test/test_kpi_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -65,90 +68,60 @@ def _get_kpi_by_name(config: ProtobufDict) -> dict:


@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())
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

# Assert
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

# Assert
self.assertIsNotNone(area, "instance[0] must have an area")
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 RuntimeError as e:
raise unittest.SkipTest(f"Simulator infrastructure unavailable: {e}") from e

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 (done in setUp)
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")


@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

# 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)"],
Expand All @@ -158,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)"],
Expand All @@ -184,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)
Expand All @@ -201,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(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(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))
Loading