Source code for engineeringkit.thermodynamics.utils.if97

r"""IAPWS-IF97 basic equations for regions 1 to 5, in reduced variables.

The coefficients are those of the IAPWS release (Lucerne 2007 revision of
the 1997 formulation): region 1 is compressed liquid, region 2 superheated
vapour, region 3 the near-critical fluid, region 4 the saturation line and
region 5 the high-temperature steam of gas turbines. Regions 1, 2 and 5 are dimensionless Gibbs free energies
:math:`\gamma(\pi, \tau)` and region 3 a dimensionless Helmholtz free
energy :math:`\phi(\delta, \tau)`; every property follows from the
function and its derivatives. Pressures are in MPa and temperatures in
K inside this module, as in the release; the public wrappers in
:mod:`engineeringkit.thermodynamics.systems.steam` convert to Pa and J.
"""

from __future__ import annotations

import numpy as np
from scipy.optimize import brentq

__all__ = [
    "R_WATER",
    "T_CRIT",
    "P_CRIT",
    "region1",
    "region2",
    "region3",
    "region3_density",
    "region5",
    "saturation_pressure_mpa",
    "saturation_temperature_k",
    "b23_pressure_mpa",
    "b23_temperature_k",
]

R_WATER = 461.526
"""Specific gas constant of water used by IF97 [J/(kg K)]."""

T_CRIT = 647.096
"""Critical temperature of water [K]."""

P_CRIT = 22.064e6
"""Critical pressure of water [Pa]."""

RHO_CRIT = 322.0
"""Critical density of water [kg/m^3]."""

# Region 1 (Table 2 of the release): I, J, n.
_R1 = np.array(
    [
        (0, -2, 0.14632971213167),
        (0, -1, -0.84548187169114),
        (0, 0, -0.37563603672040e1),
        (0, 1, 0.33855169168385e1),
        (0, 2, -0.95791963387872),
        (0, 3, 0.15772038513228),
        (0, 4, -0.16616417199501e-1),
        (0, 5, 0.81214629983568e-3),
        (1, -9, 0.28319080123804e-3),
        (1, -7, -0.60706301565874e-3),
        (1, -1, -0.18990068218419e-1),
        (1, 0, -0.32529748770505e-1),
        (1, 1, -0.21841717175414e-1),
        (1, 3, -0.52838357969930e-4),
        (2, -3, -0.47184321073267e-3),
        (2, 0, -0.30001780793026e-3),
        (2, 1, 0.47661393906987e-4),
        (2, 3, -0.44141845330846e-5),
        (2, 17, -0.72694996297594e-15),
        (3, -4, -0.31679644845054e-4),
        (3, 0, -0.28270797985312e-5),
        (3, 6, -0.85205128120103e-9),
        (4, -5, -0.22425281908000e-5),
        (4, -2, -0.65171222895601e-6),
        (4, 10, -0.14341729937924e-12),
        (5, -8, -0.40516996860117e-6),
        (8, -11, -0.12734301741641e-8),
        (8, -6, -0.17424871230634e-9),
        (21, -29, -0.68762131295531e-18),
        (23, -31, 0.14478307828521e-19),
        (29, -38, 0.26335781662795e-22),
        (30, -39, -0.11947622640071e-22),
        (31, -40, 0.18228094581404e-23),
        (32, -41, -0.93537087292458e-25),
    ]
)

# Region 2 ideal-gas part (Table 10): J0, n0.
_R2_IDEAL = np.array(
    [
        (0, -0.96927686500217e1),
        (1, 0.10086655968018e2),
        (-5, -0.56087911283020e-2),
        (-4, 0.71452738081455e-1),
        (-3, -0.40710498223928),
        (-2, 0.14240819171444e1),
        (-1, -0.43839511319450e1),
        (2, -0.28408632460772),
        (3, 0.21268463753307e-1),
    ]
)

