fix(cryo_tank): use helium-only CoolProp pressure model
This commit is contained in:
1 parent
4a22ef1334
commit
610ae39ee4
9 files changed
+126
-197
No files matched your search
@@ -46,7 +46,7 @@ OUTPUT_DIR = "results/cryo_tank"
|
|||||||
- `cryo_tank_level.png`:液位和充满率曲线。
|
- `cryo_tank_level.png`:液位和充满率曲线。
|
||||||
- `cryo_tank_he_flow.png`:氦气质量和氦气流量曲线。
|
- `cryo_tank_he_flow.png`:氦气质量和氦气流量曲线。
|
||||||
- `cryo_tank_heat.png`:漏热和气液换热曲线。
|
- `cryo_tank_heat.png`:漏热和气液换热曲线。
|
||||||
- `cryo_tank_pressure.png`:总压、氮气分压和氦气分压曲线。
|
- `cryo_tank_pressure.png`:总压和氦气气枕压力曲线。
|
||||||
|
|
||||||
如果没有看到 CSV 文件,先确认本地代码里是否有:
|
如果没有看到 CSV 文件,先确认本地代码里是否有:
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ y = [m_liq, U_liq, U_ull]
|
|||||||
- `U_liq`:液相内能
|
- `U_liq`:液相内能
|
||||||
- `U_ull`:气枕区总内能
|
- `U_ull`:气枕区总内能
|
||||||
|
|
||||||
`derive(y)` 会根据状态量计算温度、体积、液位、充满率、氦气质量、氮气分压和氦气分压等派生量。
|
`derive(y)` 会根据状态量计算温度、体积、液位、充满率、氦气质量和氦气气枕压力等派生量。
|
||||||
|
|
||||||
`rhs(t, y)` 是 ODE 右端函数,由 `scipy.integrate.solve_ivp` 调用。
|
`rhs(t, y)` 是 ODE 右端函数,由 `scipy.integrate.solve_ivp` 调用。
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ run(tank, t_end, rtol=1e-8, atol=1e-10, max_step=10.0)
|
|||||||
|
|
||||||
- 液氮密度、焓、内能计算。
|
- 液氮密度、焓、内能计算。
|
||||||
- 氮气饱和压力和饱和蒸气内能计算。
|
- 氮气饱和压力和饱和蒸气内能计算。
|
||||||
- 氦气理想气体焓、内能、比热计算。
|
- 氦气密度、焓、内能、比热计算。
|
||||||
- 建立液氮内能到温度的查表插值,加速 ODE 求解。
|
- 建立液氮内能到温度的查表插值,加速 ODE 求解。
|
||||||
|
|
||||||
该模块依赖 `CoolProp`。
|
该模块依赖 `CoolProp`。
|
||||||
@@ -348,7 +348,6 @@ CSV 第一行是列名,列名来自 `history` 字典,包括:
|
|||||||
- `V_ull`
|
- `V_ull`
|
||||||
- `liquid_level`
|
- `liquid_level`
|
||||||
- `fill_fraction`
|
- `fill_fraction`
|
||||||
- `P_N2`
|
|
||||||
- `P_He`
|
- `P_He`
|
||||||
- `P_total`
|
- `P_total`
|
||||||
- `Q_leak`
|
- `Q_leak`
|
||||||
@@ -373,7 +372,7 @@ pytest -q
|
|||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 本模型当前将储箱分为液相区和气枕区两个区域,不是完整 CFD 模型。
|
- 本模型当前将储箱分为液相区和气枕区两个区域,不是完整 CFD 模型。
|
||||||
- 气枕区氮气和氦气采用简化处理,氦气按理想气体热力学关系计算。
|
- 气枕区按纯氦气处理,物性由 CoolProp 计算。
|
||||||
- 液氮物性依赖 `CoolProp`,缺少该包会导致程序无法启动。
|
- 液氮物性依赖 `CoolProp`,缺少该包会导致程序无法启动。
|
||||||
- `results/` 目录默认被 `.gitignore` 忽略,仿真输出不会自动上传到 Git。
|
- `results/` 目录默认被 `.gitignore` 忽略,仿真输出不会自动上传到 Git。
|
||||||
- 建议每次修改参数后保存对应工况说明,避免不同仿真结果混在同一个输出目录中。
|
- 建议每次修改参数后保存对应工况说明,避免不同仿真结果混在同一个输出目录中。
|
||||||
@@ -5,13 +5,6 @@ Pure data module -- no functions, no side effects.
|
|||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
# ---------- Gas constants ----------
|
|
||||||
R_UNIVERSAL = 8314.46 # J/(kmol*K)
|
|
||||||
M_N2 = 28.014 # kg/kmol
|
|
||||||
M_HE = 4.0026 # kg/kmol
|
|
||||||
R_HE = R_UNIVERSAL / M_HE # 2077.1 J/(kg*K)
|
|
||||||
R_N2 = R_UNIVERSAL / M_N2 # 296.8 J/(kg*K)
|
|
||||||
|
|
||||||
# ---------- Tank geometry ----------
|
# ---------- Tank geometry ----------
|
||||||
V_TOTAL = 420.1e-3 # m^3 (420.1 L)
|
V_TOTAL = 420.1e-3 # m^3 (420.1 L)
|
||||||
H_TANK = 0.5 # m (cylinder height)
|
H_TANK = 0.5 # m (cylinder height)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def main():
|
|||||||
print(f" T_liq = {info0['T_liq']:.2f} K, T_ull = {info0['T_ull']:.2f} K")
|
print(f" T_liq = {info0['T_liq']:.2f} K, T_ull = {info0['T_ull']:.2f} K")
|
||||||
print(f" fill_fraction = {info0['fill_fraction']:.1%}")
|
print(f" fill_fraction = {info0['fill_fraction']:.1%}")
|
||||||
print(f" m_He = {info0['m_He']*1000:.2f} g")
|
print(f" m_He = {info0['m_He']*1000:.2f} g")
|
||||||
print(f" P_N2 = {info0['P_N2']/1e6:.4f} MPa, P_He = {info0['P_He']/1e6:.4f} MPa")
|
print(f" P_He = {info0['P_He']/1e6:.4f} MPa")
|
||||||
print(f"Running to t_end = {T_END:.0f} s ...")
|
print(f"Running to t_end = {T_END:.0f} s ...")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|||||||
@@ -116,12 +116,11 @@ def plot_heat_fluxes(history, path):
|
|||||||
|
|
||||||
|
|
||||||
def plot_pressure(history, path):
|
def plot_pressure(history, path):
|
||||||
"""Plot tank pressure (P_total, P_N2, P_He) vs time."""
|
"""Plot tank pressure (P_total and P_He) vs time."""
|
||||||
fig, ax = plt.subplots(figsize=(10, 5))
|
fig, ax = plt.subplots(figsize=(10, 5))
|
||||||
t = history['t']
|
t = history['t']
|
||||||
|
|
||||||
ax.plot(t, history['P_total'] / 1e6, label='P_total', linewidth=2)
|
ax.plot(t, history['P_total'] / 1e6, label='P_total', linewidth=2)
|
||||||
ax.plot(t, history['P_N2'] / 1e6, label='P_N2', linestyle='--')
|
|
||||||
ax.plot(t, history['P_He'] / 1e6, label='P_He', linestyle='--')
|
ax.plot(t, history['P_He'] / 1e6, label='P_He', linestyle='--')
|
||||||
ax.set_xlabel('Time [s]')
|
ax.set_xlabel('Time [s]')
|
||||||
ax.set_ylabel('Pressure [MPa]')
|
ax.set_ylabel('Pressure [MPa]')
|
||||||
|
|||||||
+40
-63
@@ -1,13 +1,11 @@
|
|||||||
# src/cryo_tank/properties.py
|
# src/cryo_tank/properties.py
|
||||||
"""
|
"""
|
||||||
Fluid property wrappers for liquid nitrogen, N2 vapor, and helium.
|
Fluid property wrappers for liquid nitrogen and helium.
|
||||||
|
|
||||||
Performance strategy (per spec Section 8.1):
|
Performance strategy:
|
||||||
- N2 (liquid & vapor): CoolProp with persistent AbstractState objects
|
- N2 liquid: CoolProp with a persistent AbstractState object
|
||||||
- He: analytical ideal gas (cp=5196.2 J/(kg*K), cv=3117.1 J/(kg*K))
|
- He: CoolProp with a persistent AbstractState object
|
||||||
- Lookup tables for the ODE hot path (built at import time)
|
|
||||||
"""
|
"""
|
||||||
import numpy as np
|
|
||||||
import CoolProp.CoolProp as CP
|
import CoolProp.CoolProp as CP
|
||||||
from CoolProp import AbstractState
|
from CoolProp import AbstractState
|
||||||
|
|
||||||
@@ -41,81 +39,60 @@ def ln2_u(T, P):
|
|||||||
|
|
||||||
|
|
||||||
def ln2_T_from_u(u, P):
|
def ln2_T_from_u(u, P):
|
||||||
"""Recover LN2 temperature from specific internal energy [K].
|
"""Recover LN2 temperature from pressure and specific internal energy [K]."""
|
||||||
|
_n2_state.update(CP.PUmass_INPUTS, P, u)
|
||||||
Uses lookup table interpolation for speed; falls back to CoolProp
|
return _n2_state.T()
|
||||||
if outside the table range.
|
|
||||||
"""
|
|
||||||
return float(np.interp(u, _ln2_u_table, _ln2_T_table))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# N2 vapor properties (at saturation or specified conditions)
|
# Helium properties via CoolProp
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def n2_sat_pressure(T):
|
_HE_P_REF = 170000.0
|
||||||
"""N2 saturation pressure [Pa] at temperature T."""
|
|
||||||
_n2_state.update(CP.QT_INPUTS, 1.0, T)
|
|
||||||
return _n2_state.p()
|
|
||||||
|
|
||||||
|
|
||||||
def n2_vapor_u(T):
|
def he_rho(T, P=_HE_P_REF):
|
||||||
"""N2 saturated vapor specific internal energy [J/kg] at temperature T."""
|
"""He density [kg/m^3]."""
|
||||||
_n2_state.update(CP.QT_INPUTS, 1.0, T)
|
_he_state.update(CP.PT_INPUTS, P, T)
|
||||||
return _n2_state.umass()
|
return _he_state.rhomass()
|
||||||
|
|
||||||
|
|
||||||
def n2_vapor_rho(T):
|
def he_cp(T=100.0, P=_HE_P_REF):
|
||||||
"""N2 saturated vapor density [kg/m^3] at temperature T."""
|
|
||||||
_n2_state.update(CP.QT_INPUTS, 1.0, T)
|
|
||||||
return _n2_state.rhomass()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helium properties (ideal gas: cp=5/2 R, cv=3/2 R, monatomic)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_HE_CP = 5196.2 # J/(kg*K), = 5/2 * R_He
|
|
||||||
_HE_CV = 3117.1 # J/(kg*K), = 3/2 * R_He
|
|
||||||
|
|
||||||
# Reference state: CoolProp He at T_ref=0K gives u_ref, h_ref
|
|
||||||
# We match CoolProp's reference by computing offset at a known point.
|
|
||||||
_he_state.update(CP.PT_INPUTS, 170000.0, 100.0)
|
|
||||||
_HE_H_REF = _he_state.hmass() - _HE_CP * 100.0 # h = cp*T + h_ref
|
|
||||||
_HE_U_REF = _he_state.umass() - _HE_CV * 100.0 # u = cv*T + u_ref
|
|
||||||
|
|
||||||
|
|
||||||
def he_cp():
|
|
||||||
"""He specific heat at constant pressure [J/(kg*K)]."""
|
"""He specific heat at constant pressure [J/(kg*K)]."""
|
||||||
return _HE_CP
|
_he_state.update(CP.PT_INPUTS, P, T)
|
||||||
|
return _he_state.cpmass()
|
||||||
|
|
||||||
|
|
||||||
def he_cv():
|
def he_cv(T=100.0, P=_HE_P_REF):
|
||||||
"""He specific heat at constant volume [J/(kg*K)]."""
|
"""He specific heat at constant volume [J/(kg*K)]."""
|
||||||
return _HE_CV
|
_he_state.update(CP.PT_INPUTS, P, T)
|
||||||
|
return _he_state.cvmass()
|
||||||
|
|
||||||
|
|
||||||
def he_h(T):
|
def he_h(T, P=_HE_P_REF):
|
||||||
"""He specific enthalpy [J/kg] (ideal gas)."""
|
"""He specific enthalpy [J/kg]."""
|
||||||
return _HE_CP * T + _HE_H_REF
|
_he_state.update(CP.PT_INPUTS, P, T)
|
||||||
|
return _he_state.hmass()
|
||||||
|
|
||||||
|
|
||||||
def he_u(T):
|
def he_u(T, P=_HE_P_REF):
|
||||||
"""He specific internal energy [J/kg] (ideal gas)."""
|
"""He specific internal energy [J/kg]."""
|
||||||
return _HE_CV * T + _HE_U_REF
|
_he_state.update(CP.PT_INPUTS, P, T)
|
||||||
|
return _he_state.umass()
|
||||||
|
|
||||||
|
|
||||||
def he_T_from_u(u):
|
def he_T_from_u(u, P=_HE_P_REF):
|
||||||
"""Recover He temperature from specific internal energy [K]."""
|
"""Recover He temperature from pressure and specific internal energy [K]."""
|
||||||
return (u - _HE_U_REF) / _HE_CV
|
_he_state.update(CP.PUmass_INPUTS, P, u)
|
||||||
|
return _he_state.T()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def he_T_from_rho(P, rho):
|
||||||
# Lookup table for LN2: u(T) -> T at P = 0.17 MPa (built at import time)
|
"""Recover He temperature from pressure and density [K]."""
|
||||||
# ---------------------------------------------------------------------------
|
_he_state.update(CP.DmassP_INPUTS, rho, P)
|
||||||
_LN2_T_MIN = 65.0
|
return _he_state.T()
|
||||||
_LN2_T_MAX = 82.0 # stay below saturation at 0.17 MPa (~82.03 K)
|
|
||||||
_LN2_TABLE_N = 200
|
|
||||||
_P_WORK = 170000.0
|
|
||||||
|
|
||||||
_ln2_T_table = np.linspace(_LN2_T_MIN, _LN2_T_MAX, _LN2_TABLE_N)
|
|
||||||
_ln2_u_table = np.array([ln2_u(T, _P_WORK) for T in _ln2_T_table])
|
def he_u_from_rho(P, rho):
|
||||||
# _ln2_u_table is monotonically increasing, so np.interp works for inverse lookup
|
"""Recover He specific internal energy from pressure and density [J/kg]."""
|
||||||
|
_he_state.update(CP.DmassP_INPUTS, rho, P)
|
||||||
|
return _he_state.umass()
|
||||||
@@ -80,7 +80,6 @@ def run(tank, t_end, rtol=1e-8, atol=1e-10, max_step=10.0):
|
|||||||
'V_ull': np.zeros(n),
|
'V_ull': np.zeros(n),
|
||||||
'liquid_level': np.zeros(n),
|
'liquid_level': np.zeros(n),
|
||||||
'fill_fraction': np.zeros(n),
|
'fill_fraction': np.zeros(n),
|
||||||
'P_N2': np.zeros(n),
|
|
||||||
'P_He': np.zeros(n),
|
'P_He': np.zeros(n),
|
||||||
'P_total': np.zeros(n),
|
'P_total': np.zeros(n),
|
||||||
'Q_leak': np.zeros(n),
|
'Q_leak': np.zeros(n),
|
||||||
@@ -100,9 +99,8 @@ def run(tank, t_end, rtol=1e-8, atol=1e-10, max_step=10.0):
|
|||||||
history['V_ull'][i] = info['V_ull']
|
history['V_ull'][i] = info['V_ull']
|
||||||
history['liquid_level'][i] = info['liquid_level']
|
history['liquid_level'][i] = info['liquid_level']
|
||||||
history['fill_fraction'][i] = info['fill_fraction']
|
history['fill_fraction'][i] = info['fill_fraction']
|
||||||
history['P_N2'][i] = info['P_N2']
|
|
||||||
history['P_He'][i] = info['P_He']
|
history['P_He'][i] = info['P_He']
|
||||||
history['P_total'][i] = info['P_N2'] + info['P_He']
|
history['P_total'][i] = info['P_He']
|
||||||
|
|
||||||
# Recompute heat terms for recording
|
# Recompute heat terms for recording
|
||||||
T_liq = info['T_liq']
|
T_liq = info['T_liq']
|
||||||
@@ -120,11 +118,9 @@ def run(tank, t_end, rtol=1e-8, atol=1e-10, max_step=10.0):
|
|||||||
history['Q_leak'][i] = Q_leak
|
history['Q_leak'][i] = Q_leak
|
||||||
history['Q_leak_liq'][i] = Q_leak_liq
|
history['Q_leak_liq'][i] = Q_leak_liq
|
||||||
history['Q_leak_ull'][i] = Q_leak_ull
|
history['Q_leak_ull'][i] = Q_leak_ull
|
||||||
|
history['mdot_He'][i] = tank._solve_he_flow_rate(
|
||||||
|
info, Q_liq_to_ull, Q_leak_ull
|
||||||
|
)
|
||||||
|
|
||||||
# He flow rate via finite difference on m_He (post-processing only, not in RHS)
|
|
||||||
dt = np.diff(t)
|
|
||||||
dm_He = np.diff(history['m_He'])
|
|
||||||
history['mdot_He'][0] = dm_He[0] / dt[0] if len(dt) > 0 else 0.0
|
|
||||||
history['mdot_He'][1:] = dm_He / dt
|
|
||||||
|
|
||||||
return history
|
return history
|
||||||
+72
-93
@@ -10,8 +10,8 @@ import math
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from scipy.optimize import brentq
|
||||||
|
|
||||||
from cryo_tank.config import R_HE, R_N2
|
|
||||||
from cryo_tank import properties as prop
|
from cryo_tank import properties as prop
|
||||||
|
|
||||||
|
|
||||||
@@ -77,21 +77,14 @@ class CryoTank:
|
|||||||
self._m_liq_0 = rho_liq_0 * V_liq_0
|
self._m_liq_0 = rho_liq_0 * V_liq_0
|
||||||
self._U_liq_0 = self._m_liq_0 * prop.ln2_u(T_init, P_work)
|
self._U_liq_0 = self._m_liq_0 * prop.ln2_u(T_init, P_work)
|
||||||
|
|
||||||
# Ullage initial state: N2 vapor at saturation + He to fill pressure
|
# Ullage initial state: helium pressurization only.
|
||||||
# Use ideal gas law for N2 mass (consistent with derive/rhs which
|
# Nitrogen evaporation is intentionally not modeled, so the ullage
|
||||||
# treat ullage N2 as ideal gas: P_N2 = m_N2 * R_N2 * T / V)
|
# pressure is provided entirely by helium and the liquid sees the same
|
||||||
P_N2_0 = prop.n2_sat_pressure(T_init)
|
# tank pressure for property lookup.
|
||||||
self.m_N2_ull = P_N2_0 * V_ull_0 / (R_N2 * T_init) # FIXED for all time
|
self._m_He_0 = prop.he_rho(T_init, P_work) * V_ull_0
|
||||||
|
|
||||||
P_He_0 = P_work - P_N2_0
|
U_He_0 = self._m_He_0 * prop.he_u(T_init, P_work)
|
||||||
self._m_He_0 = P_He_0 * V_ull_0 / (R_HE * T_init)
|
self._U_ull_0 = U_He_0
|
||||||
|
|
||||||
U_N2_ull_0 = self.m_N2_ull * prop.n2_vapor_u(T_init)
|
|
||||||
U_He_0 = self._m_He_0 * prop.he_u(T_init)
|
|
||||||
self._U_ull_0 = U_N2_ull_0 + U_He_0
|
|
||||||
|
|
||||||
# Saturation temperature warning threshold
|
|
||||||
self._T_sat = 82.0 # approximate, from CoolProp: ~82.03 K at 0.17 MPa
|
|
||||||
|
|
||||||
def initial_state(self):
|
def initial_state(self):
|
||||||
"""Return the ODE initial state vector y0 = [m_liq, U_liq, U_ull]."""
|
"""Return the ODE initial state vector y0 = [m_liq, U_liq, U_ull]."""
|
||||||
@@ -108,7 +101,8 @@ class CryoTank:
|
|||||||
"""Compute all derived quantities from state vector y.
|
"""Compute all derived quantities from state vector y.
|
||||||
|
|
||||||
Returns a dict with T_liq, T_ull, V_liq, V_ull, liquid_level,
|
Returns a dict with T_liq, T_ull, V_liq, V_ull, liquid_level,
|
||||||
fill_fraction, m_He, P_N2, P_He, etc.
|
fill_fraction, m_He, P_He, etc. Helium provides the full ullage
|
||||||
|
pressure in this no-evaporation model.
|
||||||
"""
|
"""
|
||||||
m_liq, U_liq, U_ull = y[0], y[1], y[2]
|
m_liq, U_liq, U_ull = y[0], y[1], y[2]
|
||||||
|
|
||||||
@@ -126,62 +120,53 @@ class CryoTank:
|
|||||||
# Solve T_ull from ullage energy
|
# Solve T_ull from ullage energy
|
||||||
T_ull = self._solve_ullage_temperature(U_ull, V_ull)
|
T_ull = self._solve_ullage_temperature(U_ull, V_ull)
|
||||||
|
|
||||||
# Recompute P_N2 and m_He with correct T_ull
|
# No nitrogen evaporation: helium provides all gas pressure.
|
||||||
P_N2 = self.m_N2_ull * R_N2 * T_ull / V_ull
|
P_He = self.P_work
|
||||||
P_He = self.P_work - P_N2
|
m_He = prop.he_rho(T_ull, P_He) * V_ull
|
||||||
m_He = P_He * V_ull / (R_HE * T_ull)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'T_liq': T_liq, 'T_ull': T_ull,
|
'T_liq': T_liq, 'T_ull': T_ull,
|
||||||
'V_liq': V_liq, 'V_ull': V_ull,
|
'V_liq': V_liq, 'V_ull': V_ull,
|
||||||
'liquid_level': liquid_level, 'fill_fraction': fill_fraction,
|
'liquid_level': liquid_level, 'fill_fraction': fill_fraction,
|
||||||
'rho_liq': rho_liq,
|
'rho_liq': rho_liq,
|
||||||
'm_He': m_He, 'P_N2': P_N2, 'P_He': P_He,
|
'm_He': m_He, 'P_He': P_He,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _solve_ullage_temperature(self, U_ull, V_ull):
|
def _solve_ullage_temperature(self, U_ull, V_ull):
|
||||||
"""Solve for T_ull given total ullage internal energy and volume.
|
"""Solve pure-He ullage temperature using CoolProp EOS.
|
||||||
|
|
||||||
U_ull = m_N2_ull * u_N2_vap(T) + m_He(T) * u_He(T)
|
At fixed tank pressure, T is obtained from:
|
||||||
where m_He(T) = (P_work - m_N2_ull * R_N2 * T / V_ull) * V_ull / (R_He * T)
|
U_ull = rho_He(P_work, T) * V_ull * u_He(P_work, T)
|
||||||
|
|
||||||
Solved by Newton iteration.
|
|
||||||
"""
|
"""
|
||||||
T = self._T_init # initial guess
|
def residual(T):
|
||||||
for _ in range(50):
|
rho = prop.he_rho(T, self.P_work)
|
||||||
P_N2 = self.m_N2_ull * R_N2 * T / V_ull
|
u = prop.he_u(T, self.P_work)
|
||||||
P_He = self.P_work - P_N2
|
return rho * V_ull * u - U_ull
|
||||||
if P_He < 0:
|
|
||||||
P_He = 0.0
|
|
||||||
m_He = P_He * V_ull / (R_HE * T)
|
|
||||||
|
|
||||||
# N2 vapor internal energy (ideal gas approx): u = cv_N2 * T + u_ref
|
T_grid = np.geomspace(20.0, 1000.0, 160)
|
||||||
cv_N2 = 743.0
|
T_prev = T_grid[0]
|
||||||
u_N2_ref = prop.n2_vapor_u(78.0) - cv_N2 * 78.0
|
f_prev = residual(T_prev)
|
||||||
u_N2 = cv_N2 * T + u_N2_ref
|
if abs(f_prev) < 1e-8:
|
||||||
|
return float(T_prev)
|
||||||
|
|
||||||
U_calc = self.m_N2_ull * u_N2 + m_He * prop.he_u(T)
|
best_T = T_prev
|
||||||
residual = U_calc - U_ull
|
best_abs_f = abs(f_prev)
|
||||||
|
for T in T_grid[1:]:
|
||||||
|
f = residual(T)
|
||||||
|
if abs(f) < best_abs_f:
|
||||||
|
best_T = T
|
||||||
|
best_abs_f = abs(f)
|
||||||
|
if f_prev * f <= 0.0:
|
||||||
|
return brentq(residual, T_prev, T, xtol=1e-8, rtol=1e-10)
|
||||||
|
T_prev = T
|
||||||
|
f_prev = f
|
||||||
|
|
||||||
if abs(residual) < 1e-3: # converged (< 1 mJ)
|
warnings.warn(
|
||||||
return T
|
"Unable to bracket ullage temperature from CoolProp He state: "
|
||||||
|
f"U_ull={U_ull:.6g}, V_ull={V_ull:.6g}; "
|
||||||
# Numerical derivative
|
f"using nearest T={best_T:.6g} K"
|
||||||
dT = 0.01
|
)
|
||||||
P_N2_p = self.m_N2_ull * R_N2 * (T + dT) / V_ull
|
return float(best_T)
|
||||||
P_He_p = max(0.0, self.P_work - P_N2_p)
|
|
||||||
m_He_p = P_He_p * V_ull / (R_HE * (T + dT))
|
|
||||||
u_N2_p = cv_N2 * (T + dT) + u_N2_ref
|
|
||||||
U_calc_p = self.m_N2_ull * u_N2_p + m_He_p * prop.he_u(T + dT)
|
|
||||||
dU_dT = (U_calc_p - U_calc) / dT
|
|
||||||
|
|
||||||
if abs(dU_dT) < 1e-20:
|
|
||||||
break
|
|
||||||
T = T - residual / dU_dT
|
|
||||||
T = max(50.0, min(T, 400.0)) # clamp
|
|
||||||
|
|
||||||
warnings.warn(f"_solve_ullage_temperature did not converge: T={T:.2f}, residual={residual:.2e}")
|
|
||||||
return T
|
|
||||||
|
|
||||||
def rhs(self, t, y):
|
def rhs(self, t, y):
|
||||||
"""ODE right-hand side: dy/dt = [dm_liq/dt, dU_liq/dt, dU_ull/dt].
|
"""ODE right-hand side: dy/dt = [dm_liq/dt, dU_liq/dt, dU_ull/dt].
|
||||||
@@ -220,58 +205,52 @@ class CryoTank:
|
|||||||
# --- Ullage zone ---
|
# --- Ullage zone ---
|
||||||
dU_ull_dt = mdot_He * self.h_in_he + Q_liq_to_ull + Q_leak_ull
|
dU_ull_dt = mdot_He * self.h_in_he + Q_liq_to_ull + Q_leak_ull
|
||||||
|
|
||||||
# --- Warnings ---
|
|
||||||
if T_liq > self._T_sat - 1.0:
|
|
||||||
warnings.warn(
|
|
||||||
f"T_liq={T_liq:.2f}K approaching saturation ({self._T_sat:.1f}K); "
|
|
||||||
"evaporation effects may be significant."
|
|
||||||
)
|
|
||||||
|
|
||||||
return np.array([dm_liq_dt, dU_liq_dt, dU_ull_dt])
|
return np.array([dm_liq_dt, dU_liq_dt, dU_ull_dt])
|
||||||
|
|
||||||
def _solve_he_flow_rate(self, info, Q_liq_to_ull, Q_leak_ull):
|
def _solve_he_flow_rate(self, info, Q_liq_to_ull, Q_leak_ull):
|
||||||
"""Analytically solve for m_dot_He from dP/dt = 0 constraint.
|
"""Solve He inlet mass flow from constant-pressure CoolProp EOS.
|
||||||
|
|
||||||
The key equation: P_total = P_N2 + P_He = const.
|
The ullage is pure He at P_work. With V_ull changing due to liquid
|
||||||
|
volume change, m_dot_He is found by differentiating:
|
||||||
P_N2 = m_N2_ull * R_N2 * T_ull / V_ull (N2 ideal gas, m_N2_ull = const)
|
m(T, V) = rho(P_work, T) * V
|
||||||
P_He = m_He * R_HE * T_ull / V_ull
|
U(T, V) = m(T, V) * u(P_work, T)
|
||||||
|
and combining it with dU/dt = m_dot_He*h_in + Q_ull.
|
||||||
dP/dt = 0 implies dP_He/dt = -dP_N2/dt.
|
|
||||||
|
|
||||||
Expanding and solving for m_dot_He gives a linear equation.
|
|
||||||
See spec Section 2.6 for full derivation.
|
|
||||||
"""
|
"""
|
||||||
T_ull = info['T_ull']
|
T_ull = info['T_ull']
|
||||||
V_ull = info['V_ull']
|
V_ull = info['V_ull']
|
||||||
m_He = info['m_He']
|
|
||||||
rho_liq = info['rho_liq']
|
rho_liq = info['rho_liq']
|
||||||
|
|
||||||
# dV_ull/dt = -dV_liq/dt = -dm_liq/dt / rho_liq = -(mdot_in - mdot_out) / rho_liq
|
dV_ull_dt = -self.dm_liq_dt / rho_liq
|
||||||
dV_ull_dt = -self.dm_liq_dt / rho_liq # positive when liquid drains
|
|
||||||
|
|
||||||
# Total ullage heat input (excluding He inlet, which we're solving for)
|
|
||||||
Q_ull_no_he = Q_liq_to_ull + Q_leak_ull
|
Q_ull_no_he = Q_liq_to_ull + Q_leak_ull
|
||||||
|
|
||||||
# Ullage total cv*mass (for dT_ull/dt estimation)
|
def state_at(T, V):
|
||||||
cv_N2 = 743.0 # J/(kg*K), N2 vapor
|
rho = prop.he_rho(T, self.P_work)
|
||||||
cv_He = prop.he_cv()
|
m = rho * V
|
||||||
C_ull = self.m_N2_ull * cv_N2 + m_He * cv_He # total heat capacity [J/K]
|
U = m * prop.he_u(T, self.P_work)
|
||||||
|
return m, U
|
||||||
|
|
||||||
R_mix = self.m_N2_ull * R_N2 + m_He * R_HE # effective "mR" [J/K]
|
dT = max(1e-3, abs(T_ull) * 1e-5)
|
||||||
|
dV = max(1e-8, abs(V_ull) * 1e-5)
|
||||||
|
|
||||||
# Coefficient of m_dot_He in dP/dt = 0:
|
m_plus, U_plus = state_at(T_ull + dT, V_ull)
|
||||||
# A * m_dot_He + B = 0
|
m_minus, U_minus = state_at(max(20.0, T_ull - dT), V_ull)
|
||||||
# m_dot_He = -B / A
|
actual_dT = (T_ull + dT) - max(20.0, T_ull - dT)
|
||||||
A = R_HE * T_ull / V_ull + R_mix / (V_ull * C_ull) * self.h_in_he
|
m_T = (m_plus - m_minus) / actual_dT
|
||||||
B = R_mix / (V_ull * C_ull) * Q_ull_no_he - R_mix * T_ull / (V_ull ** 2) * dV_ull_dt
|
U_T = (U_plus - U_minus) / actual_dT
|
||||||
|
|
||||||
if abs(A) < 1e-30:
|
m_plus, U_plus = state_at(T_ull, V_ull + dV)
|
||||||
|
m_minus, U_minus = state_at(T_ull, max(1e-12, V_ull - dV))
|
||||||
|
actual_dV = (V_ull + dV) - max(1e-12, V_ull - dV)
|
||||||
|
m_V = (m_plus - m_minus) / actual_dV
|
||||||
|
U_V = (U_plus - U_minus) / actual_dV
|
||||||
|
|
||||||
|
denominator = U_T - self.h_in_he * m_T
|
||||||
|
if abs(denominator) < 1e-30:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
mdot_He = -B / A
|
dT_dt = (Q_ull_no_he - (U_V - self.h_in_he * m_V) * dV_ull_dt) / denominator
|
||||||
|
mdot_He = m_T * dT_dt + m_V * dV_ull_dt
|
||||||
|
|
||||||
# Clamp: He can only flow in (strict mode per spec Section 10.3)
|
|
||||||
if mdot_He < 0:
|
if mdot_He < 0:
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
f"He backflow requested (m_dot_He={mdot_He:.4e} kg/s); "
|
f"He backflow requested (m_dot_He={mdot_He:.4e} kg/s); "
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ sys.path.insert(0, "src")
|
|||||||
|
|
||||||
from cryo_tank.properties import (
|
from cryo_tank.properties import (
|
||||||
ln2_rho, ln2_h, ln2_u, ln2_T_from_u,
|
ln2_rho, ln2_h, ln2_u, ln2_T_from_u,
|
||||||
n2_vapor_u, n2_sat_pressure,
|
|
||||||
he_u, he_h, he_cp, he_cv,
|
he_u, he_h, he_cp, he_cv,
|
||||||
)
|
)
|
||||||
from cryo_tank.config import P_WORKING
|
from cryo_tank.config import P_WORKING
|
||||||
@@ -34,24 +33,12 @@ class TestLN2Properties:
|
|||||||
assert abs(T_recovered - T_orig) < 0.01
|
assert abs(T_recovered - T_orig) < 0.01
|
||||||
|
|
||||||
|
|
||||||
class TestN2VaporProperties:
|
|
||||||
"""N2 vapor properties."""
|
|
||||||
|
|
||||||
def test_n2_sat_pressure_at_78K(self):
|
|
||||||
P_sat = n2_sat_pressure(78.0)
|
|
||||||
assert 0.10e6 < P_sat < 0.12e6 # ~0.1093 MPa
|
|
||||||
|
|
||||||
def test_n2_vapor_internal_energy_at_78K(self):
|
|
||||||
u = n2_vapor_u(78.0)
|
|
||||||
assert 50000 < u < 60000 # ~55547 J/kg
|
|
||||||
|
|
||||||
|
|
||||||
class TestHeliumProperties:
|
class TestHeliumProperties:
|
||||||
"""Helium (ideal gas) properties."""
|
"""Helium properties."""
|
||||||
|
|
||||||
def test_he_cp_near_5196(self):
|
def test_he_cp_near_5196(self):
|
||||||
cp = he_cp()
|
cp = he_cp()
|
||||||
assert abs(cp - 5196.2) < 10 # monatomic ideal gas
|
assert abs(cp - 5196.2) < 10
|
||||||
|
|
||||||
def test_he_cv_near_3117(self):
|
def test_he_cv_near_3117(self):
|
||||||
cv = he_cv()
|
cv = he_cv()
|
||||||
|
|||||||
@@ -69,10 +69,9 @@ class TestInitialState:
|
|||||||
assert abs(info['T_liq'] - T_INIT) < 0.1
|
assert abs(info['T_liq'] - T_INIT) < 0.1
|
||||||
assert abs(info['T_ull'] - T_INIT) < 1.0
|
assert abs(info['T_ull'] - T_INIT) < 1.0
|
||||||
|
|
||||||
def test_initial_pressure_components_sum_to_P_working(self):
|
def test_initial_pressure_is_provided_entirely_by_helium(self):
|
||||||
tank = _make_tank()
|
tank = _make_tank()
|
||||||
y0 = tank.initial_state()
|
y0 = tank.initial_state()
|
||||||
info = tank.derive(y0)
|
info = tank.derive(y0)
|
||||||
P_N2 = info['P_N2']
|
|
||||||
P_He = info['P_He']
|
P_He = info['P_He']
|
||||||
assert abs(P_N2 + P_He - P_WORKING) / P_WORKING < 1e-6
|
assert abs(P_He - P_WORKING) / P_WORKING < 1e-12
|
||||||
Reference in new issue
Block a user