From a0f18873f9a138eaa350a1b3117e3b594a804e0e Mon Sep 17 00:00:00 2001 From: avdudchenko <33663878+avdudchenko@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:12:01 -0400 Subject: [PATCH 1/3] fixed capex/oepx splitting --- .../tests/test_watertap_costing.py | 59 +++++++++++++++++++ src/psPlotKit/data_manager/ps_costing.py | 33 ++++++++++- src/psPlotKit/data_manager/ps_data_manager.py | 1 + src/psPlotKit/data_manager/ps_expression.py | 35 +++++++++-- 4 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/psPlotKit/data_manager/costing_packages/tests/test_watertap_costing.py b/src/psPlotKit/data_manager/costing_packages/tests/test_watertap_costing.py index d72b49d..bcdf43f 100644 --- a/src/psPlotKit/data_manager/costing_packages/tests/test_watertap_costing.py +++ b/src/psPlotKit/data_manager/costing_packages/tests/test_watertap_costing.py @@ -105,6 +105,65 @@ def test_with_stages(): ) +def test_with_separate_capex_opex(): + dm = PsDataManager(_h5_file) + stage_1 = PsCostingGroup("stage 1") + stage_1.add_unit( + "stage[1].RO", + capex_keys="capital_cost", + fixed_opex_keys="fixed_operating_cost", + ) + stage_1.add_unit( + "stage[1].pump", + capex_keys="capital_cost", + ) + stage_2 = PsCostingGroup("stage 2") + stage_2.add_unit( + "stage[2].RO", + capex_keys="capital_cost", + fixed_opex_keys="fixed_operating_cost", + ) + stage_2.add_unit( + "stage[2].pump", + capex_keys="capital_cost", + ) + erd = PsCostingGroup("ERD") + erd.add_unit( + "ERD", + capex_keys="capital_cost", + ) + power = PsCostingGroup("Power") + power.add_unit( + "stage[1].pump", + flow_keys={"electricity": "control_volume.work"}, + ) + power.add_unit( + "stage[2].pump", + flow_keys={"electricity": "control_volume.work"}, + ) + power.add_unit( + "ERD", + flow_keys={"electricity": "control_volume.work"}, + ) + pkg = WaterTapCostingPackage() + pkg.register_product_flow() + cm = PsCostingManager(dm, pkg, [stage_1, stage_2, erd, power]) + cm.build() + dm.display() + # Verify the total key was created and matches the reference in ALL directories + for dkey in dm.directory_keys: + total_lcow = dm[(*dkey, ("costing", "total", "LCOW"))].data + ref_lcow = dm[(*dkey, ("costing", "validation", "LCOW"))].data + mask = np.isfinite(total_lcow) & np.isfinite(ref_lcow) + assert np.any(mask), "No finite values in directory {}".format(dkey) + np.testing.assert_allclose( + np.asarray(total_lcow)[mask], + np.asarray(ref_lcow)[mask], + rtol=1e-4, + err_msg="LCOW mismatch in directory {}".format(dkey), + ) + + def test_with_custom_expressions(): dm = PsDataManager(_h5_file) dm.register_data_key( diff --git a/src/psPlotKit/data_manager/ps_costing.py b/src/psPlotKit/data_manager/ps_costing.py index f650c7a..631395d 100644 --- a/src/psPlotKit/data_manager/ps_costing.py +++ b/src/psPlotKit/data_manager/ps_costing.py @@ -53,9 +53,16 @@ import numpy as np +from psPlotKit.data_manager.ps_data import PsData from psPlotKit.data_manager.ps_expression import ExpressionNode from psPlotKit.util.logger import define_logger +# Sentinel key used by _GroupExpressionKeys to reference a zero-fill +# PsData instead of an inline const(0). This ensures that downstream +# expressions inherit the "zero_fill" data_type and trigger the +# unit-fallback logic in evaluate_expressions(). +_ZERO_SENTINEL_KEY = ("costing", "_zero_sentinel") + __author__ = "Alexander V. Dudchenko " _logger = define_logger(__name__, "PsCosting", level="INFO") @@ -128,7 +135,7 @@ def __getattr__(self, name): if name in alias_map: target = alias_map[name] if target is None: - return ExpressionNode._const_node(0) + return real_ek[_ZERO_SENTINEL_KEY] return real_ek[target] return getattr(real_ek, name) @@ -138,7 +145,7 @@ def __getitem__(self, key): if key in alias_map: target = alias_map[key] if target is None: - return ExpressionNode._const_node(0) + return real_ek[_ZERO_SENTINEL_KEY] return real_ek[target] return real_ek[key] @@ -512,6 +519,7 @@ def build(self, load_data=True, error_on_validation_failure=True): if load_data: self.data_manager.load_data(evaluate_expressions=False) + self._register_zero_sentinel() self._build_group_expressions() self._build_flow_expressions() self._build_per_group_flow_expressions() @@ -767,6 +775,27 @@ def _register_discovered_keys(self): # expression building # ------------------------------------------------------------------ + def _register_zero_sentinel(self): + """Register a zero-fill sentinel PsData in every directory. + + The sentinel is referenced by :class:`_GroupExpressionKeys` when + a group lacks a cost type (capex, fixed_opex, or flow_cost). + Using a real ``data_type="zero_fill"`` PsData rather than an + inline ``const(0)`` ensures that the ``has_zero_fill`` detection + in :meth:`PsDataManager.evaluate_expressions` triggers the + unit-fallback logic for downstream chained formulas. + """ + for dir_key in self.data_manager.directory_keys: + sentinel = PsData( + data_key=_ZERO_SENTINEL_KEY, + data_type="zero_fill", + data_array=np.array(0), + ) + self.data_manager.add_data(dir_key, _ZERO_SENTINEL_KEY, sentinel) + # Ensure the key is available in ExpressionKeys + if self.data_manager._expression_keys is not None: + self.data_manager._expression_keys.add_key(_ZERO_SENTINEL_KEY) + def _build_group_expressions(self): """Build per-group capex / fixed-opex and the two aggregate expressions ``("costing", "aggregate_capital_cost")`` and diff --git a/src/psPlotKit/data_manager/ps_data_manager.py b/src/psPlotKit/data_manager/ps_data_manager.py index 83a9246..ef67053 100644 --- a/src/psPlotKit/data_manager/ps_data_manager.py +++ b/src/psPlotKit/data_manager/ps_data_manager.py @@ -1215,3 +1215,4 @@ def evaluate_expressions(self): evaluated_count, return_key ) ) + self._registered_expressions = [] diff --git a/src/psPlotKit/data_manager/ps_expression.py b/src/psPlotKit/data_manager/ps_expression.py index 2f65546..bfd9622 100644 --- a/src/psPlotKit/data_manager/ps_expression.py +++ b/src/psPlotKit/data_manager/ps_expression.py @@ -215,11 +215,36 @@ def evaluate(self, data_dict): result = left_mag - right_mag return qs.Quantity(result, units) - if self.op == "+": - return left_val + right_val - elif self.op == "-": - return left_val - right_val - elif self.op == "*": + if self.op in ("+", "-"): + try: + if self.op == "+": + return left_val + right_val + else: + return left_val - right_val + except (ValueError, AssertionError): + # When both operands are Quantities with incompatible units + # but one is entirely zeros (e.g. from a const(0) proxy for + # a missing cost type), the zero side is negligible — adopt + # the non-zero operand's units. + if left_is_qty and right_is_qty: + left_mag = np.asarray(left_val.magnitude) + right_mag = np.asarray(right_val.magnitude) + left_is_zero = np.all( + np.isnan(left_mag) | (left_mag == 0) + ) + right_is_zero = np.all( + np.isnan(right_mag) | (right_mag == 0) + ) + if left_is_zero: + if self.op == "+": + return right_val.copy() + else: + return qs.Quantity(-right_mag, right_val.units) + if right_is_zero: + return left_val.copy() + raise + + if self.op == "*": return left_val * right_val elif self.op == "/": return left_val / right_val From 0ee3c191c955b1d7d5330b48e123bcc2b1145ad0 Mon Sep 17 00:00:00 2001 From: avdudchenko <33663878+avdudchenko@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:15:53 -0400 Subject: [PATCH 2/3] Update test_ps_costing.py --- src/psPlotKit/data_manager/tests/test_ps_costing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/psPlotKit/data_manager/tests/test_ps_costing.py b/src/psPlotKit/data_manager/tests/test_ps_costing.py index 492b92a..8cc3d8d 100644 --- a/src/psPlotKit/data_manager/tests/test_ps_costing.py +++ b/src/psPlotKit/data_manager/tests/test_ps_costing.py @@ -838,6 +838,7 @@ def test_per_group_formula_missing_capex_uses_zero(self): cm._group_flow_type_keys["Pumps"] = {"electricity": ["pump_elec"]} cm._build_group_expressions() + cm._register_zero_sentinel() cm._build_flow_expressions() cm._build_per_group_flow_expressions() # Skip _build_formula_expressions — the global formula would fail From 061de3d0ec425e3d23e7df4e9e5a6d1d9ac4d71a Mon Sep 17 00:00:00 2001 From: avdudchenko <33663878+avdudchenko@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:17:21 -0400 Subject: [PATCH 3/3] Update ps_expression.py --- src/psPlotKit/data_manager/ps_expression.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/psPlotKit/data_manager/ps_expression.py b/src/psPlotKit/data_manager/ps_expression.py index bfd9622..839bff8 100644 --- a/src/psPlotKit/data_manager/ps_expression.py +++ b/src/psPlotKit/data_manager/ps_expression.py @@ -229,12 +229,8 @@ def evaluate(self, data_dict): if left_is_qty and right_is_qty: left_mag = np.asarray(left_val.magnitude) right_mag = np.asarray(right_val.magnitude) - left_is_zero = np.all( - np.isnan(left_mag) | (left_mag == 0) - ) - right_is_zero = np.all( - np.isnan(right_mag) | (right_mag == 0) - ) + left_is_zero = np.all(np.isnan(left_mag) | (left_mag == 0)) + right_is_zero = np.all(np.isnan(right_mag) | (right_mag == 0)) if left_is_zero: if self.op == "+": return right_val.copy()