# Region 2 residual part (Table 11): I, J, n.
_R2_RES = np.array(
    [
        (1, 0, -0.17731742473213e-2),
        (1, 1, -0.17834862292358e-1),
        (1, 2, -0.45996013696365e-1),
        (1, 3, -0.57581259083432e-1),
        (1, 6, -0.50325278727930e-1),
        (2, 1, -0.33032641670203e-4),
        (2, 2, -0.18948987516315e-3),
        (2, 4, -0.39392777243355e-2),
        (2, 7, -0.43797295650573e-1),
        (2, 36, -0.26674547914087e-4),
        (3, 0, 0.20481737692309e-7),
        (3, 1, 0.43870667284435e-6),
        (3, 3, -0.32277677238570e-4),
        (3, 6, -0.15033924542148e-2),
        (3, 35, -0.40668253562649e-1),
        (4, 1, -0.78847309559367e-9),
        (4, 2, 0.12790717852285e-7),
        (4, 3, 0.48225372718507e-6),
        (5, 7, 0.22922076337661e-5),
        (6, 3, -0.16714766451061e-10),
        (6, 16, -0.21171472321355e-2),
        (6, 35, -0.23895741934104e2),
        (7, 0, -0.59059564324270e-17),
        (7, 11, -0.12621808899101e-5),
        (7, 25, -0.38946842435739e-1),
        (8, 8, 0.11256211360459e-10),
        (8, 36, -0.82311340897998e1),
        (9, 13, 0.19809712802088e-7),
        (10, 4, 0.10406965210174e-18),
        (10, 10, -0.10234747095929e-12),
        (10, 14, -0.10018179379511e-8),
        (16, 29, -0.80882908646985e-10),
        (16, 50, 0.10693031879409),
        (18, 57, -0.33662250574171),
        (20, 20, 0.89185845355421e-24),
        (20, 35, 0.30629316876232e-12),
        (20, 48, -0.42002467698208e-5),
        (21, 21, -0.59056029685639e-25),
        (22, 53, 0.37826947613457e-5),
        (23, 39, -0.12768608934681e-14),
        (24, 26, 0.73087610595061e-28),
        (24, 40, 0.55414715350778e-16),
        (24, 58, -0.94369707241210e-6),
    ]
)

# Region 3 (Table 30): n1 multiplies ln(delta); then I, J, n.
_R3_N1 = 0.10658070028513e1
_R3 = np.array(
    [
        (0, 0, -0.15732845290239e2),
        (0, 1, 0.20944396974307e2),
        (0, 2, -0.76867707878716e1),
        (0, 7, 0.26185947787954e1),
        (0, 10, -0.28080781148620e1),
        (0, 12, 0.12053369696517e1),
        (0, 23, -0.84566812812502e-2),
        (1, 2, -0.12654315477714e1),
        (1, 6, -0.11524407806681e1),
        (1, 15, 0.88521043984318),
        (1, 17, -0.64207765181607),
        (2, 0, 0.38493460186671),
        (2, 2, -0.85214708824206),
        (2, 6, 0.48972281541877e1),
        (2, 7, -0.30502617256965e1),
        (2, 22, 0.39420536879154e-1),
        (2, 26, 0.12558408424308),
        (3, 0, -0.27999329698710),
        (3, 2, 0.13899799569460e1),
        (3, 4, -0.20189915023570e1),
        (3, 16, -0.82147637173963e-2),
        (3, 26, -0.47596035734923),
        (4, 0, 0.43984074473500e-1),
        (4, 2, -0.44476435428739),
        (4, 4, 0.90572070719733),
        (4, 26, 0.70522450087967),
        (5, 1, 0.10770512626332),
        (5, 3, -0.32913623258954),
        (5, 26, -0.50871062041158),
        (6, 0, -0.22175400873096e-1),
        (6, 2, 0.94260751665092e-1),
        (6, 26, 0.16436278447961),
        (7, 2, -0.13503372241348e-1),
        (8, 26, -0.14834345352472e-1),
        (9, 2, 0.57922953628084e-3),
        (9, 26, 0.32308904703711e-2),
        (10, 0, 0.80964802996215e-4),
        (10, 1, -0.16557679795037e-3),
        (11, 26, -0.44923899061815e-4),
    ]
)

