refactor: organize src modules by category

This commit is contained in:
lujingze committed 2026-06-04 06:42:29 +00:00
1 parent d57c632db8
commit 4a22ef1334
20 files changed
+194 -144

No files matched your search

+300
View File
@@ -0,0 +1,300 @@
# src/tank_pipe/output.py
"""
Output helpers: persistence (.npz), static plots (.png/.html), animation (.gif).
Uses matplotlib's Agg backend so it works in headless environments.
The PillowWriter is used for GIF output to avoid an ffmpeg dependency.
"""
import html
import os
import numpy as np
import matplotlib
matplotlib.use("Agg") # headless-safe; must be set before pyplot import
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation, PillowWriter
def _pipe_primitives(history, gamma, R_gas):
W_hist = history['W_hist']
rho = W_hist[:, 0, :]
u = W_hist[:, 1, :] / rho
P = (gamma - 1) * (W_hist[:, 2, :] - 0.5 * rho * u ** 2)
T = P / (rho * R_gas)
a = np.sqrt(gamma * P / rho)
Ma = u / a
return rho, u, P, T, a, Ma
def save_history(history, pipe, path, gamma, R_gas):
"""
Persist the full simulation history + pipe geometry to a .npz file.
Loadable later with:
d = np.load("results/history.npz")
rho = d['W_hist'][:, 0, :]
u = d['W_hist'][:, 1, :] / rho
P = (d['gamma'] - 1) * (d['W_hist'][:, 2, :] - 0.5 * rho * u**2)
"""
dirname = os.path.dirname(path)
if dirname:
os.makedirs(dirname, exist_ok=True)
np.savez_compressed(
path,
t=history['t'],
P1=history['P1'], T1=history['T1'],
P2=history['P2'], T2=history['T2'],
W_hist=history['W_hist'],
x=pipe.x_centers,
dx=pipe.dx,
area=pipe.area,
gamma=gamma,
R_gas=R_gas,
)
def plot_tank_pressure(history, path):
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(history['t'], history['P1'] / 1e6, label="Tank 1 (high pressure)")
ax.plot(history['t'], history['P2'] / 1e6, label="Tank 2 (low pressure)")
ax.set_xlabel("Time [s]")
ax.set_ylabel("Pressure [MPa]")
ax.set_title("Tank pressures vs time")
ax.grid(True)
ax.legend()
fig.tight_layout()
fig.savefig(path, dpi=120)
plt.close(fig)
def plot_tank_temperature(history, path):
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(history['t'], history['T1'], label="Tank 1 (high pressure)")
ax.plot(history['t'], history['T2'], label="Tank 2 (low pressure)")
ax.set_xlabel("Time [s]")
ax.set_ylabel("Temperature [K]")
ax.set_title("Tank temperatures vs time")
ax.grid(True)
ax.legend()
fig.tight_layout()
fig.savefig(path, dpi=120)
plt.close(fig)
def make_pipe_animation(history, pipe, path, gamma, R_gas, stride=10):
"""
Render a GIF of the pipe's P(x), u(x), T(x) evolution over time.
Uses PillowWriter so no ffmpeg is needed.
"""
t = history['t']
x = pipe.x_centers
n_steps = history['W_hist'].shape[0]
# Frame indices: every `stride`th snapshot, plus the final one
frames = list(range(0, n_steps, stride))
if frames[-1] != n_steps - 1:
frames.append(n_steps - 1)
# Precompute primitives for all frames in one vectorized pass
_, u, P, T, _, Ma = _pipe_primitives(history, gamma, R_gas)
fig, axes = plt.subplots(4, 1, figsize=(10, 12), sharex=True)
# Pressure subplot
line_P, = axes[0].plot(x, P[0] / 1e6)
axes[0].set_ylabel("P [MPa]")
axes[0].set_ylim(P.min() / 1e6 * 0.95, P.max() / 1e6 * 1.05)
axes[0].grid(True)
# Velocity subplot
line_u, = axes[1].plot(x, u[0])
axes[1].set_ylabel("u [m/s]")
u_min, u_max = float(u.min()), float(u.max())
pad = max(1.0, 0.05 * (u_max - u_min) if u_max > u_min else 1.0)
axes[1].set_ylim(u_min - pad, u_max + pad)
axes[1].grid(True)
# Temperature subplot
line_T, = axes[2].plot(x, T[0])
axes[2].set_ylabel("T [K]")
axes[2].set_ylim(T.min() * 0.95, T.max() * 1.05)
axes[2].grid(True)
# Mach number subplot
line_Ma, = axes[3].plot(x, Ma[0])
axes[3].set_ylabel("Mach [-]")
axes[3].set_xlabel("x [m]")
Ma_min, Ma_max = float(Ma.min()), float(Ma.max())
pad_Ma = max(0.05, 0.05 * (Ma_max - Ma_min) if Ma_max > Ma_min else 0.05)
axes[3].set_ylim(Ma_min - pad_Ma, Ma_max + pad_Ma)
axes[3].grid(True)
title = fig.suptitle("")
def update(frame_idx):
line_P.set_ydata(P[frame_idx] / 1e6)
line_u.set_ydata(u[frame_idx])
line_T.set_ydata(T[frame_idx])
line_Ma.set_ydata(Ma[frame_idx])
title.set_text(
f"t = {t[frame_idx]:.5f} s (step {frame_idx + 1}/{n_steps})"
)
return line_P, line_u, line_T, line_Ma, title
anim = FuncAnimation(fig, update, frames=frames, interval=50, blit=False)
dirname = os.path.dirname(path)
if dirname:
os.makedirs(dirname, exist_ok=True)
anim.save(path, writer=PillowWriter(fps=20))
plt.close(fig)
def plot_pipe_final_profiles(history, pipe, path, gamma, R_gas):
"""Plot final pipe P(x), u(x), T(x), and Mach(x) as a static PNG."""
x = pipe.x_centers
_, u, P, T, _, Ma = _pipe_primitives(history, gamma, R_gas)
fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharex=True)
axes = axes.ravel()
axes[0].plot(x, P[-1] / 1e6)
axes[0].set_ylabel("P [MPa]")
axes[0].set_title("Final pressure profile")
axes[0].grid(True)
axes[1].plot(x, u[-1])
axes[1].set_ylabel("u [m/s]")
axes[1].set_title("Final velocity profile")
axes[1].grid(True)
axes[2].plot(x, T[-1])
axes[2].set_xlabel("x [m]")
axes[2].set_ylabel("T [K]")
axes[2].set_title("Final temperature profile")
axes[2].grid(True)
axes[3].plot(x, Ma[-1])
axes[3].axhline(1.0, color="r", linestyle="--", linewidth=1, label="Mach 1")
axes[3].set_xlabel("x [m]")
axes[3].set_ylabel("Mach [-]")
axes[3].set_title("Final Mach profile")
axes[3].grid(True)
axes[3].legend()
fig.tight_layout()
fig.savefig(path, dpi=120)
plt.close(fig)
def write_summary_report(history, pipe, path, gamma, R_gas, config):
"""Write an HTML summary report for the latest simulation outputs."""
t = history['t']
P1 = history['P1']
T1 = history['T1']
P2 = history['P2']
T2 = history['T2']
W_hist = history['W_hist']
x = pipe.x_centers
_, u, P, T, _, Ma = _pipe_primitives(history, gamma, R_gas)
V1 = config['V1']
V2 = config['V2']
m1_init = P1[0] * V1 / (R_gas * T1[0])
m1_final = P1[-1] * V1 / (R_gas * T1[-1])
m2_init = P2[0] * V2 / (R_gas * T2[0])
m2_final = P2[-1] * V2 / (R_gas * T2[-1])
mpipe_init = float(np.sum(W_hist[0, 0, :] * pipe.area * pipe.dx))
mpipe_final = float(np.sum(W_hist[-1, 0, :] * pipe.area * pipe.dx))
m_total_init = m1_init + m2_init + mpipe_init
m_total_final = m1_final + m2_final + mpipe_final
rel_err_m = abs(m_total_final - m_total_init) / m_total_init
U1_init = P1[0] * V1 / (gamma - 1)
U1_final = P1[-1] * V1 / (gamma - 1)
U2_init = P2[0] * V2 / (gamma - 1)
U2_final = P2[-1] * V2 / (gamma - 1)
Upipe_init = float(np.sum(W_hist[0, 2, :] * pipe.area * pipe.dx))
Upipe_final = float(np.sum(W_hist[-1, 2, :] * pipe.area * pipe.dx))
U_total_init = U1_init + U2_init + Upipe_init
U_total_final = U1_final + U2_final + Upipe_final
rel_err_U = abs(U_total_final - U_total_init) / U_total_init
idx_ma = np.unravel_index(np.argmax(Ma), Ma.shape)
idx_p = np.unravel_index(np.argmax(P), P.shape)
sections = [
("Simulation setup", [
("Gamma", f"{gamma:.3f}"),
("R_gas", f"{R_gas:.3f} J/(kg K)"),
("Tank 1", f"V={V1:.3f} m^3, P0={config['P1_INIT']/1e6:.6f} MPa, T0={config['T1_INIT']:.3f} K"),
("Tank 2", f"V={V2:.3f} m^3, P0={config['P2_INIT']/1e6:.6f} MPa, T0={config['T2_INIT']:.3f} K"),
("Pipe", f"L={config['L']:.3f} m, D={config['D']*1e3:.3f} mm, N={config['N_CELLS']}"),
("Run control", f"t_end={config['T_END']:.6f} s, CFL={config['CFL']:.3f}, steps={len(t)}"),
]),
("Tank states", [
("Tank 1 pressure", f"{P1[0]/1e6:.6f} -> {P1[-1]/1e6:.6f} MPa"),
("Tank 2 pressure", f"{P2[0]/1e6:.6f} -> {P2[-1]/1e6:.6f} MPa"),
("Tank 1 temperature", f"{T1[0]:.6f} -> {T1[-1]:.6f} K"),
("Tank 2 temperature", f"{T2[0]:.6f} -> {T2[-1]:.6f} K"),
]),
("Pipe extrema", [
("Max pressure", f"{P[idx_p]/1e6:.6f} MPa at t={t[idx_p[0]]:.6e} s, x={x[idx_p[1]]:.6f} m"),
("Max velocity", f"{u.max():.6f} m/s"),
("Min / max temperature", f"{T.min():.6f} / {T.max():.6f} K"),
("Max Mach", f"{Ma[idx_ma]:.6f} at t={t[idx_ma[0]]:.6e} s, x={x[idx_ma[1]]:.6f} m"),
("Final Mach range", f"{Ma[-1].min():.6f} -> {Ma[-1].max():.6f}"),
("Final supersonic cells", f"{int(np.sum(Ma[-1] > 1.0))} / {Ma.shape[1]}"),
]),
("Conservation check", [
("Total mass", f"{m_total_init:.12e} -> {m_total_final:.12e} kg (rel err {rel_err_m:.3e})"),
("Total energy", f"{U_total_init:.12e} -> {U_total_final:.12e} J (rel err {rel_err_U:.3e})"),
]),
]
parts = [
"<!doctype html>",
"<html lang='en'>",
"<head>",
"<meta charset='utf-8'>",
"<title>Pipe system simulation summary</title>",
"<style>",
"body { font-family: Arial, sans-serif; margin: 24px; line-height: 1.45; }",
"h1, h2 { margin-bottom: 0.3em; }",
"table { border-collapse: collapse; width: 100%; margin: 12px 0 24px; }",
"th, td { border: 1px solid #ccc; padding: 8px 10px; text-align: left; vertical-align: top; }",
"th { background: #f5f5f5; width: 28%; }",
"img { max-width: 100%; height: auto; border: 1px solid #ddd; margin: 8px 0 24px; }",
"code { background: #f5f5f5; padding: 1px 4px; }",
"</style>",
"</head>",
"<body>",
"<h1>Pipe system simulation summary</h1>",
f"<p>Generated from <code>results/history.npz</code>. Final simulation time: {t[-1]:.6f} s.</p>",
]
for title, rows in sections:
parts.append(f"<h2>{html.escape(title)}</h2>")
parts.append("<table>")
for key, value in rows:
parts.append(
f"<tr><th>{html.escape(str(key))}</th><td>{html.escape(str(value))}</td></tr>"
)
parts.append("</table>")
parts.extend([
"<h2>Figures</h2>",
"<p><img src='tank_pressure.png' alt='Tank pressure history'></p>",
"<p><img src='tank_temperature.png' alt='Tank temperature history'></p>",
"<p><img src='pipe_final_profiles.png' alt='Final pipe profiles'></p>",
"<p>Animation: <a href='pipe_animation.gif'>pipe_animation.gif</a></p>",
"</body>",
"</html>",
])
dirname = os.path.dirname(path)
if dirname:
os.makedirs(dirname, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(parts))