"""Heat-exchanger component models, separate from fans and correlations."""
import math
import warnings
from collections.abc import Callable
import numpy as np
from scipy.optimize import root_scalar
from . import calc_util as cu
from .constants import c_a, rho_a
def calc_ground_hx_UA_from_capacity(rated_capacity: float, specific_UA: float = 0.18) -> float:
"""Default ground refrigerant/water HX rated UA [W/K] from capacity [W].
``UA_rated = specific_UA * rated_capacity`` is a TMHP reduced-order sizing
assumption, not a fitted refrigerant correlation. ``specific_UA`` has units
1/K. The default 0.18 represents approximate water flow 3 L/min per kW,
cooling HX duty 1.2 times rated load and a 4 K leaving-water approach.
For a phase-change HX, UA = Cw*log(1 + Q_HX/(Cw*approach_out)); scaling
flow and duty with capacity gives a proportional sizing rule. Its rounded
default coefficient must be calibrated when device data are available.
Reference for refrigerant-side BPHE heat-transfer behaviour only:
Longo, G.A. (2009), R410A condensation inside a commercial brazed plate
heat exchanger, Experimental Thermal and Fluid Science 33(2), 284-291.
DOI: 10.1016/j.expthermflusci.2008.09.004.
The paper reports mass-flux dependence in forced-convection condensation;
it does NOT propose UA = 0.18*capacity or determine this coefficient.
"""
if not all(math.isfinite(v) and v > 0 for v in (rated_capacity, specific_UA)):
raise ValueError("Rated capacity and ground specific UA must be positive and finite")
value = specific_UA * rated_capacity
if not math.isfinite(value):
raise ValueError("Calculated ground rated UA must be finite")
return value
def calc_UA_two_stream_scaled(
UA_rated: float,
m_dot_fluid: float,
m_dot_fluid_rated: float,
m_dot_ref: float,
m_dot_ref_rated: float,
fluid_fraction: float = 0.5,
refrigerant_fraction: float = 0.3,
constant_fraction: float = 0.2,
fluid_exponent: float = 0.8,
refrigerant_exponent: float = 0.8,
) -> float:
"""Scale rated UA through a series resistance network [W/K].
Fractions and exponents are modelling assumptions, not fitted universal
refrigerant correlations. All mass flows are positive; handle off states
separately. Rated flows reproduce UA_rated exactly.
"""
positive = (UA_rated, m_dot_fluid, m_dot_fluid_rated, m_dot_ref, m_dot_ref_rated)
fractions = (fluid_fraction, refrigerant_fraction, constant_fraction)
exponents = (fluid_exponent, refrigerant_exponent)
if not all(math.isfinite(v) and v > 0 for v in positive):
raise ValueError("UA and mass flows must be finite and positive")
if not all(math.isfinite(v) and v >= 0 for v in (*fractions, *exponents)):
raise ValueError("Resistance fractions and exponents must be finite and nonnegative")
if not math.isclose(sum(fractions), 1.0, rel_tol=0, abs_tol=1e-10):
raise ValueError("Resistance fractions must sum to 1")
resistance_ratio = (
fluid_fraction * (m_dot_fluid / m_dot_fluid_rated) ** (-fluid_exponent)
+ refrigerant_fraction * (m_dot_ref / m_dot_ref_rated) ** (-refrigerant_exponent)
+ constant_fraction
)
return float(UA_rated / resistance_ratio)
def solve_secondary_flow_for_target_heat(
Q_target: float,
UA: float | Callable[[float], float],
cp: float,
T_fluid_in: float,
T_ref_sat: float,
m_dot_min: float,
m_dot_max: float,
) -> dict:
"""Bracket a secondary-fluid mass flow [kg/s] for a positive duty [W].
A callable UA receives the candidate mass flow. Feasibility requires a
continuous capacity curve with a root inside the supplied flow interval.
"""
if (
not all(math.isfinite(v) for v in (Q_target, m_dot_min, m_dot_max))
or Q_target < 0
or not 0 < m_dot_min < m_dot_max
):
raise ValueError("Invalid target heat or secondary-flow bounds")
def residual(m_dot: float) -> float:
conductance = UA(m_dot) if callable(UA) else UA
return calc_phase_change_hx_capacity(conductance, m_dot, cp, T_fluid_in, T_ref_sat) - Q_target
if Q_target == 0:
return {"m_dot": 0.0, "Q_HX": 0.0, "converged": True}
low, high = residual(m_dot_min), residual(m_dot_max)
if low * high > 0:
return {"m_dot": math.nan, "Q_HX": math.nan, "converged": False}
root = root_scalar(residual, bracket=(m_dot_min, m_dot_max), method="brentq", xtol=1e-12)
return {"m_dot": root.root, "Q_HX": residual(root.root) + Q_target, "converged": bool(root.converged)}
def calc_phase_change_hx_effectiveness(UA: float, m_dot: float, cp: float) -> float:
"""Effectiveness of a constant-temperature refrigerant / single-phase HX.
UA [W/K], m_dot [kg/s], cp [J/(kg K)]. Zero UA or flow gives zero duty.
"""
if not all(math.isfinite(v) for v in (UA, m_dot, cp)) or UA < 0 or m_dot < 0 or cp <= 0:
raise ValueError("UA and m_dot must be nonnegative; cp must be positive (all finite)")
return 1.0 - math.exp(-UA / (m_dot * cp)) if m_dot > 0 else 0.0
def calc_phase_change_hx_capacity(UA: float, m_dot: float, cp: float, T_fluid_in: float, T_ref_sat: float) -> float:
"""Available heat-transfer magnitude [W]; temperatures must use the same units."""
if not all(math.isfinite(v) for v in (T_fluid_in, T_ref_sat)):
raise ValueError("Temperatures must be finite")
return calc_phase_change_hx_effectiveness(UA, m_dot, cp) * m_dot * cp * abs(T_fluid_in - T_ref_sat)
def resolve_fan_flow_limits(
reference: float, minimum: float | None = None, maximum: float | None = None, *, custom_curve: bool = False
) -> tuple[float, float]:
"""Validate independent air-flow reference and solver bounds [m3/s].
The generic ASHRAE correlation is limited to 15--100% of reference.
Custom curves may supply independent limits outside that validity range.
Reference is always a normalization point, not an implicit hardware limit.
"""
minimum = 0.15 * reference if minimum is None else minimum
maximum = reference if maximum is None else maximum
if not all(math.isfinite(v) for v in (reference, minimum, maximum)) or reference <= 0 or not 0 <= minimum < maximum:
raise ValueError("Fan reference must be positive and finite; require finite 0 <= min < max")
if not custom_curve:
minimum = max(minimum, 0.15 * reference)
maximum = min(maximum, reference)
if minimum >= maximum:
raise ValueError("Generic ASHRAE fan limits must overlap 0.15--1.0 of reference")
return minimum, maximum
[docs]
def calc_UA_from_dV_fan(
dV_fan: float,
dV_fan_rated: float | None = None,
A_cross: float | None = None,
UA: float | None = None,
exponent: float = 0.71,
*,
dV_fan_ref: float | None = None,
) -> float:
"""Calculate velocity-dependent UA via lumped scaling (Wang et al., 2000).
Parameters
----------
dV_fan : float
Current fan flow rate [m³/s].
dV_fan_rated : float
Rated fan flow rate [m³/s].
A_cross : float
Heat exchanger cross-sectional area [m²].
UA : float
Rated UA value [W/K].
exponent : float
Exponent for velocity scaling. Default is 0.71 for a 1-row configuration.
Returns
-------
float
Scaled UA value [W/K].
Notes
-----
Instead of the Dittus-Boelter tube-side exponent (0.8), this uses
a simplified lumped exponent (default 0.71). This derivation assumes a 1-row
plain fin-and-tube configuration (N=1) where the Colburn j-factor
is proportional to Re^-0.29, leading to h ∝ V^0.71. Multi-row coils may
use exponents between 0.5 and 0.8 depending on configuration.
Reference: Wang et al. (2000), DOI: 10.1016/S0017-9310(99)00333-6
"""
reference = dV_fan_ref if dV_fan_ref is not None else dV_fan_rated
if reference is None or UA is None or A_cross is None:
raise ValueError("Fan reference, UA and cross-sectional area are required")
if (
not all(math.isfinite(v) for v in (reference, dV_fan, A_cross, UA, exponent))
or reference <= 0
or A_cross <= 0
or dV_fan < 0
or UA <= 0
or exponent < 0
):
raise ValueError("Invalid fan/UA scaling input")
return float(UA * (dV_fan / reference) ** exponent)
[docs]
def calc_HX_perf_for_target_heat(
Q_ref_target,
T_a_in_C=None,
T_ref_sat_K=None,
A_cross=None,
UA_rated=None,
dV_fan_rated=None,
is_active=True,
exponent=0.71,
# Legacy parameters for backward compatibility
T_ou_a_in_C=None,
T_ref_evap_sat_K=None,
T_ref_cond_sat_l_K=None,
UA_design=None,
dV_fan_design=None,
*,
dV_fan_ref=None,
dV_fan_min=None,
dV_fan_max=None,
custom_fan_curve=False,
):
"""Numerically solve for the air-side flow rate of an ε-NTU heat exchanger.
Given a target heat transfer duty, find the airflow that delivers it,
accounting for the velocity dependence of UA via the Wang et al. (2000)
fin-and-tube correlation (UA ∝ velocity^0.71).
Parameters
----------
Q_ref_target : float
Target heat transfer rate [W] (always positive).
T_a_in_C : float, optional
Air-side inlet temperature [°C].
T_ref_sat_K : float, optional
Refrigerant saturation temperature [K] on the constant-temperature side.
A_cross : float
Heat-exchanger cross-sectional area [m²].
UA_rated : float
Rated UA [W/K].
dV_fan_rated : float
Rated fan volumetric flow rate [m³/s].
is_active : bool
Active flag.
exponent : float
UA scaling exponent (default: 0.71).
# Legacy aliases (optional)
T_ou_a_in_C : float, optional
Backward-compat alias for ``T_a_in_C``.
T_ref_evap_sat_K : float, optional
Backward-compat alias for ``T_ref_sat_K``.
T_ref_cond_sat_l_K : float, optional
Unused; kept only to preserve the older function signature.
Returns
-------
dict
Dictionary with the following keys:
- ``dV_fan`` — required air-side flow rate [m³/s]
- ``UA`` — overall heat-transfer coefficient at the solution point [W/K]
- ``T_a_mid_C`` — air temperature between the heat exchanger and the fan [°C]
- ``Q_air`` — heat-transfer rate at the operating point [W]
- ``epsilon`` — effectiveness at the operating point [–]
- ``converged`` — whether the solver converged
All numeric values are ``np.nan`` when ``is_active=False``.
"""
# Backward-compat: accept legacy parameter names.
if T_a_in_C is None:
T_a_in_C = T_ou_a_in_C
if T_ref_sat_K is None:
T_ref_sat_K = T_ref_evap_sat_K
if UA_rated is None:
UA_rated = UA_design
if dV_fan_rated is None:
dV_fan_rated = dV_fan_design
if dV_fan_design is not None:
warnings.warn(
"dV_fan_design is deprecated; use dV_fan_ref with independent min/max.", DeprecationWarning, stacklevel=2
)
if dV_fan_ref is None:
dV_fan_ref = dV_fan_rated
if not is_active or T_a_in_C is None or T_ref_sat_K is None:
return {
"converged": True,
"dV_fan": np.nan,
"UA": np.nan,
"T_a_mid_C": np.nan,
"Q_air": np.nan,
"epsilon": np.nan,
# Legacy keys
"T_ou_a_mid": np.nan,
"Q_ou_air": np.nan,
}
T_a_in_K = cu.C2K(T_a_in_C)
if dV_fan_ref is None:
raise ValueError("Fan reference flow is required")
dV_min, dV_max = resolve_fan_flow_limits(dV_fan_ref, dV_fan_min, dV_fan_max, custom_curve=custom_fan_curve)
if abs(Q_ref_target) < 1e-6:
return {
"converged": True,
"dV_fan": 0.0,
"UA": 0.0,
"T_a_mid_C": T_a_in_C,
"Q_air": 0.0,
"epsilon": 0.0,
# Legacy keys
"T_ou_a_mid": T_a_in_C,
"Q_ou_air": 0.0,
}
def _error_function(dV_fan):
if dV_fan <= 0:
return -Q_ref_target
UA = calc_UA_from_dV_fan(dV_fan, dV_fan_ref, A_cross, UA_rated, exponent)
C_air = c_a * rho_a * dV_fan
epsilon = 1 - np.exp(-UA / C_air)
# Heat transfer Q = C_air * epsilon * abs(T_air_in - T_ref_sat)
Q_air = C_air * epsilon * abs(T_a_in_K - T_ref_sat_K)
return Q_air - Q_ref_target
# Clamp the flow demand, but do not mark an unmatched HX duty feasible.
err_min, err_max = _error_function(dV_min), _error_function(dV_max)
min_limit, max_limit = bool(err_min >= 0), bool(err_max <= 0)
tolerance = 0.0 if custom_fan_curve else max(0.01, 1e-5 * abs(Q_ref_target))
if min_limit:
dV_sol, converged = dV_min, bool(abs(err_min) <= tolerance)
elif max_limit:
dV_sol, converged = dV_max, bool(abs(err_max) <= tolerance)
else:
sol = root_scalar(_error_function, bracket=[dV_min, dV_max], method="bisect")
dV_sol, converged = sol.root, sol.converged
# Final calculations at solved point
UA_sol = calc_UA_from_dV_fan(dV_sol, dV_fan_ref, A_cross, UA_rated, exponent)
C_air_sol = c_a * rho_a * dV_sol
eps_sol = 1 - np.exp(-UA_sol / C_air_sol) if dV_sol > 0 else 0.0
# Exit air temperature (before fan heat if any)
T_a_mid_K = T_a_in_K - (T_a_in_K - T_ref_sat_K) * eps_sol
T_a_mid_C = cu.K2C(T_a_mid_K)
Q_air_sol = C_air_sol * abs(T_a_in_K - T_a_mid_K)
return {
"converged": converged,
"dV_fan": dV_sol,
"UA": UA_sol,
"T_a_mid_C": T_a_mid_C,
"Q_air": Q_air_sol,
"epsilon": eps_sol,
"min_limit": min_limit,
"max_limit": max_limit,
"fan_flow_ratio_to_ref": dV_sol / dV_fan_ref,
"capacity_margin_W": Q_air_sol - Q_ref_target,
"min_flow_capacity_margin_W": err_min,
"max_flow_capacity_margin_W": err_max,
# Legacy keys
"T_ou_a_mid": T_a_mid_C,
"Q_ou_air": Q_air_sol,
}