# Region 5 (Tables 37 and 38 of the 2007 revision, valid to 50 MPa): ideal part J0, n0; residual I, J, n.
_R5_IDEAL = np.array(
    [
        (0, -0.13179983674201e2),
        (1, 0.68540841634434e1),
        (-3, -0.24805148933466e-1),
        (-2, 0.36901534980333),
        (-1, -0.31161318213925e1),
        (2, -0.32961626538917),
    ]
)
_R5_RES = np.array(
    [
        (1, 1, 0.15736404855259e-2),
        (1, 2, 0.90153761673944e-3),
        (1, 3, -0.50270077677648e-2),
        (2, 3, 0.22440037409485e-5),
        (2, 9, -0.41163275453471e-5),
        (3, 7, 0.37919454822955e-7),
    ]
)

# Region 4 saturation line (Table 34): n1 ... n10.
_R4 = np.array(
    [
        0.11670521452767e4,
        -0.72421316703206e6,
        -0.17073846940092e2,
        0.12020824702470e5,
        -0.32325550322333e7,
        0.14915108613530e2,
        -0.48232657361591e4,
        0.40511340542057e6,
        -0.23855557567849,
        0.65017534844798e3,
    ]
)

# Boundary between regions 2 and 3 (Table 1): n1 ... n5.
_B23 = (0.34805185628969e3, -0.11671859879975e1, 0.10192970039326e-2)


def _props(T, p_mpa, g, g_p, g_t, g_tt, pi, tau):
    """Specific properties from a reduced Gibbs function and its derivatives (SI: m^3/kg, J/kg, J/(kg K))."""
    RT = R_WATER * T
    return {
        "v": pi * g_p * RT / (p_mpa * 1e6),
        "h": RT * tau * g_t,
        "u": RT * (tau * g_t - pi * g_p),
        "s": R_WATER * (tau * g_t - g),
        "cp": -R_WATER * tau**2 * g_tt,
    }


[docs] def region1(T, p_mpa): """Region 1 (compressed liquid) properties from the IF97 Gibbs function. Parameters ---------- T : float or array_like Temperature [K], 273.15 K to 623.15 K. p_mpa : float or array_like Pressure [MPa], above saturation and at most 100 MPa. Returns ------- dict of ndarray Specific volume ``v`` [m^3/kg], enthalpy ``h`` [J/kg], internal energy ``u`` [J/kg], entropy ``s`` [J/(kg K)] and isobaric heat capacity ``cp`` [J/(kg K)], broadcast over ``T`` and ``p_mpa``. """ T, p_mpa = np.broadcast_arrays(np.asarray(T, dtype=float), np.asarray(p_mpa, dtype=float)) pi, tau = p_mpa / 16.53, 1386.0 / T I, J, n = _R1[:, 0], _R1[:, 1], _R1[:, 2] a, b = (7.1 - pi)[..., None], (tau - 1.222)[..., None] g = np.sum(n * a**I * b**J, axis=-1) g_p = np.sum(-n * I * a ** (I - 1) * b**J, axis=-1) g_t = np.sum(n * a**I * J * b ** (J - 1), axis=-1) g_tt = np.sum(n * a**I * J * (J - 1) * b ** (J - 2), axis=-1) return _props(T, p_mpa, g, g_p, g_t, g_tt, pi, tau)
[docs] def region2(T, p_mpa): """Region 2 (vapour) properties from the IF97 Gibbs function. Parameters ---------- T : float or array_like Temperature [K], 273.15 K to 1073.15 K. p_mpa : float or array_like Pressure [MPa], positive and below saturation (and below the region 2-3 boundary above 623.15 K). Returns ------- dict of ndarray Specific volume ``v`` [m^3/kg], enthalpy ``h`` [J/kg], internal energy ``u`` [J/kg], entropy ``s`` [J/(kg K)] and isobaric heat capacity ``cp`` [J/(kg K)], broadcast over ``T`` and ``p_mpa``. """ T, p_mpa = np.broadcast_arrays(np.asarray(T, dtype=float), np.asarray(p_mpa, dtype=float)) pi, tau = p_mpa / 1.0, 540.0 / T J0, n0 = _R2_IDEAL[:, 0], _R2_IDEAL[:, 1] I, J, n = _R2_RES[:, 0], _R2_RES[:, 1], _R2_RES[:, 2] t0, P, t = tau[..., None], pi[..., None], (tau - 0.5)[..., None] g = np.log(pi) + np.sum(n0 * t0**J0, axis=-1) + np.sum(n * P**I * t**J, axis=-1) g_p = 1.0 / pi + np.sum(n * I * P ** (I - 1) * t**J, axis=-1) g_t = np.sum(n0 * J0 * t0 ** (J0 - 1), axis=-1) + np.sum(n * P**I * J * t ** (J - 1), axis=-1) g_tt = np.sum(n0 * J0 * (J0 - 1) * t0 ** (J0 - 2), axis=-1) + np.sum(n * P**I * J * (J - 1) * t ** (J - 2), axis=-1) return _props(T, p_mpa, g, g_p, g_t, g_tt, pi, tau)
[docs] def region5(T, p_mpa): """Region 5 (high-temperature steam) properties from the IF97 Gibbs function. Parameters ---------- T : float or array_like Temperature [K], 1073.15 K to 2273.15 K. p_mpa : float or array_like Pressure [MPa], positive and at most 50 MPa. Returns ------- dict of ndarray Specific volume ``v`` [m^3/kg], enthalpy ``h`` [J/kg], internal energy ``u`` [J/kg], entropy ``s`` [J/(kg K)] and isobaric heat capacity ``cp`` [J/(kg K)], broadcast over ``T`` and ``p_mpa``. """ T, p_mpa = np.broadcast_arrays(np.asarray(T, dtype=float), np.asarray(p_mpa, dtype=float)) pi, tau = p_mpa / 1.0, 1000.0 / T J0, n0 = _R5_IDEAL[:, 0], _R5_IDEAL[:, 1] I, J, n = _R5_RES[:, 0], _R5_RES[:, 1], _R5_RES[:, 2] t, P = tau[..., None], pi[..., None] g = np.log(pi) + np.sum(n0 * t**J0, axis=-1) + np.sum(n * P**I * t**J, axis=-1) g_p = 1.0 / pi + np.sum(n * I * P ** (I - 1) * t**J, axis=-1) g_t = np.sum(n0 * J0 * t ** (J0 - 1), axis=-1) + np.sum(n * P**I * J * t ** (J - 1), axis=-1) g_tt = np.sum(n0 * J0 * (J0 - 1) * t ** (J0 - 2), axis=-1) + np.sum(n * P**I * J * (J - 1) * t ** (J - 2), axis=-1) return _props(T, p_mpa, g, g_p, g_t, g_tt, pi, tau)
[docs] def region3(rho, T): """Region 3 (near-critical) properties from the IF97 Helmholtz function. Parameters ---------- rho : float or array_like Density [kg/m^3], positive. T : float or array_like Temperature [K], 623.15 K to 863.15 K. Returns ------- dict of ndarray Pressure ``p`` [Pa] and, as for :func:`region1`, ``v``, ``h``, ``u``, ``s`` and ``cp``, broadcast over ``rho`` and ``T``. """ rho, T = np.broadcast_arrays(np.asarray(rho, dtype=float), np.asarray(T, dtype=float)) delta, tau = rho / RHO_CRIT, T_CRIT / T I, J, n = _R3[:, 0], _R3[:, 1], _R3[:, 2] d, t = delta[..., None], tau[..., None] f = _R3_N1 * np.log(delta) + np.sum(n * d**I * t**J, axis=-1) f_d = _R3_N1 / delta + np.sum(n * I * d ** (I - 1) * t**J, axis=-1) f_dd = -_R3_N1 / delta**2 + np.sum(n * I * (I - 1) * d ** (I - 2) * t**J, axis=-1) f_t = np.sum(n * d**I * J * t ** (J - 1), axis=-1) f_tt = np.sum(n * d**I * J * (J - 1) * t ** (J - 2), axis=-1) f_dt = np.sum(n * I * d ** (I - 1) * J * t ** (J - 1), axis=-1) RT = R_WATER * T coupling = delta * f_d - delta * tau * f_dt return { "p": rho * RT * delta * f_d, "v": 1 / rho, "h": RT * (tau * f_t + delta * f_d), "u": RT * tau * f_t, "s": R_WATER * (tau * f_t - f), "cp": R_WATER * (-(tau**2) * f_tt + coupling**2 / (2 * delta * f_d + delta**2 * f_dd)), }
[docs] def region3_density(T, p_mpa, phase): """Density at which the region 3 pressure equals ``p_mpa`` on the isotherm ``T``. Below the critical temperature an isotherm of the Helmholtz function has a van der Waals loop, so up to three densities share a pressure; the largest is the liquid and the smallest the vapour. Parameters ---------- T : float Temperature [K], 623.15 K to 863.15 K. p_mpa : float Pressure [MPa]. phase : {"liquid", "vapour"} Which root to return when there are several. Returns ------- float Density [kg/m^3]. Raises ------ ValueError If no density between 100 and 800 kg/m^3 gives that pressure. Notes ----- Region 3 spans about 113 to 765 kg/m^3. The search stops at 800 kg/m^3 because the fit, extrapolated to denser states, turns back down and would give a spurious liquid root. """ grid = np.linspace(100.0, 800.0, 2801) gap = region3(grid, T)["p"] / 1e6 - p_mpa roots = np.flatnonzero(np.sign(gap[:-1]) != np.sign(gap[1:])) if not roots.size: raise ValueError("no region 3 density gives this pressure and temperature") i = roots[-1] if phase == "liquid" else roots[0] return float(brentq(lambda r: float(region3(r, T)["p"]) / 1e6 - p_mpa, grid[i], grid[i + 1], xtol=1e-12))
[docs] def saturation_pressure_mpa(T): """Region 4 saturation pressure. Parameters ---------- T : float or array_like Temperature [K], 273.15 K to 647.096 K. Returns ------- float or ndarray Saturation pressure [MPa]. """ n = _R4 T = np.asarray(T, dtype=float) theta = T + n[8] / (T - n[9]) A = theta**2 + n[0] * theta + n[1] B = n[2] * theta**2 + n[3] * theta + n[4] C = n[5] * theta**2 + n[6] * theta + n[7] return (2 * C / (-B + np.sqrt(B**2 - 4 * A * C))) ** 4
[docs] def saturation_temperature_k(p_mpa): """Region 4 saturation temperature, the inverse of :func:`saturation_pressure_mpa`. Parameters ---------- p_mpa : float or array_like Pressure [MPa], 611.213 Pa to 22.064 MPa. Returns ------- float or ndarray Saturation temperature [K]. """ n = _R4 beta = np.asarray(p_mpa, dtype=float) ** 0.25 E = beta**2 + n[2] * beta + n[5] F = n[0] * beta**2 + n[3] * beta + n[6] G = n[1] * beta**2 + n[4] * beta + n[7] D = 2 * G / (-F - np.sqrt(F**2 - 4 * E * G)) return (n[9] + D - np.sqrt((n[9] + D) ** 2 - 4 * (n[8] + n[9] * D))) / 2
[docs] def b23_pressure_mpa(T): """Pressure on the boundary between regions 2 and 3. Parameters ---------- T : float or array_like Temperature [K], 623.15 K to 863.15 K. Returns ------- float or ndarray Boundary pressure [MPa]. """ n1, n2, n3 = _B23 T = np.asarray(T, dtype=float) return n1 + n2 * T + n3 * T**2
[docs] def b23_temperature_k(p_mpa): """Temperature on the boundary between regions 2 and 3, the inverse of :func:`b23_pressure_mpa`. Parameters ---------- p_mpa : float or array_like Pressure [MPa], 16.529 MPa to 100 MPa. Returns ------- float or ndarray Boundary temperature [K]. """ n4, n5 = 0.57254459862746e3, 0.13918839778870e2 return n4 + np.sqrt((np.asarray(p_mpa, dtype=float) - n5) / _B23[2])