import logging
import numpy as np
from exerpy.components.component import Component, component_registry
[docs]
@component_registry
class Condenser(Component):
r"""
Class for exergy and exergoeconomic analysis of condensers (only dissipative).
This class performs exergy and exergoeconomic analysis calculations for condenser components,
accounting for two inlet and two outlet streams. This class should be used only for dissipative
condensers. For non-dissipative condensers, use components that are modeled in ExerPy using the
`HeatExchanger` class.
Attributes
----------
E_F : float
Exergy fuel of the component :math:`\dot{E}_\mathrm{F}` in :math:`\mathrm{W}`.
E_D : float
Exergy destruction of the component :math:`\dot{E}_\mathrm{D}` in :math:`\mathrm{W}`.
E_L : float
Exergy loss of the component :math:`\dot{E}_\mathrm{L}` in :math:`\mathrm{W}`.
inl : dict
Dictionary containing inlet stream data with mass flows and specific exergies.
outl : dict
Dictionary containing outlet stream data with mass flows and specific exergies.
Z_costs : float
Investment cost rate of the component in currency/h.
C_F : float
Cost of fuel stream :math:`\dot{C}_F` in currency/h.
C_D : float
Cost of exergy destruction :math:`\dot{C}_D` in currency/h.
c_F : float
Specific cost of fuel stream (currency per unit exergy).
f : float
Exergoeconomic factor, :math:`\dot{Z}/(\dot{Z} + \dot{C}_D)`.
Ex_C_col : dict
Custom cost coefficients collection passed via `kwargs`.
"""
def __init__(self, **kwargs):
r"""
Initialize the condenser component.
Parameters
----------
**kwargs : dict
Arbitrary keyword arguments. Recognized keys:
- Ex_C_col (dict): custom cost coefficients, default {}
- Z_costs (float): investment cost rate in currency/h, default 0.0
"""
super().__init__(**kwargs)
def _temperature_case(self, T0):
"""Classify the condenser temperature configuration.
Uses a tolerance of 0.01 K to handle floating-point edge cases
where stream temperatures are numerically very close to T0.
Parameters
----------
T0 : float
Ambient temperature (K).
Returns
-------
int
Case number (1-7).
"""
tol = 0.01 # K
T0_in = self.inl[0]["T"]
T1_in = self.inl[1]["T"]
T0_out = self.outl[0]["T"]
T1_out = self.outl[1]["T"]
all_T = [T0_in, T1_in, T0_out, T1_out]
def above(T):
return T0 + tol < T
def at_or_below(T):
return T0 + tol >= T
# Case 1: All streams at or above T0
if all(T0 - tol <= T for T in all_T):
return 1
# Case 2: All streams at or below T0
if all(at_or_below(T) for T in all_T):
return 2
# Case 3: Both streams crossing T0
if above(T0_in) and above(T1_out) and at_or_below(T0_out) and at_or_below(T1_in):
return 3
# Case 4: Only hot inlet above T0
if above(T0_in) and at_or_below(T1_in) and at_or_below(T0_out) and at_or_below(T1_out):
return 4
# Case 5: Only cold inlet below T0
if above(T0_in) and at_or_below(T1_in) and above(T0_out) and above(T1_out):
return 5
# Case 6: Hot stream always above, cold stream always below (dissipative)
if above(T0_in) and at_or_below(T1_in) and above(T0_out) and at_or_below(T1_out):
return 6
# Case 7: Unrecognized
return 7
[docs]
def calc_exergy_balance(self, T0: float, p0: float, split_physical_exergy) -> None:
r"""
Compute the exergy balance of the condenser.
In order to distinguish between the exergetic destruction because of heat transfer
and the exergetic loss (coldf stream leaving the system) the exergetic losses and
destruction are calculated as follows:
.. math::
\dot{E}_{\mathrm{L}}
= \dot{E}^{\mathrm{PH}}_{\mathrm{out},2}
- \dot{E}^{\mathrm{PH}}_{\mathrm{in},2}
.. math::
\dot{E}_{\mathrm{D}}
= \dot{E}^{\mathrm{PH}}_{\mathrm{in},1}
- \dot{E}^{\mathrm{PH}}_{\mathrm{out},1}
- \dot{E}_{\mathrm{L}}
However, these value can only be accessed via the attributes `E_L` and `E_D` of the component.
In the table of final results of the exergy analysis of the system, the exergy destruction of
the condenser is counted as the exergy loss and the exergetic destruction due to heat transfer.
Parameters
----------
T0 : float
Ambient temperature (K).
p0 : float
Ambient pressure (Pa).
split_physical_exergy : bool
Whether to split thermal and mechanical exergy.
Raises
------
ValueError
If required inlets or outlets are missing.
"""
# Ensure that the component has both inlet and outlet streams
if len(self.inl) < 2 or len(self.outl) < 2:
raise ValueError("Condenser requires two inlets and two outlets.")
# Calculate exergy loss (E_L) for the heat transfer process
self.E_L = self.outl[1]["m"] * (self.outl[1]["e_PH"] - self.inl[1]["e_PH"])
# Calculate exergy destruction (E_D)
self.E_D = self.outl[0]["m"] * (self.inl[0]["e_PH"] - self.outl[0]["e_PH"]) - self.E_L
# Exergy fuel and product are not typically defined for a condenser
self.E_F = np.nan
self.E_P = np.nan
self.epsilon = np.nan
# Log the results
logging.info(
f"Exergy balance of Condenser {self.name} calculated: "
f"E_P={self.E_P:.2f}, E_F={self.E_F:.2f}, E_D={self.E_D:.2f}, "
f"Efficiency={self.epsilon:.2%}"
)
[docs]
def aux_eqs(self, A, b, counter, T0, equations, chemical_exergy_enabled):
r"""
Add auxiliary cost equations for the condenser.
This method appends rows to the cost matrix to enforce:
Case 1: All streams above ambient temperature
F rule for thermal exergy of the hot stream:
.. math::
-\frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{out},1}}\,\dot{C}^{\mathrm{T}}_{\mathrm{out},1}
+ \frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{in},1}}\,\dot{C}^{\mathrm{T}}_{\mathrm{in},1}
= 0
Case 2: All streams below or equal to ambient temperature
F rule for thermal exergy of the cold stream:
.. math::
-\frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{out},2}}\,\dot{C}^{\mathrm{T}}_{\mathrm{out},2}
+ \frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{in},2}}\,\dot{C}^{\mathrm{T}}_{\mathrm{in},2}
= 0
Case 3: Both stream crossing ambient temperature
P rule for thermal exergy of both outlets:
.. math::
-\frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{out},1}}\,\dot{C}^{\mathrm{T}}_{\mathrm{out},1}
+ \frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{out},2}}\,\dot{C}^{\mathrm{T}}_{\mathrm{out},2}
= 0
Case 4: Only the hot inlet above ambient temperature
F rule for thermal exergy of the cold stream:
.. math::
-\frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{out},2}}\,\dot{C}^{\mathrm{T}}_{\mathrm{out},2}
+ \frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{in},2}}\,\dot{C}^{\mathrm{T}}_{\mathrm{in},2}
= 0
Case 5: Only the cold inlet below ambient temperature
F rule for thermal exergy of the hot stream:
.. math::
-\frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{out},1}}\,\dot{C}^{\mathrm{T}}_{\mathrm{out},1}
+ \frac{1}{\dot{E}^{\mathrm{T}}_{\mathrm{in},1}}\,\dot{C}^{\mathrm{T}}_{\mathrm{in},1}
= 0
Case 6: Hot stream always above and cold stream always below ambiente temperature (dissipative case):
The dissipative is not handeld here!
For all cases, the mechanical and chemical exergy costs are handled as follows:
F rule for mechanical exergy of the hot stream:
.. math::
-\frac{1}{\dot{E}^{\mathrm{M}}_{\mathrm{out},i}}\,\dot{C}^{\mathrm{M}}_{\mathrm{out},i}
+ \frac{1}{\dot{E}^{\mathrm{M}}_{\mathrm{in},i}}\,\dot{C}^{\mathrm{M}}_{\mathrm{in},i}
= 0
F rule for chemical exergy on hot branch:
.. math::
-\frac{1}{\dot{E}^{\mathrm{CH}}_{\mathrm{out},i}}\,\dot{C}^{\mathrm{CH}}_{\mathrm{out},i}
+ \frac{1}{\dot{E}^{\mathrm{CH}}_{\mathrm{in},i}}\,\dot{C}^{\mathrm{CH}}_{\mathrm{in},i}
= 0
Parameters
----------
A : numpy.ndarray
Current cost matrix.
b : numpy.ndarray
Current RHS vector.
counter : int
Starting row index for auxiliary equations.
T0 : float
Ambient temperature (K).
equations : dict or list
Structure for equation labels.
chemical_exergy_enabled : bool
Must be True to include chemical exergy mixing.
Returns
-------
A : numpy.ndarray
Updated cost matrix.
b : numpy.ndarray
Updated RHS vector.
counter : int
Updated row index after adding equations.
equations : dict or list
Updated labels.
Raises
------
ValueError
If required cost variable indices are missing.
"""
# Equality equation for mechanical and chemical exergy costs.
def set_equal(A, row, in_item, out_item, var):
if in_item["e_" + var] != 0 and out_item["e_" + var] != 0:
A[row, in_item["CostVar_index"][var]] = 1 / in_item["E_" + var]
A[row, out_item["CostVar_index"][var]] = -1 / out_item["E_" + var]
elif in_item["e_" + var] == 0 and out_item["e_" + var] != 0:
A[row, in_item["CostVar_index"][var]] = 1
elif in_item["e_" + var] != 0 and out_item["e_" + var] == 0:
A[row, out_item["CostVar_index"][var]] = 1
else:
A[row, in_item["CostVar_index"][var]] = 1
A[row, out_item["CostVar_index"][var]] = -1
# Thermal fuel rule on hot stream: c_T_in0 = c_T_out0.
def set_thermal_f_hot(A, row):
if self.inl[0]["e_T"] != 0 and self.outl[0]["e_T"] != 0:
A[row, self.inl[0]["CostVar_index"]["T"]] = 1 / self.inl[0]["E_T"]
A[row, self.outl[0]["CostVar_index"]["T"]] = -1 / self.outl[0]["E_T"]
elif self.inl[0]["e_T"] == 0 and self.outl[0]["e_T"] != 0:
A[row, self.inl[0]["CostVar_index"]["T"]] = 1
elif self.inl[0]["e_T"] != 0 and self.outl[0]["e_T"] == 0:
A[row, self.outl[0]["CostVar_index"]["T"]] = 1
else:
A[row, self.inl[0]["CostVar_index"]["T"]] = 1
A[row, self.outl[0]["CostVar_index"]["T"]] = -1
# Thermal fuel rule on cold stream: c_T_in1 = c_T_out1.
def set_thermal_f_cold(A, row):
if self.inl[1]["e_T"] != 0 and self.outl[1]["e_T"] != 0:
A[row, self.inl[1]["CostVar_index"]["T"]] = 1 / self.inl[1]["E_T"]
A[row, self.outl[1]["CostVar_index"]["T"]] = -1 / self.outl[1]["E_T"]
elif self.inl[1]["e_T"] == 0 and self.outl[1]["e_T"] != 0:
A[row, self.inl[1]["CostVar_index"]["T"]] = 1
elif self.inl[1]["e_T"] != 0 and self.outl[1]["e_T"] == 0:
A[row, self.outl[1]["CostVar_index"]["T"]] = 1
else:
A[row, self.inl[1]["CostVar_index"]["T"]] = 1
A[row, self.outl[1]["CostVar_index"]["T"]] = -1
# Thermal product rule: Equate the two outlet thermal costs (c_T_out0 = c_T_out1).
def set_thermal_p_rule(A, row):
if self.outl[0]["e_T"] != 0 and self.outl[1]["e_T"] != 0:
A[row, self.outl[0]["CostVar_index"]["T"]] = 1 / self.outl[0]["E_T"]
A[row, self.outl[1]["CostVar_index"]["T"]] = -1 / self.outl[1]["E_T"]
elif self.outl[0]["e_T"] == 0 and self.outl[1]["e_T"] != 0:
A[row, self.outl[0]["CostVar_index"]["T"]] = 1
elif self.outl[0]["e_T"] != 0 and self.outl[1]["e_T"] == 0:
A[row, self.outl[1]["CostVar_index"]["T"]] = 1
else:
A[row, self.outl[0]["CostVar_index"]["T"]] = 1
A[row, self.outl[1]["CostVar_index"]["T"]] = -1
# Determine the thermal case based on temperatures.
case = self._temperature_case(T0)
# Case 1: All temperatures > T0.
if case == 1:
set_thermal_f_hot(A, counter + 0)
equations[counter] = {
"kind": "aux_f_rule_hot",
"objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
"property": "c_T",
}
# Case 2: All temperatures <= T0.
elif case == 2:
set_thermal_f_cold(A, counter + 0)
equations[counter] = {
"kind": "aux_f_rule_cold",
"objects": [self.name, self.inl[1]["name"], self.outl[1]["name"]],
"property": "c_T",
}
logging.warning(
f"All temperatures in {self.name} are below ambient temperature. "
"This is not a typical case for a dissipative condenser."
)
# Case 3: Both stream crossing T0 (hot inlet and cold outlet > T0, hot outlet and cold inlet <= T0)
elif case == 3:
set_thermal_p_rule(A, counter + 0)
equations[counter] = {
"kind": "aux_p_rule",
"objects": [self.name, self.outl[0]["name"], self.outl[1]["name"]],
"property": "c_T",
}
logging.warning(
f"Hot inlet and cold outlet in {self.name} are above ambient temperature, "
"while hot outlet and cold inlet are below. This is not a typical case for a dissipative condenser. "
"The exergoeconomic analysis is counting the outlets as products."
)
# Case 4: Only hot inlet > T0
elif case == 4:
set_thermal_f_cold(A, counter + 0)
equations[counter] = {
"kind": "aux_f_rule_cold",
"objects": [self.name, self.inl[1]["name"], self.outl[1]["name"]],
"property": "c_T",
}
logging.warning(
f"Cold inlet in {self.name} is below ambient temperature. "
"This is not a typical case for a dissipative condenser."
)
# Case 5: Only cold inlet <= T0
elif case == 5:
set_thermal_f_hot(A, counter + 0)
equations[counter] = {
"kind": "aux_f_rule_hot",
"objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
"property": "c_T",
}
logging.warning(
f"Cold inlet in {self.name} is below ambient temperature. "
"This is not a typical case for a dissipative condenser."
)
# Case 6: hot stream always above T0, cold stream always below T0
elif case == 6:
logging.info(
f"Condenser {self.name}: dissipative temperature configuration detected in aux_eqs. "
"Skipping auxiliary equations (handled by dis_eqs)."
)
return A, b, counter, equations
# Case 7: Default case.
else:
set_thermal_f_hot(A, counter + 0)
equations[counter] = {
"kind": "aux_f_rule_hot",
"objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
"property": "c_T",
}
# Mechanical equations (always added)
set_equal(A, counter + 1, self.inl[0], self.outl[0], "M")
set_equal(A, counter + 2, self.inl[1], self.outl[1], "M")
equations[counter + 1] = {
"kind": "aux_equality",
"objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
"property": "c_M",
}
equations[counter + 2] = {
"kind": "aux_equality",
"objects": [self.name, self.inl[1]["name"], self.outl[1]["name"]],
"property": "c_M",
}
# Only add chemical auxiliary equations if chemical exergy is enabled.
if chemical_exergy_enabled:
set_equal(A, counter + 3, self.inl[0], self.outl[0], "CH")
set_equal(A, counter + 4, self.inl[1], self.outl[1], "CH")
equations[counter + 3] = {
"kind": "aux_equality",
"objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
"property": "c_CH",
}
equations[counter + 4] = {
"kind": "aux_equality",
"objects": [self.name, self.inl[1]["name"], self.outl[1]["name"]],
"property": "c_CH",
}
num_aux_eqs = 5
else:
# Skip chemical auxiliary equations.
num_aux_eqs = 3
for i in range(num_aux_eqs):
b[counter + i] = 0
return A, b, counter + num_aux_eqs, equations
[docs]
def dis_eqs(self, A, b, counter, T0, equations, chemical_exergy_enabled=False, all_components=None):
r"""
Construct cost equations for a dissipative Condenser.
Distributes the condenser's extra cost difference (C_diff) to all other productive
components (non-dissipative and non-CycleCloser) in proportion to their exergy destruction (E_D)
and adds an overall cost balance row that enforces:
.. math::
(\dot C_{\mathrm{in},1} - \dot C_{\mathrm{out},1})
+ (\dot C_{\mathrm{in},2} - \dot C_{\mathrm{out},2})
- \dot C_{\mathrm{diff}}
= -\,\dot Z_{\mathrm{costs}}
The equality equations enforce that specific costs are equal between inlet and outlet
for each exergy component (thermal, mechanical, chemical) on both the hot and cold streams.
Parameters
----------
A : numpy.ndarray
The current cost matrix.
b : numpy.ndarray
The current right-hand-side vector.
counter : int
The current row index in the cost matrix.
T0 : float
Ambient temperature (K).
equations : dict
Dictionary mapping row indices to equation labels.
chemical_exergy_enabled : bool, optional
Flag indicating whether chemical exergy is considered.
all_components : list, optional
Global list of all component objects; if not provided, defaults to [].
Returns
-------
tuple
Updated (A, b, counter, equations).
"""
def set_equal_dis(A, row, in_item, out_item, var):
if in_item.get("E_" + var, 0) and out_item.get("E_" + var, 0):
A[row, in_item["CostVar_index"][var]] = 1 / in_item["E_" + var]
A[row, out_item["CostVar_index"][var]] = -1 / out_item["E_" + var]
else:
A[row, in_item["CostVar_index"][var]] = 1
A[row, out_item["CostVar_index"][var]] = -1
# --- Thermal equality for hot stream ---
set_equal_dis(A, counter, self.inl[0], self.outl[0], "T")
b[counter] = 0
equations[counter] = {
"kind": "dis_equality",
"objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
"property": "c_T",
}
counter += 1
# --- Thermal equality for cold stream ---
set_equal_dis(A, counter, self.inl[1], self.outl[1], "T")
b[counter] = 0
equations[counter] = {
"kind": "dis_equality",
"objects": [self.name, self.inl[1]["name"], self.outl[1]["name"]],
"property": "c_T",
}
counter += 1
# --- Mechanical equality for hot stream ---
set_equal_dis(A, counter, self.inl[0], self.outl[0], "M")
b[counter] = 0
equations[counter] = {
"kind": "dis_equality",
"objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
"property": "c_M",
}
counter += 1
# --- Mechanical equality for cold stream ---
set_equal_dis(A, counter, self.inl[1], self.outl[1], "M")
b[counter] = 0
equations[counter] = {
"kind": "dis_equality",
"objects": [self.name, self.inl[1]["name"], self.outl[1]["name"]],
"property": "c_M",
}
counter += 1
# --- Chemical equality (if enabled) ---
if chemical_exergy_enabled:
set_equal_dis(A, counter, self.inl[0], self.outl[0], "CH")
b[counter] = 0
equations[counter] = {
"kind": "dis_equality",
"objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
"property": "c_CH",
}
counter += 1
set_equal_dis(A, counter, self.inl[1], self.outl[1], "CH")
b[counter] = 0
equations[counter] = {
"kind": "dis_equality",
"objects": [self.name, self.inl[1]["name"], self.outl[1]["name"]],
"property": "c_CH",
}
counter += 1
# --- Distribution of dissipative cost difference to other components based on E_D ---
if all_components is None:
all_components = []
serving = [
comp
for comp in all_components
if comp is not self
and hasattr(comp, "exergy_cost_line")
and not comp.__class__.__name__.endswith("PowerBus")
and hasattr(comp, "E_D")
and not np.isnan(comp.E_D)
]
total_E_D = sum(comp.E_D for comp in serving)
diss_col = self.inl[0]["CostVar_index"].get("dissipative")
if diss_col is None:
logging.error(f"No 'dissipative' column allocated for {self.name}.")
else:
if total_E_D == 0:
if len(serving) > 0:
for comp in serving:
A[comp.exergy_cost_line, diss_col] = 1 / len(serving)
else:
logging.warning(f"No serving components found for dissipative component {self.name}")
else:
for comp in serving:
weight = getattr(comp, "E_D", 0) / total_E_D
comp.serving_weight = weight
A[comp.exergy_cost_line, diss_col] = weight
# --- Overall cost balance row ---
# (C_in_hot - C_out_hot) + (C_in_cold - C_out_cold) - C_diff = -Z_costs
A[counter, self.inl[0]["CostVar_index"]["T"]] = 1
A[counter, self.outl[0]["CostVar_index"]["T"]] = -1
A[counter, self.inl[0]["CostVar_index"]["M"]] = 1
A[counter, self.outl[0]["CostVar_index"]["M"]] = -1
A[counter, self.inl[1]["CostVar_index"]["T"]] = 1
A[counter, self.outl[1]["CostVar_index"]["T"]] = -1
A[counter, self.inl[1]["CostVar_index"]["M"]] = 1
A[counter, self.outl[1]["CostVar_index"]["M"]] = -1
if chemical_exergy_enabled:
A[counter, self.inl[0]["CostVar_index"]["CH"]] = 1
A[counter, self.outl[0]["CostVar_index"]["CH"]] = -1
A[counter, self.inl[1]["CostVar_index"]["CH"]] = 1
A[counter, self.outl[1]["CostVar_index"]["CH"]] = -1
A[counter, self.inl[0]["CostVar_index"]["dissipative"]] = -1
b[counter] = -self.Z_costs
equations[counter] = {"kind": "dis_balance", "objects": [self.name], "property": "dissipative_cost_balance"}
counter += 1
return A, b, counter, equations
[docs]
def exergoeconomic_balance(self, T0, chemical_exergy_enabled=False):
r"""
Perform exergoeconomic cost balance for the condenser.
The condenser is always dissipative (no identifiable product), so:
.. math::
\dot{C}_P = \mathrm{NaN}
.. math::
\dot{C}_F = \bigl(\dot{C}^{\mathrm{PH}}_{\mathrm{in},1}
- \dot{C}^{\mathrm{PH}}_{\mathrm{out},1}\bigr)
+ \bigl(\dot{C}^{\mathrm{PH}}_{\mathrm{in},2}
- \dot{C}^{\mathrm{PH}}_{\mathrm{out},2}\bigr)
Since :math:`\dot{E}_F` and :math:`\dot{E}_P` are not defined for a dissipative condenser,
the specific costs :math:`c_F`, :math:`c_P`, the cost of exergy destruction :math:`\dot{C}_D`,
the relative cost difference :math:`r`, and the exergoeconomic factor :math:`f` are all NaN.
Parameters
----------
T0 : float
Ambient temperature (K).
chemical_exergy_enabled : bool, optional
If True, chemical exergy is considered in the calculations.
"""
# Condenser is always dissipative: no identifiable product
self.C_P = np.nan
self.C_F = self.inl[0]["C_PH"] - self.outl[0]["C_PH"] + (self.inl[1]["C_PH"] - self.outl[1]["C_PH"])
self.c_F = np.nan
self.c_P = np.nan
self.C_D = np.nan
self.r = np.nan
self.f = np.nan