feat(cryo_tank): use ullage temperature state

This commit is contained in:
lujingze committed 2026-06-08 09:15:56 +00:00
1 parent 610ae39ee4
commit 1cf8427b52
7 files changed
+571 -109

No files matched your search

+491
View File
@@ -0,0 +1,491 @@
# cryo_tank 模型方程与 ODE 求解逻辑
本文只保留当前 `src/cryo_tank/` 模型内部实际使用的方程,以及 ODE 方程组的求解逻辑。当前版本采用方案二:把气枕温度 `T_ull` 作为 ODE 状态量,氦气质量 `m_He` 由恒压状态方程派生。
## 1. 模型假设
当前模型是液氮贮箱的两区集总参数模型:
- 液相区为液氮。
- 气枕区为纯氦气。
- 气枕压力固定为工作压力 `P_work`。
- 不考虑氮气蒸发、冷凝和气液相间质量传递。
- 氦气质量不是独立状态量,而是维持恒压所需的派生量。
- 氦气只允许补入,不允许倒流;若计算得到负的氦气流量,则报告流量钳制为 0。
对应代码:
- 状态和派生量:`src/cryo_tank/tank_model.py:98-129`
- ODE 右端项:`src/cryo_tank/tank_model.py:131-163`
- 氦气补气温度方程:`src/cryo_tank/tank_model.py:165-214`
## 2. 状态向量
ODE 状态向量为:
```text
y = [m_liq, U_liq, T_ull]
```
其中:
```text
m_liq : 液氮质量 [kg]
U_liq : 液相总内能 [J]
T_ull : 气枕区温度 [K]
```
代码位置:`src/cryo_tank/tank_model.py:1-7`,`src/cryo_tank/tank_model.py:87-89`
## 3. 初始状态方程
初始液相体积:
```text
V_liq_0 = (1 - ullage_fraction) * V_total
```
初始气枕体积:
```text
V_ull_0 = ullage_fraction * V_total
```
初始液氮质量:
```text
rho_liq_0 = rho_LN2(T_init, P_work)
m_liq_0 = rho_liq_0 * V_liq_0
```
初始液相总内能:
```text
U_liq_0 = m_liq_0 * u_LN2(T_init, P_work)
```
初始气枕温度:
```text
T_ull_0 = T_init
```
初始氦气质量是派生量:
```text
m_He_0 = rho_He(T_init, P_work) * V_ull_0
```
代码位置:`src/cryo_tank/tank_model.py:71-85`
## 4. 几何方程
横截面积:
```text
A_cross = V_total / H_tank
```
圆柱直径:
```text
D = sqrt(4 * A_cross / pi)
```
侧面积:
```text
A_side = pi * D * H_tank
```
端盖面积:
```text
A_cap = A_cross
```
总面积:
```text
A_total = A_side + 2 * A_cap
```
代码位置:`src/cryo_tank/tank_model.py:41-48`
液位对应的湿壁和干壁面积:
```text
level = clamp(liquid_level, 0, H_tank)
A_wet = A_cap + pi * D * level
A_dry = A_cap + pi * D * (H_tank - level)
```
代码位置:`src/cryo_tank/tank_model.py:91-96`
## 5. 派生量方程
给定状态:
```text
y = [m_liq, U_liq, T_ull]
```
### 5.1 液相派生量
```text
u_liq = U_liq / m_liq
T_liq = T_LN2_from_u(u_liq, P_work)
rho_liq = rho_LN2(T_liq, P_work)
V_liq = m_liq / rho_liq
liquid_level = V_liq / A_cross
fill_fraction = liquid_level / H_tank
```
代码位置:`src/cryo_tank/tank_model.py:107-113`
### 5.2 气枕派生量
```text
V_ull = V_total - V_liq
P_He = P_work
rho_He = rho_He(T_ull, P_He)
m_He = rho_He * V_ull
U_ull = m_He * u_He(T_ull, P_He)
```
代码位置:`src/cryo_tank/tank_model.py:115-121`
## 6. 换热方程
### 6.1 液相与气枕界面换热
```text
Q_liq_to_ull = h_conv * A_cross * (T_liq - T_ull)
```
符号约定:
```text
Q_liq_to_ull > 0 : 热量从液相传给气枕
Q_liq_to_ull < 0 : 热量从气枕传给液相
```
代码位置:`src/cryo_tank/tank_model.py:141-142`
### 6.2 外界总漏热
当前主程序默认使用 MLI 漏热模型:
```text
Q_leak = A_total * q_mli
```
代码位置:
- 默认创建:`src/cryo_tank/main.py:32-33`
- MLI 方程:`src/cryo_tank/heat_leak.py:21-40`
代码中还提供 Foam 漏热模型:
```text
T_mean = (T_inner + T_env) / 2
Q_leak = A_total * k_eff(T_mean) * (T_env - T_inner) / delta
```
如果 `k_eff` 是常数,则直接使用该常数。
代码位置:`src/cryo_tank/heat_leak.py:43-70`
### 6.3 漏热分配
总漏热按湿壁面积和干壁面积分配:
```text
Q_leak_liq = Q_leak * A_wet / (A_wet + A_dry)
Q_leak_ull = Q_leak * A_dry / (A_wet + A_dry)
```
代码位置:`src/cryo_tank/tank_model.py:144-148`
## 7. 液氮质量方程
液氮质量变化率:
```text
dm_liq/dt = mdot_in_ln2 - mdot_out_ln2
```
代码位置:
- 净流量预计算:`src/cryo_tank/tank_model.py:64-65`
- ODE 中使用:`src/cryo_tank/tank_model.py:150-151`
## 8. 液相能量方程
入口液氮焓:
```text
h_in_ln2 = h_LN2(T_in_ln2, P_work)
```
当前液相出口焓:
```text
h_liq = h_LN2(T_liq, P_work)
```
液相总内能变化率:
```text
dU_liq/dt =
mdot_in_ln2 * h_in_ln2
- mdot_out_ln2 * h_liq
- Q_liq_to_ull
+ Q_leak_liq
```
代码位置:
- 入口焓预计算:`src/cryo_tank/tank_model.py:60-62`
- 能量方程:`src/cryo_tank/tank_model.py:152-156`
## 9. 气枕温度和氦气质量导数方程
当前模型把 `T_ull` 作为 ODE 状态。氦气质量由恒压关系派生:
```text
m_He = rho_He(T_ull, P_work) * V_ull
```
代码位置:`src/cryo_tank/tank_model.py:115-121`
### 9.1 气枕体积变化率
液氮质量变化导致气枕体积变化:
```text
dV_ull/dt = -dm_liq_dt / rho_liq
```
代码位置:`src/cryo_tank/tank_model.py:186-188`
### 9.2 气枕区非氦气入口热量
```text
Q_ull_no_he = Q_liq_to_ull + Q_leak_ull
```
代码位置:`src/cryo_tank/tank_model.py:188`
### 9.3 恒压氦气状态函数
在固定压力 `P_work` 下,给定 `T_ull` 和 `V_ull`:
```text
rho = rho_He(T_ull, P_work)
m(T, V) = rho * V
U(T, V) = m(T, V) * u_He(T_ull, P_work)
```
代码位置:`src/cryo_tank/tank_model.py:115-121`
### 9.4 解析偏导数
代码不再对温度和体积做有限差分,而是使用恒压物性导数:
```text
drhodT_P = (partial rho / partial T)_P
dudT_P = (partial u / partial T)_P
```
CoolProp 偏导接口位置:`src/cryo_tank/properties.py:100-109`
由此得到:
```text
m_T = V_ull * drhodT_P
m_V = rho
U_T = V_ull * (u * drhodT_P + rho * dudT_P)
U_V = rho * u
```
代码位置:`src/cryo_tank/tank_model.py:165-178`
### 9.5 气枕温度变化率
氦气入口焓:
```text
h_in_he = h_He(T_in_he, P_work)
```
气枕温度变化率:
```text
dT_ull/dt =
[Q_ull_no_he - (U_V + P_work - h_in_he * m_V) * dV_ull/dt]
/ [U_T - h_in_he * m_T]
```
代码位置:`src/cryo_tank/tank_model.py:190-197`
若分母过小:
```text
if abs(U_T - h_in_he * m_T) < 1e-30:
dT_ull/dt = 0
mdot_He = 0
```
代码位置:`src/cryo_tank/tank_model.py:190-193`
### 9.6 氦气质量变化率
```text
dm_He/dt = mdot_He
```
其中:
```text
mdot_He = m_T * dT_ull/dt + m_V * dV_ull/dt
```
代码位置:`src/cryo_tank/tank_model.py:198`
若 `mdot_He < 0`,报告流量钳制为 0:
```text
mdot_He = 0
```
代码位置:`src/cryo_tank/tank_model.py:200-205`
## 10. 完整 ODE 方程组
状态向量:
```text
y = [m_liq, U_liq, T_ull]
```
ODE 方程组:
```text
dm_liq/dt = mdot_in_ln2 - mdot_out_ln2
```
```text
dU_liq/dt =
mdot_in_ln2 * h_in_ln2
- mdot_out_ln2 * h_liq
- Q_liq_to_ull
+ Q_leak_liq
```
```text
dT_ull/dt =
[Q_ull_no_he - (U_V + P_work - h_in_he * m_V) * dV_ull/dt]
/ [U_T - h_in_he * m_T]
```
其中:
```text
h_liq = h_LN2(T_liq, P_work)
Q_liq_to_ull = h_conv * A_cross * (T_liq - T_ull)
Q_ull_no_he = Q_liq_to_ull + Q_leak_ull
Q_leak_liq = Q_leak * A_wet / (A_wet + A_dry)
Q_leak_ull = Q_leak * A_dry / (A_wet + A_dry)
```
氦气质量和质量流量是派生量:
```text
m_He = rho_He(T_ull, P_work) * V_ull
mdot_He = m_T * dT_ull/dt + m_V * dV_ull/dt
```
代码位置:`src/cryo_tank/tank_model.py:131-214`
## 11. ODE 求解逻辑
求解器入口:
```text
run(tank, t_end, rtol=1e-8, atol=1e-10, max_step=10.0)
```
代码位置:`src/cryo_tank/solver.py:22-24`
求解初值:
```text
y0 = tank.initial_state()
```
代码位置:`src/cryo_tank/solver.py:43`
调用 `solve_ivp`:
```text
solve_ivp(
tank.rhs,
[0.0, t_end],
y0,
method="RK45",
rtol=rtol,
atol=atol,
max_step=max_step,
events=[_liquid_empty_event],
dense_output=True,
)
```
代码位置:`src/cryo_tank/solver.py:45-55`
液体排空事件:
```text
event(t, y) = m_liq
```
事件属性:
```text
terminal = True
direction = -1
```
含义:
- 当液氮质量下降到 0 时终止求解。
- 只检测从正到零的方向。
代码位置:`src/cryo_tank/solver.py:14-19`
求解失败时抛出错误;若液体提前排空,则发出警告。
代码位置:`src/cryo_tank/solver.py:57-64`
## 12. 后处理逻辑
求解完成后,`solver.run()` 对每个输出时刻执行:
```text
info = tank.derive(y_i)
```
并重新计算:
```text
Q_liq_to_ull
Q_leak
Q_leak_liq
Q_leak_ull
mdot_He
```
其中 `m_He`、`U_ull`、`mdot_He` 均为后处理派生结果,不是独立 ODE 状态。
代码位置:`src/cryo_tank/solver.py:70-124`
Binary file not shown.
+8 -5
View File
@@ -96,16 +96,16 @@ find results/cryo_tank -maxdepth 1 -type f -print
状态量为: 状态量为:
```text ```text
y = [m_liq, U_liq, U_ull] y = [m_liq, U_liq, T_ull]
``` ```
含义分别是: 含义分别是:
- `m_liq`:液氮质量 - `m_liq`:液氮质量
- `U_liq`:液相内能 - `U_liq`:液相内能
- `U_ull`:气枕区总内能 - `T_ull`:气枕区温度
`derive(y)` 会根据状态量计算温度、体积、液位、充满率、氦气质量和氦气气枕压力等派生量。 `derive(y)` 会根据状态量计算温度、体积、液位、充满率、氦气质量、气枕区总内能和氦气气枕压力等派生量。当前模型按恒压纯氦气枕处理,`m_He = rho_He(T_ull, P_work) * V_ull`,所以氦气质量是派生量,不是独立 ODE 状态。
`rhs(t, y)` 是 ODE 右端函数,由 `scipy.integrate.solve_ivp` 调用。 `rhs(t, y)` 是 ODE 右端函数,由 `scipy.integrate.solve_ivp` 调用。
@@ -132,9 +132,11 @@ run(tank, t_end, rtol=1e-8, atol=1e-10, max_step=10.0)
主要功能: 主要功能:
- 液氮密度、焓、内能计算。 - 液氮密度、焓、内能计算。
- 氮气饱和压力和饱和蒸气内能计算。 - 根据液氮比内能和压力反推液相温度。
- 氦气密度、焓、内能、比热计算。 - 氦气密度、焓、内能、比热计算。
- 建立液氮内能到温度的查表插值,加速 ODE 求解。 - 根据氦气压力和密度反推气枕温度。
- 计算氦气恒压密度温度导数 `(partial rho / partial T)_P`。
- 计算氦气恒压内能温度导数 `(partial u / partial T)_P`。
该模块依赖 `CoolProp`。 该模块依赖 `CoolProp`。
@@ -373,6 +375,7 @@ pytest -q
- 本模型当前将储箱分为液相区和气枕区两个区域,不是完整 CFD 模型。 - 本模型当前将储箱分为液相区和气枕区两个区域,不是完整 CFD 模型。
- 气枕区按纯氦气处理,物性由 CoolProp 计算。 - 气枕区按纯氦气处理,物性由 CoolProp 计算。
- 气枕温度 `T_ull` 是 ODE 状态量;氦气质量 `m_He` 和补气流量 `mdot_He` 是恒压条件下的派生结果。
- 液氮物性依赖 `CoolProp`,缺少该包会导致程序无法启动。 - 液氮物性依赖 `CoolProp`,缺少该包会导致程序无法启动。
- `results/` 目录默认被 `.gitignore` 忽略,仿真输出不会自动上传到 Git。 - `results/` 目录默认被 `.gitignore` 忽略,仿真输出不会自动上传到 Git。
- 建议每次修改参数后保存对应工况说明,避免不同仿真结果混在同一个输出目录中。 - 建议每次修改参数后保存对应工况说明,避免不同仿真结果混在同一个输出目录中。
+2 -1
View File
@@ -3,6 +3,7 @@
Configuration for the cryogenic LN2 tank simulation. Configuration for the cryogenic LN2 tank simulation.
Pure data module -- no functions, no side effects. Pure data module -- no functions, no side effects.
""" """
import math import math
# ---------- Tank geometry ---------- # ---------- Tank geometry ----------
@@ -29,7 +30,7 @@ MDOT_OUT_LN2 = 1.1895 # kg/s
T_IN_HE = 100.0 # K T_IN_HE = 100.0 # K
# ---------- Heat transfer ---------- # ---------- Heat transfer ----------
H_CONV_SURFACE = 50.0 # W/(m^2*K) liquid-to-ullage surface convection H_CONV_SURFACE = 0.0 # W/(m^2*K) liquid-to-ullage surface convection
T_ENV = 300.0 # K ambient temperature T_ENV = 300.0 # K ambient temperature
# ---------- Simulation control ---------- # ---------- Simulation control ----------
+13
View File
@@ -96,3 +96,16 @@ def he_u_from_rho(P, rho):
"""Recover He specific internal energy from pressure and density [J/kg].""" """Recover He specific internal energy from pressure and density [J/kg]."""
_he_state.update(CP.DmassP_INPUTS, rho, P) _he_state.update(CP.DmassP_INPUTS, rho, P)
return _he_state.umass() return _he_state.umass()
def he_drho_dT_const_p(T, P=_HE_P_REF):
"""He density temperature derivative at constant pressure [kg/(m^3*K)]."""
_he_state.update(CP.PT_INPUTS, P, T)
return _he_state.first_partial_deriv(CP.iDmass, CP.iT, CP.iP)
def he_du_dT_const_p(T, P=_HE_P_REF):
"""He internal-energy temperature derivative at constant pressure [J/(kg*K)]."""
_he_state.update(CP.PT_INPUTS, P, T)
return _he_state.first_partial_deriv(CP.iUmass, CP.iT, CP.iP)
+2 -1
View File
@@ -71,7 +71,7 @@ def run(tank, t_end, rtol=1e-8, atol=1e-10, max_step=10.0):
't': t, 't': t,
'm_liq': sol.y[0], 'm_liq': sol.y[0],
'U_liq': sol.y[1], 'U_liq': sol.y[1],
'U_ull': sol.y[2], 'U_ull': np.zeros(n),
'T_liq': np.zeros(n), 'T_liq': np.zeros(n),
'T_ull': np.zeros(n), 'T_ull': np.zeros(n),
'm_He': np.zeros(n), 'm_He': np.zeros(n),
@@ -94,6 +94,7 @@ def run(tank, t_end, rtol=1e-8, atol=1e-10, max_step=10.0):
history['T_liq'][i] = info['T_liq'] history['T_liq'][i] = info['T_liq']
history['T_ull'][i] = info['T_ull'] history['T_ull'][i] = info['T_ull']
history['U_ull'][i] = info['U_ull']
history['m_He'][i] = info['m_He'] history['m_He'][i] = info['m_He']
history['V_liq'][i] = info['V_liq'] history['V_liq'][i] = info['V_liq']
history['V_ull'][i] = info['V_ull'] history['V_ull'][i] = info['V_ull']
+55 -102
View File
@@ -2,7 +2,7 @@
""" """
CryoTank: two-zone (liquid + ullage) cryogenic tank model. CryoTank: two-zone (liquid + ullage) cryogenic tank model.
State vector y = [m_liq, U_liq, U_ull] (3 components). State vector y = [m_liq, U_liq, T_ull] (3 components).
Derived quantities (T, V, m_He, etc.) computed by derive(y). Derived quantities (T, V, m_He, etc.) computed by derive(y).
ODE right-hand side provided by rhs(t, y). ODE right-hand side provided by rhs(t, y).
""" """
@@ -10,7 +10,6 @@ import math
import warnings import warnings
import numpy as np import numpy as np
from scipy.optimize import brentq
from cryo_tank import properties as prop from cryo_tank import properties as prop
@@ -60,7 +59,7 @@ class CryoTank:
# Precompute constant inlet enthalpies # Precompute constant inlet enthalpies
self.h_in_ln2 = prop.ln2_h(T_in_ln2, P_work) self.h_in_ln2 = prop.ln2_h(T_in_ln2, P_work)
self.h_in_he = prop.he_h(T_in_he) self.h_in_he = prop.he_h(T_in_he, P_work)
# Net liquid flow (constant) # Net liquid flow (constant)
self.dm_liq_dt = mdot_in_ln2 - mdot_out_ln2 self.dm_liq_dt = mdot_in_ln2 - mdot_out_ln2
@@ -81,14 +80,13 @@ class CryoTank:
# Nitrogen evaporation is intentionally not modeled, so the ullage # Nitrogen evaporation is intentionally not modeled, so the ullage
# pressure is provided entirely by helium and the liquid sees the same # pressure is provided entirely by helium and the liquid sees the same
# tank pressure for property lookup. # tank pressure for property lookup.
self._T_ull_0 = T_init
self._m_He_0 = prop.he_rho(T_init, P_work) * V_ull_0 self._m_He_0 = prop.he_rho(T_init, P_work) * V_ull_0
self._U_ull_0 = self._m_He_0 * prop.he_u(T_init, P_work)
U_He_0 = self._m_He_0 * prop.he_u(T_init, P_work)
self._U_ull_0 = U_He_0
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, T_ull]."""
return np.array([self._m_liq_0, self._U_liq_0, self._U_ull_0]) return np.array([self._m_liq_0, self._U_liq_0, self._T_ull_0])
def wetted_areas(self, liquid_level): def wetted_areas(self, liquid_level):
"""Return (A_wet, A_dry) for the given liquid level [m].""" """Return (A_wet, A_dry) for the given liquid level [m]."""
@@ -101,86 +99,44 @@ 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_He, etc. Helium provides the full ullage fill_fraction, m_He, U_ull, P_He, etc. Helium provides the full
pressure in this no-evaporation model. ullage pressure in this no-evaporation model.
""" """
m_liq, U_liq, U_ull = y[0], y[1], y[2] m_liq, U_liq, T_ull = y[0], y[1], y[2]
# Liquid zone # Liquid zone
u_liq = U_liq / m_liq # specific internal energy u_liq = U_liq / m_liq
T_liq = prop.ln2_T_from_u(u_liq, self.P_work) T_liq = prop.ln2_T_from_u(u_liq, self.P_work)
rho_liq = prop.ln2_rho(T_liq, self.P_work) rho_liq = prop.ln2_rho(T_liq, self.P_work)
V_liq = m_liq / rho_liq V_liq = m_liq / rho_liq
liquid_level = V_liq / self.A_cross liquid_level = V_liq / self.A_cross
fill_fraction = liquid_level / self.H_tank fill_fraction = liquid_level / self.H_tank
# Ullage zone # Ullage zone: pure helium at tank pressure. T_ull is the ODE state;
# m_He is the amount required to maintain P_work in the current volume.
V_ull = self.V_total - V_liq V_ull = self.V_total - V_liq
# Solve T_ull from ullage energy
T_ull = self._solve_ullage_temperature(U_ull, V_ull)
# No nitrogen evaporation: helium provides all gas pressure.
P_He = self.P_work P_He = self.P_work
m_He = prop.he_rho(T_ull, P_He) * V_ull rho_He = prop.he_rho(T_ull, P_He)
m_He = rho_He * V_ull
U_ull = m_He * prop.he_u(T_ull, P_He)
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, 'rho_He': rho_He,
'm_He': m_He, 'P_He': P_He, 'm_He': m_He, 'U_ull': U_ull, 'P_He': P_He,
} }
def _solve_ullage_temperature(self, U_ull, V_ull):
"""Solve pure-He ullage temperature using CoolProp EOS.
At fixed tank pressure, T is obtained from:
U_ull = rho_He(P_work, T) * V_ull * u_He(P_work, T)
"""
def residual(T):
rho = prop.he_rho(T, self.P_work)
u = prop.he_u(T, self.P_work)
return rho * V_ull * u - U_ull
T_grid = np.geomspace(20.0, 1000.0, 160)
T_prev = T_grid[0]
f_prev = residual(T_prev)
if abs(f_prev) < 1e-8:
return float(T_prev)
best_T = T_prev
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
warnings.warn(
"Unable to bracket ullage temperature from CoolProp He state: "
f"U_ull={U_ull:.6g}, V_ull={V_ull:.6g}; "
f"using nearest T={best_T:.6g} K"
)
return float(best_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, dT_ull/dt].
This is called by scipy.integrate.solve_ivp. This is called by scipy.integrate.solve_ivp.
""" """
info = self.derive(y) info = self.derive(y)
m_liq = y[0]
T_liq = info['T_liq'] T_liq = info['T_liq']
T_ull = info['T_ull'] T_ull = info['T_ull']
V_ull = info['V_ull']
rho_liq = info['rho_liq']
liquid_level = info['liquid_level'] liquid_level = info['liquid_level']
m_He = info['m_He']
# --- Heat transfer --- # --- Heat transfer ---
Q_liq_to_ull = self.h_conv * self.A_cross * (T_liq - T_ull) Q_liq_to_ull = self.h_conv * self.A_cross * (T_liq - T_ull)
@@ -191,9 +147,6 @@ class CryoTank:
Q_leak_liq = Q_leak * A_wet / A_total if A_total > 0 else 0.0 Q_leak_liq = Q_leak * A_wet / A_total if A_total > 0 else 0.0
Q_leak_ull = Q_leak * A_dry / A_total if A_total > 0 else 0.0 Q_leak_ull = Q_leak * A_dry / A_total if A_total > 0 else 0.0
# --- He flow rate (analytical, per spec Section 2.6) ---
mdot_He = self._solve_he_flow_rate(info, Q_liq_to_ull, Q_leak_ull)
# --- Liquid zone --- # --- Liquid zone ---
dm_liq_dt = self.dm_liq_dt # constant: mdot_in - mdot_out dm_liq_dt = self.dm_liq_dt # constant: mdot_in - mdot_out
h_liq = prop.ln2_h(T_liq, self.P_work) h_liq = prop.ln2_h(T_liq, self.P_work)
@@ -203,59 +156,59 @@ class CryoTank:
+ Q_leak_liq) + Q_leak_liq)
# --- Ullage zone --- # --- Ullage zone ---
dU_ull_dt = mdot_He * self.h_in_he + Q_liq_to_ull + Q_leak_ull dT_ull_dt, _ = self._solve_ullage_temperature_rate(
info, Q_liq_to_ull, Q_leak_ull
)
return np.array([dm_liq_dt, dU_liq_dt, dU_ull_dt]) return np.array([dm_liq_dt, dU_liq_dt, dT_ull_dt])
def _solve_he_flow_rate(self, info, Q_liq_to_ull, Q_leak_ull): def _he_mass_energy_partials(self, info):
"""Solve He inlet mass flow from constant-pressure CoolProp EOS. """Return local partials for m(T,V) and U(T,V) at constant pressure."""
The ullage is pure He at P_work. With V_ull changing due to liquid
volume change, m_dot_He is found by differentiating:
m(T, V) = rho(P_work, T) * V
U(T, V) = m(T, V) * u(P_work, T)
and combining it with dU/dt = m_dot_He*h_in + Q_ull.
"""
T_ull = info['T_ull'] T_ull = info['T_ull']
V_ull = info['V_ull'] V_ull = info['V_ull']
rho_liq = info['rho_liq'] rho = info['rho_He']
u = prop.he_u(T_ull, self.P_work)
drho_dT = prop.he_drho_dT_const_p(T_ull, self.P_work)
du_dT = prop.he_du_dT_const_p(T_ull, self.P_work)
m_T = V_ull * drho_dT
m_V = rho
U_T = V_ull * (u * drho_dT + rho * du_dT)
U_V = rho * u
return m_T, U_T, m_V, U_V
def _solve_ullage_temperature_rate(self, info, Q_liq_to_ull, Q_leak_ull):
"""Solve dT_ull/dt and He inlet flow from constant-pressure EOS.
T_ull is the ODE state. The pure-He ullage is maintained at P_work,
so m_He = rho(P_work, T_ull) * V_ull is a derived quantity.
"""
rho_liq = info['rho_liq']
dV_ull_dt = -self.dm_liq_dt / rho_liq dV_ull_dt = -self.dm_liq_dt / rho_liq
Q_ull_no_he = Q_liq_to_ull + Q_leak_ull Q_ull_no_he = Q_liq_to_ull + Q_leak_ull
def state_at(T, V): m_T, U_T, m_V, U_V = self._he_mass_energy_partials(info)
rho = prop.he_rho(T, self.P_work)
m = rho * V
U = m * prop.he_u(T, self.P_work)
return m, U
dT = max(1e-3, abs(T_ull) * 1e-5)
dV = max(1e-8, abs(V_ull) * 1e-5)
m_plus, U_plus = state_at(T_ull + dT, V_ull)
m_minus, U_minus = state_at(max(20.0, T_ull - dT), V_ull)
actual_dT = (T_ull + dT) - max(20.0, T_ull - dT)
m_T = (m_plus - m_minus) / actual_dT
U_T = (U_plus - U_minus) / actual_dT
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 denominator = U_T - self.h_in_he * m_T
if abs(denominator) < 1e-30: if abs(denominator) < 1e-30:
return 0.0 return 0.0, 0.0
dT_dt = (Q_ull_no_he - (U_V - self.h_in_he * m_V) * dV_ull_dt) / denominator dT_ull_dt = (Q_ull_no_he
mdot_He = m_T * dT_dt + m_V * dV_ull_dt - (U_V + self.P_work - self.h_in_he * m_V)
* dV_ull_dt) / denominator
mdot_He = m_T * dT_ull_dt + m_V * dV_ull_dt
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); "
"clamping to 0. Pressure may drift above target." "clamping reported flow to 0. Pressure control may be invalid."
) )
mdot_He = 0.0 mdot_He = 0.0
return dT_ull_dt, mdot_He
def _solve_he_flow_rate(self, info, Q_liq_to_ull, Q_leak_ull):
"""Return derived He inlet mass flow for the current state [kg/s]."""
_, mdot_He = self._solve_ullage_temperature_rate(
info, Q_liq_to_ull, Q_leak_ull
)
return mdot_He return mdot_He