代码仓库移植

This commit is contained in:
ljz committed 2026-06-03 15:41:04 +08:00
commit 0f57ba94f3
44 files changed
+7119

No files matched your search

View File
Whitespace-only changes.
View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(os.path.dirname(_HERE))
sys.path.insert(0, os.path.join(_ROOT, "src"))
+47
View File
@@ -0,0 +1,47 @@
# tests/cryo_tank/test_heat_leak.py
"""Tests for heat leak models."""
from cryo_tank.heat_leak import HeatLeakModel, MLIHeatLeak, FoamHeatLeak
class TestMLIHeatLeak:
def test_mli_returns_constant_heat_flux(self):
model = MLIHeatLeak(A_total=3.306, q_mli=1.5)
Q = model.compute(T_inner=78.0, T_env=300.0)
assert abs(Q - 3.306 * 1.5) < 1e-10
def test_mli_default_q_is_1(self):
model = MLIHeatLeak(A_total=3.306)
Q = model.compute(T_inner=78.0, T_env=300.0)
assert abs(Q - 3.306) < 1e-10
def test_mli_independent_of_temperature(self):
model = MLIHeatLeak(A_total=3.306, q_mli=2.0)
Q1 = model.compute(T_inner=78.0, T_env=300.0)
Q2 = model.compute(T_inner=80.0, T_env=250.0)
assert abs(Q1 - Q2) < 1e-10
class TestFoamHeatLeak:
def test_foam_constant_k(self):
model = FoamHeatLeak(A_total=3.306, k_eff=0.03, delta=0.05)
Q = model.compute(T_inner=78.0, T_env=300.0)
expected = 3.306 * 0.03 * (300.0 - 78.0) / 0.05
assert abs(Q - expected) < 1e-6
def test_foam_callable_k(self):
def k_func(T):
return 0.01 + 0.0001 * T # linear k(T)
model = FoamHeatLeak(A_total=3.306, k_eff=k_func, delta=0.05)
Q = model.compute(T_inner=78.0, T_env=300.0)
T_mean = (300.0 + 78.0) / 2.0
k_at_mean = k_func(T_mean)
expected = 3.306 * k_at_mean * (300.0 - 78.0) / 0.05
assert abs(Q - expected) < 1e-6
def test_foam_zero_dT_gives_zero_Q(self):
model = FoamHeatLeak(A_total=3.306, k_eff=0.03, delta=0.05)
Q = model.compute(T_inner=300.0, T_env=300.0)
assert abs(Q) < 1e-10
+79
View File
@@ -0,0 +1,79 @@
# tests/cryo_tank/test_integration.py
"""Integration tests for the cryogenic tank simulation."""
import numpy as np
from cryo_tank.tank_model import CryoTank
from cryo_tank.heat_leak import MLIHeatLeak
from cryo_tank.solver import run
from cryo_tank.config import (
V_TOTAL, H_TANK, P_WORKING, T_INIT, ULLAGE_FRACTION,
MDOT_IN_LN2, T_IN_LN2, MDOT_OUT_LN2, T_IN_HE,
H_CONV_SURFACE, T_ENV, A_TOTAL,
)
def _make_tank(**overrides):
"""Create a tank with default config, allowing overrides."""
kw = dict(
V_total=V_TOTAL, H_tank=H_TANK,
P_work=P_WORKING,
T_init=T_INIT, ullage_fraction=ULLAGE_FRACTION,
mdot_in_ln2=MDOT_IN_LN2, T_in_ln2=T_IN_LN2,
mdot_out_ln2=MDOT_OUT_LN2,
T_in_he=T_IN_HE,
h_conv=H_CONV_SURFACE, T_env=T_ENV,
heat_leak_model=MLIHeatLeak(A_total=A_TOTAL, q_mli=1.0),
)
kw.update(overrides)
return CryoTank(**kw)
class TestMassConservation:
def test_liquid_mass_change_matches_net_flow(self):
"""Over a short run, dm_liq should equal (mdot_in - mdot_out) * dt."""
tank = _make_tank()
history = run(tank, t_end=10.0, max_step=1.0)
m_liq_0 = history['m_liq'][0]
m_liq_f = history['m_liq'][-1]
t_f = history['t'][-1]
expected_dm = (MDOT_IN_LN2 - MDOT_OUT_LN2) * t_f
actual_dm = m_liq_f - m_liq_0
rel_err = abs(actual_dm - expected_dm) / abs(expected_dm)
assert rel_err < 1e-6, f"Mass conservation error: rel_err={rel_err:.2e}"
class TestSteadyState:
def test_zero_flow_zero_leak_is_static(self):
"""With no flow and no heat leak, state should not change."""
tank = _make_tank(
mdot_in_ln2=0.0,
mdot_out_ln2=0.0,
h_conv=0.0,
heat_leak_model=MLIHeatLeak(A_total=A_TOTAL, q_mli=0.0),
)
history = run(tank, t_end=100.0, max_step=10.0)
T_liq = history['T_liq']
T_ull = history['T_ull']
assert abs(T_liq[-1] - T_liq[0]) < 0.01, f"T_liq drifted: {T_liq[0]:.3f} -> {T_liq[-1]:.3f}"
assert abs(T_ull[-1] - T_ull[0]) < 0.1, f"T_ull drifted: {T_ull[0]:.3f} -> {T_ull[-1]:.3f}"
class TestPhysicalBehavior:
def test_liquid_level_decreases(self):
"""With net outflow, liquid level should decrease."""
tank = _make_tank()
history = run(tank, t_end=60.0, max_step=5.0)
assert history['fill_fraction'][-1] < history['fill_fraction'][0]
def test_he_flow_rate_positive(self):
"""He should always flow in (pressurization), not out."""
tank = _make_tank()
history = run(tank, t_end=60.0, max_step=5.0)
assert np.all(history['mdot_He'] >= -1e-10) # allow tiny numerical noise
+67
View File
@@ -0,0 +1,67 @@
# tests/cryo_tank/test_properties.py
"""Tests for CoolProp property wrappers."""
import pytest
import sys
sys.path.insert(0, "src")
from cryo_tank.properties import (
ln2_rho, ln2_h, ln2_u, ln2_T_from_u,
n2_vapor_u, n2_sat_pressure,
he_u, he_h, he_cp, he_cv,
)
from cryo_tank.config import P_WORKING
class TestLN2Properties:
"""Liquid nitrogen properties at P = 0.17 MPa."""
def test_ln2_density_at_78K(self):
rho = ln2_rho(78.0, P_WORKING)
assert 800 < rho < 810 # ~803 kg/m3
def test_ln2_enthalpy_at_77K(self):
h = ln2_h(77.0, P_WORKING)
assert -130000 < h < -110000 # ~-122695 J/kg
def test_ln2_internal_energy_at_78K(self):
u = ln2_u(78.0, P_WORKING)
assert -130000 < u < -110000 # ~-120865 J/kg
def test_ln2_T_from_u_roundtrip(self):
T_orig = 78.0
u = ln2_u(T_orig, P_WORKING)
T_recovered = ln2_T_from_u(u, P_WORKING)
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:
"""Helium (ideal gas) properties."""
def test_he_cp_near_5196(self):
cp = he_cp()
assert abs(cp - 5196.2) < 10 # monatomic ideal gas
def test_he_cv_near_3117(self):
cv = he_cv()
assert abs(cv - 3117.1) < 10
def test_he_enthalpy_at_100K(self):
h = he_h(100.0)
# CoolProp gives ~524762 J/kg at 100K
assert 500000 < h < 550000
def test_he_internal_energy_at_78K(self):
u = he_u(78.0)
assert 200000 < u < 280000 # ~247932 J/kg
+78
View File
@@ -0,0 +1,78 @@
# tests/cryo_tank/test_tank_model.py
"""Tests for CryoTank model."""
import pytest
import numpy as np
from cryo_tank.tank_model import CryoTank
from cryo_tank.heat_leak import MLIHeatLeak
from cryo_tank.config import (
V_TOTAL, H_TANK, A_CROSS, A_TOTAL, P_WORKING,
T_INIT, ULLAGE_FRACTION,
MDOT_IN_LN2, T_IN_LN2, MDOT_OUT_LN2, T_IN_HE,
H_CONV_SURFACE, T_ENV,
)
def _make_tank():
"""Create a CryoTank with default config and MLI heat leak."""
heat_leak = MLIHeatLeak(A_total=A_TOTAL, q_mli=1.0)
return CryoTank(
V_total=V_TOTAL, H_tank=H_TANK,
P_work=P_WORKING,
T_init=T_INIT, ullage_fraction=ULLAGE_FRACTION,
mdot_in_ln2=MDOT_IN_LN2, T_in_ln2=T_IN_LN2,
mdot_out_ln2=MDOT_OUT_LN2,
T_in_he=T_IN_HE,
h_conv=H_CONV_SURFACE, T_env=T_ENV,
heat_leak_model=heat_leak,
)
class TestGeometry:
def test_cross_section_area(self):
tank = _make_tank()
assert abs(tank.A_cross - 0.8402) < 0.001
def test_total_surface_area(self):
tank = _make_tank()
assert abs(tank.A_total - 3.305) < 0.01
def test_wetted_area_at_70_percent_fill(self):
tank = _make_tank()
level = 0.7 * H_TANK # 0.35 m
A_wet, A_dry = tank.wetted_areas(level)
# A_wet = bottom cap + side * level
expected_wet = A_CROSS + np.pi * tank.D * level
assert abs(A_wet - expected_wet) < 0.01
assert abs(A_wet + A_dry - A_TOTAL) < 0.01
class TestInitialState:
def test_initial_liquid_mass(self):
tank = _make_tank()
y0 = tank.initial_state()
m_liq = y0[0]
# rho_LN2(78K, 0.17MPa) ~ 803.3 kg/m3, V_liq = 0.2941 m3
assert 235 < m_liq < 237 # ~236.25 kg
def test_initial_fill_fraction(self):
tank = _make_tank()
y0 = tank.initial_state()
info = tank.derive(y0)
assert abs(info['fill_fraction'] - 0.70) < 0.01
def test_initial_temperatures(self):
tank = _make_tank()
y0 = tank.initial_state()
info = tank.derive(y0)
assert abs(info['T_liq'] - T_INIT) < 0.1
assert abs(info['T_ull'] - T_INIT) < 1.0
def test_initial_pressure_components_sum_to_P_working(self):
tank = _make_tank()
y0 = tank.initial_state()
info = tank.derive(y0)
P_N2 = info['P_N2']
P_He = info['P_He']
assert abs(P_N2 + P_He - P_WORKING) / P_WORKING < 1e-6
+55
View File
@@ -0,0 +1,55 @@
"""
End-to-end conservation tests: after a short run, total system mass
and total system energy must be conserved to machine precision. These
are the load-bearing tests for the "flux doubling" coupling mechanism.
"""
import numpy as np
import pytest
from tank import Tank
from pipe import Pipe
from solver import run
GAMMA = 1.4
R_GAS = 287.0
def _build_scenario():
"""Default blowdown scenario at smaller scale — same physics, short run."""
tank1 = Tank(V=5.0, P_init=10e6, T_init=300.0, gamma=GAMMA, R_gas=R_GAS)
tank2 = Tank(V=10.0, P_init=101325.0, T_init=300.0, gamma=GAMMA, R_gas=R_GAS)
pipe = Pipe(L=1.0, D=5e-3, N=20,
P_init=101325.0, T_init=300.0, gamma=GAMMA, R_gas=R_GAS)
return tank1, tank2, pipe
def _total_mass(tank1, tank2, pipe):
return tank1.mass + tank2.mass + float(np.sum(pipe.W[0, :] * pipe.area * pipe.dx))
def _total_energy(tank1, tank2, pipe):
return tank1.U + tank2.U + float(np.sum(pipe.W[2, :] * pipe.area * pipe.dx))
def test_total_mass_conserved_over_short_run():
tank1, tank2, pipe = _build_scenario()
m_init = _total_mass(tank1, tank2, pipe)
run(tank1, tank2, pipe, t_end=1e-3, cfl=0.5)
m_final = _total_mass(tank1, tank2, pipe)
rel_err = abs(m_final - m_init) / m_init
assert rel_err < 1e-10, (
f"Total mass not conserved: m_init={m_init:.6e}, "
f"m_final={m_final:.6e}, rel_err={rel_err:.2e}"
)
def test_total_energy_conserved_over_short_run():
tank1, tank2, pipe = _build_scenario()
U_init = _total_energy(tank1, tank2, pipe)
run(tank1, tank2, pipe, t_end=1e-3, cfl=0.5)
U_final = _total_energy(tank1, tank2, pipe)
rel_err = abs(U_final - U_init) / U_init
assert rel_err < 1e-10, (
f"Total energy not conserved: U_init={U_init:.6e}, "
f"U_final={U_final:.6e}, rel_err={rel_err:.2e}"
)
+59
View File
@@ -0,0 +1,59 @@
# tests/test_pipe.py
import numpy as np
import pytest
from pipe import Pipe
GAMMA = 1.4
R_GAS = 287.0
def test_pipe_uniform_initialization_all_cells_identical():
"""
Initial state: uniform P, T, u=0 across all N cells.
All cells should have identical W. primitives() should round-trip
back to u=0 and P=P_init.
"""
pipe = Pipe(L=1.0, D=5e-3, N=20,
P_init=1e5, T_init=300.0,
gamma=GAMMA, R_gas=R_GAS)
# All cells identical
for i in range(1, pipe.N):
assert np.allclose(pipe.W[:, i], pipe.W[:, 0], rtol=1e-14)
rho, u, P, a = pipe.primitives()
rho_expected = 1e5 / (R_GAS * 300.0)
assert np.allclose(u, 0.0)
assert np.allclose(P, 1e5, rtol=1e-10)
assert np.allclose(rho, rho_expected, rtol=1e-10)
# Sound speed a = sqrt(gamma * P / rho)
a_expected = np.sqrt(GAMMA * 1e5 / rho_expected)
assert np.allclose(a, a_expected, rtol=1e-10)
# Geometric sanity
assert pipe.dx == 1.0 / 20
assert pipe.x_centers.shape == (20,)
assert abs(pipe.x_centers[0] - 0.025) < 1e-14
assert abs(pipe.x_centers[-1] - 0.975) < 1e-14
def test_pipe_uniform_state_plus_matching_boundary_flux_is_static():
"""
If all cells have identical W (so all internal HLL fluxes are
identical to the physical flux F(W) = [0, P, 0] for u=0 state),
and we pass in boundary fluxes equal to [0, P, 0] as well, then
every difference (flux[i+1] - flux[i]) is zero, so W must not
change after one step. Verify to machine precision.
"""
pipe = Pipe(L=1.0, D=5e-3, N=20,
P_init=1e5, T_init=300.0,
gamma=GAMMA, R_gas=R_GAS)
W_before = pipe.W.copy()
# Boundary flux matching the static interior: [rho*u, rho*u^2+P, u*(E+P)]
# with u=0 -> [0, P, 0]
flux_boundary = np.array([0.0, 1e5, 0.0])
pipe.step(flux_boundary, flux_boundary, dt=1e-5)
assert np.allclose(pipe.W, W_before, atol=1e-6, rtol=1e-12)
+74
View File
@@ -0,0 +1,74 @@
# tests/test_riemann.py
import numpy as np
from riemann import hll_flux
GAMMA = 1.4
def _to_conservative(rho, u, P, gamma):
"""(rho, u, P) -> [rho, rho*u, rho*E] where E = e + u^2/2."""
return np.array([
rho,
rho * u,
P / (gamma - 1) + 0.5 * rho * u ** 2,
])
def _physical_flux(W, gamma):
"""F(W) = [rho*u, rho*u^2 + P, u*(rho*E + P)]."""
rho = W[0]
u = W[1] / rho
E = W[2]
P = (gamma - 1) * (E - 0.5 * rho * u ** 2)
return np.array([rho * u, rho * u ** 2 + P, u * (E + P)])
def test_hll_identical_states_returns_physical_flux():
"""
When W_L == W_R, HLL must return the exact physical flux F(W)
with zero numerical dissipation (the (W_R - W_L) term vanishes).
"""
W = _to_conservative(rho=1.2, u=50.0, P=2.5e5, gamma=GAMMA)
F = hll_flux(W, W, GAMMA)
expected = _physical_flux(W, GAMMA)
assert np.allclose(F, expected, rtol=1e-12), f"F={F}, expected={expected}"
def test_hll_equal_pressure_equal_energy_gives_exact_pressure_flux():
"""
Two stationary states (u=0) with same P but different rho:
- Both have the same energy density E = P/(gamma-1), so the HLL
(W_R - W_L)[2] term vanishes -> exact zero energy flux.
- F_L[1] = F_R[1] = P, and W_L[1] = W_R[1] = 0, so the momentum
flux is exactly P.
- The mass flux is NOT exactly zero for HLL (the density jump
triggers the (W_R - W_L)[0] dissipation term) — this is a known
HLL limitation for stationary contact discontinuities. We do
not assert on F[0] here.
"""
W_L = _to_conservative(rho=10.0, u=0.0, P=1e5, gamma=GAMMA)
W_R = _to_conservative(rho=1.0, u=0.0, P=1e5, gamma=GAMMA)
F = hll_flux(W_L, W_R, GAMMA)
assert abs(F[1] - 1e5) < 1e-6, f"momentum flux should equal P=1e5, got {F[1]}"
assert abs(F[2]) < 1e-8, f"energy flux should be exactly 0, got {F[2]}"
def test_hll_sod_shock_tube_directional_fluxes_all_positive():
"""
Classical Sod initial values:
left: (rho, u, P) = (1.0, 0, 1.0)
right: (rho, u, P) = (0.125, 0, 0.1)
The pressure gradient drives flow from left to right, so the
HLL flux at the interface should have all three components
strictly positive:
F[0] > 0 : mass flux rightward
F[1] > 0 : momentum flux rightward
F[2] > 0 : energy flux rightward
"""
W_L = _to_conservative(rho=1.0, u=0.0, P=1.0, gamma=GAMMA)
W_R = _to_conservative(rho=0.125, u=0.0, P=0.1, gamma=GAMMA)
F = hll_flux(W_L, W_R, GAMMA)
assert F[0] > 0, f"expected positive mass flux, got {F[0]}"
assert F[1] > 0, f"expected positive momentum flux, got {F[1]}"
assert F[2] > 0, f"expected positive energy flux, got {F[2]}"
+57
View File
@@ -0,0 +1,57 @@
# tests/test_tank.py
import numpy as np
import pytest
from tank import Tank
GAMMA = 1.4
R_GAS = 287.0
def test_tank_initial_state_matches_ideal_gas():
"""
Given P, T, V construct a Tank; mass and U should match ideal gas:
rho = P / (R * T)
mass = rho * V
U = P * V / (gamma - 1) (since u=0 inside tank)
T = (U / mass) * (gamma - 1) / R (round-trip)
"""
tank = Tank(V=5.0, P_init=10e6, T_init=300.0, gamma=GAMMA, R_gas=R_GAS)
rho_expected = 10e6 / (R_GAS * 300.0)
assert abs(tank.rho - rho_expected) < 1e-9
assert abs(tank.mass - rho_expected * 5.0) < 1e-6
assert abs(tank.U - 10e6 * 5.0 / (GAMMA - 1)) < 1e-3
assert abs(tank.P - 10e6) < 1e-3
assert abs(tank.T - 300.0) < 1e-9
def test_tank_ghost_state_is_stagnation_conservative_vector():
"""
ghost_state() should return [rho, 0, P/(gamma-1)]
(u_ghost = 0 per spec §2.3, so total energy density equals
internal energy density = P/(gamma-1)).
"""
tank = Tank(V=5.0, P_init=10e6, T_init=300.0, gamma=GAMMA, R_gas=R_GAS)
g = tank.ghost_state()
assert g.shape == (3,)
assert abs(g[0] - tank.rho) < 1e-12
assert g[1] == 0.0
assert abs(g[2] - 10e6 / (GAMMA - 1)) < 1e-3
def test_tank_apply_flux_outflow_reduces_mass_energy_and_pressure():
"""
apply_flux(mdot, edot, dt, sign=-1) should subtract mdot*dt from mass
and edot*dt from U. Derived P should decrease correspondingly.
"""
tank = Tank(V=5.0, P_init=10e6, T_init=300.0, gamma=GAMMA, R_gas=R_GAS)
mass_before = tank.mass
U_before = tank.U
P_before = tank.P
mdot = 1.0 # kg/s
edot = 5e5 # J/s (enthalpy rate)
dt = 1e-3
tank.apply_flux(mdot=mdot, edot=edot, dt=dt, sign=-1)
assert abs(tank.mass - (mass_before - mdot * dt)) < 1e-12
assert abs(tank.U - (U_before - edot * dt)) < 1e-9
assert tank.P < P_before