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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
33 changes: 31 additions & 2 deletions src/psPlotKit/data_manager/ps_costing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand All @@ -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]

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/psPlotKit/data_manager/ps_data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1215,3 +1215,4 @@ def evaluate_expressions(self):
evaluated_count, return_key
)
)
self._registered_expressions = []
31 changes: 26 additions & 5 deletions src/psPlotKit/data_manager/ps_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,32 @@ 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
Expand Down
1 change: 1 addition & 0 deletions src/psPlotKit/data_manager/tests/test_ps_costing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading