1427 lines
43 KiB
Markdown
1427 lines
43 KiB
Markdown
# 0D–1D Tank-Pipe Blowdown MVP Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a transient simulation of a high-pressure tank blowing down through a 1 m pipe (D=5 mm) into a low-pressure tank, using a 0D lumped-parameter model for each tank coupled to a 1D finite-volume Euler solver on the pipe via ghost-cell + HLL Riemann fluxes.
|
||
|
||
**Architecture:** 7 Python source modules (`config`, `riemann`, `tank`, `pipe`, `solver`, `output`, `main`) under `src/`, with `tests/` for pytest-based unit + integration tests. Test-driven workflow: each module is built by writing failing tests first, then the minimal implementation that passes them, then committing. The solver enforces mass/energy conservation via "flux doubling" — the same boundary flux vector updates both the pipe boundary cell and the adjacent tank.
|
||
|
||
**Tech Stack:** Python 3, NumPy, Matplotlib (with `PillowWriter` for GIF animation, avoiding ffmpeg dependency), pytest.
|
||
|
||
**Spec reference:** `docs/superpowers/specs/2026-04-14-0d-1d-tank-pipe-blowdown-mvp-design.md`
|
||
|
||
---
|
||
|
||
## File Layout (Target State)
|
||
|
||
```
|
||
pipe-system-simulation-test/
|
||
├── src/
|
||
│ ├── config.py # constants (gas props, geometry, ICs, numerics)
|
||
│ ├── riemann.py # hll_flux(W_L, W_R, gamma) pure function
|
||
│ ├── tank.py # Tank class (0D, state = mass + total internal energy U)
|
||
│ ├── pipe.py # Pipe class (1D, conservative W field, step())
|
||
│ ├── solver.py # run(tank1, tank2, pipe, t_end, cfl, ...) time loop
|
||
│ ├── output.py # save_history / plot_* / make_pipe_animation
|
||
│ └── main.py # entry: assemble → run → sanity check → save
|
||
├── tests/
|
||
│ ├── test_riemann.py # 3 tests
|
||
│ ├── test_tank.py # 3 tests
|
||
│ ├── test_pipe.py # 2 tests
|
||
│ └── test_integration.py # 2 tests (total mass / energy conservation)
|
||
├── conftest.py # sys.path hack so tests/ can import from src/
|
||
├── results/ # runtime outputs (.npz, .png, .gif) — created at runtime
|
||
└── docs/superpowers/
|
||
├── specs/2026-04-14-0d-1d-tank-pipe-blowdown-mvp-design.md
|
||
└── plans/2026-04-14-0d-1d-tank-pipe-blowdown-mvp.md (this file)
|
||
```
|
||
|
||
The `src/README.md`, `tests/`, `results/README.md`, `cases/README.md`, `scripts/README.md` existing scaffolding files are untouched; only `src/README.md` and friends are left as-is.
|
||
|
||
---
|
||
|
||
## Pre-flight: Dependencies
|
||
|
||
Verify Python 3 + required packages before starting any task. **Run this once at the beginning.**
|
||
|
||
```bash
|
||
python3 -c "import numpy, matplotlib, pytest; print(numpy.__version__, matplotlib.__version__, pytest.__version__)"
|
||
```
|
||
|
||
Expected: three version strings printed with no import error.
|
||
|
||
If `matplotlib` or `pytest` is missing:
|
||
```bash
|
||
pip install --user numpy matplotlib pytest
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Project Skeleton
|
||
|
||
Create the directory structure, `conftest.py` path shim, and `results/` directory. No Python code yet — just scaffolding so subsequent tasks can import cleanly.
|
||
|
||
**Files:**
|
||
- Create: `conftest.py`
|
||
- Create: `tests/` (directory only)
|
||
- Create: `results/` (directory only; add a `.gitkeep` so git tracks the folder)
|
||
|
||
- [ ] **Step 1: Create `conftest.py` at project root**
|
||
|
||
Write this file exactly (path shim so `tests/test_*.py` can do `from riemann import ...`):
|
||
|
||
```python
|
||
# conftest.py
|
||
import os
|
||
import sys
|
||
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, os.path.join(_HERE, "src"))
|
||
```
|
||
|
||
- [ ] **Step 2: Create empty `tests/` directory**
|
||
|
||
```bash
|
||
mkdir -p tests
|
||
```
|
||
|
||
- [ ] **Step 3: Create `results/` directory with `.gitkeep`**
|
||
|
||
```bash
|
||
mkdir -p results
|
||
touch results/.gitkeep
|
||
```
|
||
|
||
- [ ] **Step 4: Verify pytest can discover the (empty) tests directory**
|
||
|
||
```bash
|
||
pytest tests/ -v
|
||
```
|
||
|
||
Expected output: `no tests ran in X.XXs` (exit code 5 is fine — it means "no tests collected", which is what we want at this stage).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add conftest.py tests results/.gitkeep
|
||
git commit -m "scaffold: add conftest, tests/, results/ for MVP"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `src/config.py` — Constants & Scenario
|
||
|
||
A pure data module with all physical/numerical constants. Includes `assert` statements for parameter validation (per spec §6.1). No functions, no classes, no side effects.
|
||
|
||
**Files:**
|
||
- Create: `src/config.py`
|
||
|
||
- [ ] **Step 1: Write `src/config.py`**
|
||
|
||
```python
|
||
# src/config.py
|
||
"""
|
||
Physical, geometric, and numerical constants for the 0D-1D tank-pipe
|
||
blowdown MVP. Pure data module — no functions, no side effects.
|
||
"""
|
||
|
||
# ---------- Gas properties (ideal air-like) ----------
|
||
GAMMA = 1.4
|
||
R_GAS = 287.0 # J / (kg K)
|
||
|
||
# ---------- High-pressure tank (upstream, Tank 1) ----------
|
||
V1 = 5.0 # m^3
|
||
P1_INIT = 10e6 # Pa (10 MPa)
|
||
T1_INIT = 300.0 # K
|
||
|
||
# ---------- Low-pressure tank (downstream, Tank 2) ----------
|
||
V2 = 10.0 # m^3
|
||
P2_INIT = 101325.0 # Pa (1 atm)
|
||
T2_INIT = 300.0 # K
|
||
|
||
# ---------- Pipe geometry ----------
|
||
L = 1.0 # m
|
||
D = 5e-3 # m (5 mm)
|
||
N_CELLS = 20 # number of finite-volume cells
|
||
|
||
# ---------- Simulation control ----------
|
||
T_END = 0.1 # s
|
||
CFL = 0.5
|
||
|
||
# ---------- Output & animation ----------
|
||
ANIMATION_STRIDE = 10 # keep every Nth frame in the GIF
|
||
OUTPUT_DIR = "results"
|
||
|
||
# ---------- Parameter validation (per spec §6.1) ----------
|
||
assert GAMMA > 1, "GAMMA must be > 1"
|
||
assert R_GAS > 0, "R_GAS must be > 0"
|
||
assert V1 > 0 and V2 > 0, "tank volumes must be > 0"
|
||
assert L > 0, "L must be > 0"
|
||
assert D > 0, "D must be > 0"
|
||
assert N_CELLS >= 2, "N_CELLS must be >= 2"
|
||
assert P1_INIT > 0 and P2_INIT > 0, "initial pressures must be > 0"
|
||
assert T1_INIT > 0 and T2_INIT > 0, "initial temperatures must be > 0"
|
||
assert 0 < CFL <= 1, "CFL must be in (0, 1]"
|
||
assert T_END > 0, "T_END must be > 0"
|
||
assert ANIMATION_STRIDE >= 1, "ANIMATION_STRIDE must be >= 1"
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the module imports cleanly and assertions pass**
|
||
|
||
```bash
|
||
python3 -c "import sys; sys.path.insert(0,'src'); import config; print(config.GAMMA, config.N_CELLS, config.T_END)"
|
||
```
|
||
|
||
Expected output: `1.4 20 0.1`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/config.py
|
||
git commit -m "feat(config): add physical and numerical constants"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `src/riemann.py` — HLL Flux (TDD)
|
||
|
||
HLL Riemann solver as a pure function. Written TDD: 3 tests first, then implementation.
|
||
|
||
**Files:**
|
||
- Create: `tests/test_riemann.py`
|
||
- Create: `src/riemann.py`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/test_riemann.py`:
|
||
|
||
```python
|
||
# 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]}"
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
pytest tests/test_riemann.py -v
|
||
```
|
||
|
||
Expected: 3 failures with `ModuleNotFoundError: No module named 'riemann'`.
|
||
|
||
- [ ] **Step 3: Write the minimal implementation**
|
||
|
||
Create `src/riemann.py`:
|
||
|
||
```python
|
||
# src/riemann.py
|
||
"""
|
||
HLL Riemann flux for the 1D compressible Euler equations.
|
||
|
||
Conservative variable vector: W = [rho, rho*u, rho*E]
|
||
where E = e + u^2/2 is specific total energy,
|
||
e = P / (rho * (gamma - 1)) is specific internal energy.
|
||
|
||
Physical flux: F(W) = [rho*u, rho*u^2 + P, u*(rho*E + P)]
|
||
"""
|
||
|
||
import numpy as np
|
||
|
||
|
||
def hll_flux(W_L, W_R, gamma):
|
||
"""
|
||
Compute the HLL numerical flux at the interface between two states.
|
||
|
||
Parameters
|
||
----------
|
||
W_L, W_R : array-like of shape (3,)
|
||
Left and right conservative state vectors.
|
||
gamma : float
|
||
Ratio of specific heats.
|
||
|
||
Returns
|
||
-------
|
||
np.ndarray of shape (3,)
|
||
HLL numerical flux vector.
|
||
|
||
Raises
|
||
------
|
||
ValueError
|
||
If either state has non-positive density or pressure.
|
||
"""
|
||
# --- Recover primitives from left state ---
|
||
rho_L = W_L[0]
|
||
if rho_L <= 0:
|
||
raise ValueError(f"Non-positive density in W_L: rho={rho_L}, W_L={W_L}")
|
||
u_L = W_L[1] / rho_L
|
||
E_L = W_L[2] # total energy density (= rho * E)
|
||
p_L = (gamma - 1) * (E_L - 0.5 * rho_L * u_L ** 2)
|
||
if p_L <= 0:
|
||
raise ValueError(f"Non-positive pressure in W_L: p={p_L}, W_L={W_L}")
|
||
a_L = np.sqrt(gamma * p_L / rho_L)
|
||
|
||
# --- Recover primitives from right state ---
|
||
rho_R = W_R[0]
|
||
if rho_R <= 0:
|
||
raise ValueError(f"Non-positive density in W_R: rho={rho_R}, W_R={W_R}")
|
||
u_R = W_R[1] / rho_R
|
||
E_R = W_R[2]
|
||
p_R = (gamma - 1) * (E_R - 0.5 * rho_R * u_R ** 2)
|
||
if p_R <= 0:
|
||
raise ValueError(f"Non-positive pressure in W_R: p={p_R}, W_R={W_R}")
|
||
a_R = np.sqrt(gamma * p_R / rho_R)
|
||
|
||
# --- Physical fluxes at left/right states ---
|
||
F_L = np.array([
|
||
rho_L * u_L,
|
||
rho_L * u_L ** 2 + p_L,
|
||
u_L * (E_L + p_L),
|
||
])
|
||
F_R = np.array([
|
||
rho_R * u_R,
|
||
rho_R * u_R ** 2 + p_R,
|
||
u_R * (E_R + p_R),
|
||
])
|
||
|
||
# --- HLL wave-speed estimates ---
|
||
S_L = min(u_L - a_L, u_R - a_R)
|
||
S_R = max(u_L + a_L, u_R + a_R)
|
||
|
||
# --- HLL flux, piecewise on wave configuration ---
|
||
if S_L >= 0:
|
||
return F_L
|
||
if S_R <= 0:
|
||
return F_R
|
||
W_L_arr = np.asarray(W_L, dtype=float)
|
||
W_R_arr = np.asarray(W_R, dtype=float)
|
||
return (S_R * F_L - S_L * F_R + S_L * S_R * (W_R_arr - W_L_arr)) / (S_R - S_L)
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
pytest tests/test_riemann.py -v
|
||
```
|
||
|
||
Expected: `3 passed`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/riemann.py tests/test_riemann.py
|
||
git commit -m "feat(riemann): HLL flux with 3 unit tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `src/tank.py` — 0D Tank (TDD)
|
||
|
||
Tank class with `(mass, U)` as primary state and `(rho, T, P)` as derived properties. Written TDD: 3 tests first, then implementation.
|
||
|
||
**Files:**
|
||
- Create: `tests/test_tank.py`
|
||
- Create: `src/tank.py`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/test_tank.py`:
|
||
|
||
```python
|
||
# 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
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
pytest tests/test_tank.py -v
|
||
```
|
||
|
||
Expected: 3 failures with `ModuleNotFoundError: No module named 'tank'`.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `src/tank.py`:
|
||
|
||
```python
|
||
# src/tank.py
|
||
"""
|
||
0D lumped-parameter tank for ideal gas. The tank's *primary* state is
|
||
(mass, U) where U is total internal energy in joules. Pressure, temperature,
|
||
and density are derived properties computed from (mass, U) on demand, so
|
||
they are always consistent with the conservation-law updates.
|
||
|
||
Conservation laws (u=0 inside tank):
|
||
dm/dt = mdot_in (mass)
|
||
dU/dt = Hdot_in = mdot_in * h_t,in (energy, open-system first law)
|
||
|
||
where h_t is specific total enthalpy. When the tank couples to a 1D pipe
|
||
through the HLL boundary flux, flux[0]*A = mdot and flux[2]*A = Hdot
|
||
automatically — see solver.py.
|
||
"""
|
||
|
||
import numpy as np
|
||
|
||
|
||
class Tank:
|
||
def __init__(self, V, P_init, T_init, gamma, R_gas):
|
||
self.V = V
|
||
self.gamma = gamma
|
||
self.R = R_gas
|
||
rho = P_init / (R_gas * T_init)
|
||
self.mass = rho * V
|
||
# For u=0, total internal energy equals rho*e*V = P*V / (gamma-1)
|
||
self.U = P_init * V / (gamma - 1)
|
||
|
||
@property
|
||
def rho(self):
|
||
return self.mass / self.V
|
||
|
||
@property
|
||
def T(self):
|
||
return (self.U / self.mass) * (self.gamma - 1) / self.R
|
||
|
||
@property
|
||
def P(self):
|
||
return self.rho * self.R * self.T
|
||
|
||
def ghost_state(self):
|
||
"""
|
||
Return the conservative variable vector [rho, rho*u, rho*E] that
|
||
represents this tank as a ghost cell for the 1D pipe solver.
|
||
Since u_ghost = 0, rho*u = 0 and rho*E = P/(gamma-1).
|
||
"""
|
||
return np.array([self.rho, 0.0, self.P / (self.gamma - 1)])
|
||
|
||
def apply_flux(self, mdot, edot, dt, sign):
|
||
"""
|
||
Update (mass, U) from one time step of boundary flux.
|
||
|
||
Parameters
|
||
----------
|
||
mdot : float
|
||
Mass flux across the interface in kg/s (already multiplied
|
||
by pipe cross-sectional area). Sign is the "outward normal"
|
||
convention of the pipe: positive = pipe-rightward.
|
||
edot : float
|
||
Total enthalpy rate in W (= flux[2] * A), same convention.
|
||
dt : float
|
||
Time-step size in seconds.
|
||
sign : int (+1 or -1)
|
||
Orientation for this tank. For an upstream tank whose gas
|
||
flows "out to the right" into the pipe, the HLL left-boundary
|
||
flux has mdot > 0, so sign = -1 (tank loses mass).
|
||
For a downstream tank receiving gas from the right boundary
|
||
with mdot > 0 entering, sign = +1.
|
||
|
||
Raises
|
||
------
|
||
RuntimeError
|
||
If the tank's mass becomes non-positive after the update.
|
||
"""
|
||
self.mass += sign * mdot * dt
|
||
self.U += sign * edot * dt
|
||
if self.mass <= 0:
|
||
raise RuntimeError(
|
||
f"Tank mass non-positive after apply_flux: mass={self.mass}, "
|
||
f"mdot={mdot}, edot={edot}, dt={dt}, sign={sign}"
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
pytest tests/test_tank.py -v
|
||
```
|
||
|
||
Expected: `3 passed`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/tank.py tests/test_tank.py
|
||
git commit -m "feat(tank): 0D tank with (mass, U) primary state and 3 tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `src/pipe.py` — 1D Pipe (TDD)
|
||
|
||
Pipe class storing the conservative-variable field W of shape (3, N). `step()` takes left/right boundary fluxes and advances the field by one explicit Euler step.
|
||
|
||
**Files:**
|
||
- Create: `tests/test_pipe.py`
|
||
- Create: `src/pipe.py`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/test_pipe.py`:
|
||
|
||
```python
|
||
# 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)
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
pytest tests/test_pipe.py -v
|
||
```
|
||
|
||
Expected: 2 failures with `ModuleNotFoundError: No module named 'pipe'`.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `src/pipe.py`:
|
||
|
||
```python
|
||
# src/pipe.py
|
||
"""
|
||
1D finite-volume pipe for compressible Euler equations:
|
||
dW/dt + dF(W)/dx = 0
|
||
W = [rho, rho*u, rho*E], F = [rho*u, rho*u^2 + P, u*(rho*E + P)]
|
||
|
||
Discretization:
|
||
- N uniform cells, cell-averaged piecewise-constant reconstruction
|
||
- HLL numerical flux at all interior interfaces
|
||
- Boundary (tank-side) interface fluxes are provided by the caller
|
||
via step(flux_L, flux_R, dt)
|
||
"""
|
||
|
||
import numpy as np
|
||
from riemann import hll_flux
|
||
|
||
|
||
class Pipe:
|
||
def __init__(self, L, D, N, P_init, T_init, gamma, R_gas):
|
||
self.L = L
|
||
self.D = D
|
||
self.N = N
|
||
self.dx = L / N
|
||
self.area = np.pi * (D / 2) ** 2
|
||
self.gamma = gamma
|
||
self.R = R_gas
|
||
self.x_centers = np.linspace(self.dx / 2, L - self.dx / 2, N)
|
||
|
||
# Uniform initial state, u = 0
|
||
rho = P_init / (R_gas * T_init)
|
||
E_density = P_init / (gamma - 1) # since u=0, total energy density = internal
|
||
|
||
self.W = np.zeros((3, N))
|
||
self.W[0, :] = rho
|
||
self.W[1, :] = 0.0
|
||
self.W[2, :] = E_density
|
||
|
||
def primitives(self):
|
||
"""
|
||
Return (rho, u, P, a) each of shape (N,), computed from W.
|
||
"""
|
||
rho = self.W[0, :]
|
||
u = self.W[1, :] / rho
|
||
P = (self.gamma - 1) * (self.W[2, :] - 0.5 * rho * u ** 2)
|
||
a = np.sqrt(self.gamma * P / rho)
|
||
return rho, u, P, a
|
||
|
||
def max_wave_speed(self):
|
||
"""
|
||
Return max over cells of |u| + a, used for CFL dt calculation.
|
||
"""
|
||
_, u, _, a = self.primitives()
|
||
return float(np.max(np.abs(u) + a))
|
||
|
||
def step(self, flux_L, flux_R, dt):
|
||
"""
|
||
Advance W by one explicit Euler step. The caller provides the
|
||
two boundary interface fluxes (with ghost states already folded
|
||
in); internal interface fluxes are computed here with HLL.
|
||
|
||
Parameters
|
||
----------
|
||
flux_L, flux_R : np.ndarray of shape (3,)
|
||
Numerical fluxes at the leftmost and rightmost interfaces
|
||
(cell -1/2 and cell N-1/2, i.e. the tank-facing boundaries).
|
||
dt : float
|
||
Time-step size.
|
||
|
||
Raises
|
||
------
|
||
RuntimeError
|
||
If the updated state has any non-positive density or pressure.
|
||
"""
|
||
N = self.N
|
||
W_snap = self.W.copy()
|
||
|
||
# Internal fluxes: flux_int[:, k] is the flux at the interface
|
||
# between cell k and cell k+1, for k = 0 .. N-2 (total N-1 of them)
|
||
flux_int = np.zeros((3, N - 1))
|
||
for k in range(N - 1):
|
||
flux_int[:, k] = hll_flux(W_snap[:, k], W_snap[:, k + 1], self.gamma)
|
||
|
||
# First cell: left face = flux_L, right face = flux_int[:, 0]
|
||
self.W[:, 0] = W_snap[:, 0] - (dt / self.dx) * (flux_int[:, 0] - flux_L)
|
||
|
||
# Interior cells: left face = flux_int[:, i-1], right face = flux_int[:, i]
|
||
for i in range(1, N - 1):
|
||
self.W[:, i] = W_snap[:, i] - (dt / self.dx) * (flux_int[:, i] - flux_int[:, i - 1])
|
||
|
||
# Last cell: left face = flux_int[:, N-2], right face = flux_R
|
||
self.W[:, N - 1] = W_snap[:, N - 1] - (dt / self.dx) * (flux_R - flux_int[:, N - 2])
|
||
|
||
# Physical-state sanity check
|
||
rho_new = self.W[0, :]
|
||
if np.any(rho_new <= 0):
|
||
bad = np.where(rho_new <= 0)[0]
|
||
raise RuntimeError(
|
||
f"Non-positive density after pipe step at cells {bad.tolist()}: "
|
||
f"rho={rho_new[bad].tolist()}"
|
||
)
|
||
u_new = self.W[1, :] / rho_new
|
||
P_new = (self.gamma - 1) * (self.W[2, :] - 0.5 * rho_new * u_new ** 2)
|
||
if np.any(P_new <= 0):
|
||
bad = np.where(P_new <= 0)[0]
|
||
raise RuntimeError(
|
||
f"Non-positive pressure after pipe step at cells {bad.tolist()}: "
|
||
f"P={P_new[bad].tolist()}"
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
pytest tests/test_pipe.py -v
|
||
```
|
||
|
||
Expected: `2 passed`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/pipe.py tests/test_pipe.py
|
||
git commit -m "feat(pipe): 1D FV pipe with HLL internal fluxes and 2 tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `src/solver.py` + Integration Tests (TDD)
|
||
|
||
The time-loop driver that orchestrates tank ↔ pipe coupling. The integration tests verify total mass and total energy are conserved to 1e-10 over a short run — the end-to-end correctness check for the "flux doubling" mechanism.
|
||
|
||
**Files:**
|
||
- Create: `tests/test_integration.py`
|
||
- Create: `src/solver.py`
|
||
|
||
- [ ] **Step 1: Write the failing integration tests**
|
||
|
||
Create `tests/test_integration.py`:
|
||
|
||
```python
|
||
# tests/test_integration.py
|
||
"""
|
||
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}"
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
||
```bash
|
||
pytest tests/test_integration.py -v
|
||
```
|
||
|
||
Expected: 2 failures with `ModuleNotFoundError: No module named 'solver'`.
|
||
|
||
- [ ] **Step 3: Write the solver implementation**
|
||
|
||
Create `src/solver.py`:
|
||
|
||
```python
|
||
# src/solver.py
|
||
"""
|
||
Time-loop driver for the 0D-1D coupled tank-pipe simulation.
|
||
|
||
Per time step (per spec §3.1):
|
||
1. Compute CFL-limited dt from pipe's max wave speed
|
||
2. Freeze ghost states from current tank states
|
||
3. Compute two boundary HLL fluxes (left and right)
|
||
4. Advance pipe by one step using those two fluxes (pipe.step handles
|
||
the internal fluxes itself)
|
||
5. Advance both tanks using the SAME two boundary fluxes * area
|
||
-> this "flux doubling" is the mechanism that makes system mass
|
||
and energy strictly conserved to machine precision
|
||
6. Advance time
|
||
7. Append snapshot to history
|
||
"""
|
||
import numpy as np
|
||
from riemann import hll_flux
|
||
|
||
|
||
def run(tank1, tank2, pipe, t_end, cfl, verbose=False, log_every=100):
|
||
"""
|
||
Run the coupled tank-pipe simulation from t=0 to t=t_end.
|
||
|
||
Parameters
|
||
----------
|
||
tank1, tank2 : Tank
|
||
Upstream and downstream tanks. tank1 connects to pipe.W[:, 0],
|
||
tank2 connects to pipe.W[:, -1].
|
||
pipe : Pipe
|
||
1D pipe instance with initial state already set.
|
||
t_end : float
|
||
End time in seconds.
|
||
cfl : float
|
||
CFL number in (0, 1].
|
||
verbose : bool, default False
|
||
If True, print step-progress info every `log_every` steps.
|
||
log_every : int, default 100
|
||
Logging interval when verbose=True.
|
||
|
||
Returns
|
||
-------
|
||
dict
|
||
History with keys 't', 'P1', 'T1', 'P2', 'T2' (all 1D arrays
|
||
of shape (n_steps,)), and 'W_hist' of shape (n_steps, 3, N).
|
||
"""
|
||
history = {
|
||
't': [],
|
||
'P1': [], 'T1': [],
|
||
'P2': [], 'T2': [],
|
||
'W_hist': [],
|
||
}
|
||
|
||
t = 0.0
|
||
step = 0
|
||
|
||
while t < t_end:
|
||
# --- Phase 1: CFL time step ---
|
||
a_max = pipe.max_wave_speed()
|
||
dt = cfl * pipe.dx / a_max
|
||
dt = min(dt, t_end - t)
|
||
if dt < 1e-12:
|
||
raise RuntimeError(
|
||
f"dt degenerate at step {step}: dt={dt:.3e}, a_max={a_max:.3e}"
|
||
)
|
||
|
||
# --- Phase 2: freeze tank ghost states (snapshot for this step) ---
|
||
W_ghost_L = tank1.ghost_state()
|
||
W_ghost_R = tank2.ghost_state()
|
||
|
||
# --- Phase 3: two boundary fluxes (solver-level) ---
|
||
flux_L = hll_flux(W_ghost_L, pipe.W[:, 0], pipe.gamma)
|
||
flux_R = hll_flux(pipe.W[:, -1], W_ghost_R, pipe.gamma)
|
||
|
||
# --- Phase 4: advance pipe (internal fluxes handled inside) ---
|
||
pipe.step(flux_L, flux_R, dt)
|
||
|
||
# --- Phase 5: advance tanks with the SAME boundary fluxes * area ---
|
||
fL_A = flux_L * pipe.area
|
||
fR_A = flux_R * pipe.area
|
||
# Left boundary flux is "rightward positive"; tank1 loses that mass
|
||
tank1.apply_flux(mdot=fL_A[0], edot=fL_A[2], dt=dt, sign=-1)
|
||
# Right boundary flux is "rightward positive"; tank2 gains that mass
|
||
tank2.apply_flux(mdot=fR_A[0], edot=fR_A[2], dt=dt, sign=+1)
|
||
|
||
# --- Phase 6: advance time ---
|
||
t += dt
|
||
step += 1
|
||
|
||
# --- Phase 7: record history ---
|
||
history['t'].append(t)
|
||
history['P1'].append(tank1.P)
|
||
history['T1'].append(tank1.T)
|
||
history['P2'].append(tank2.P)
|
||
history['T2'].append(tank2.T)
|
||
history['W_hist'].append(pipe.W.copy())
|
||
|
||
if verbose and step % log_every == 0:
|
||
_, u, _, _ = pipe.primitives()
|
||
print(
|
||
f"step={step:6d} t={t:.5f} dt={dt:.2e} "
|
||
f"P1={tank1.P/1e6:7.4f}MPa P2={tank2.P/1e6:7.4f}MPa "
|
||
f"max|u|={float(np.max(np.abs(u))):7.1f}m/s"
|
||
)
|
||
|
||
if step == 0:
|
||
raise RuntimeError("solver.run() exited without taking any step")
|
||
|
||
# Convert lists to arrays for downstream consumers
|
||
history['t'] = np.asarray(history['t'])
|
||
history['P1'] = np.asarray(history['P1'])
|
||
history['T1'] = np.asarray(history['T1'])
|
||
history['P2'] = np.asarray(history['P2'])
|
||
history['T2'] = np.asarray(history['T2'])
|
||
history['W_hist'] = np.stack(history['W_hist']) # shape (n_steps, 3, N)
|
||
|
||
return history
|
||
```
|
||
|
||
- [ ] **Step 4: Run integration tests to verify they pass**
|
||
|
||
```bash
|
||
pytest tests/test_integration.py -v
|
||
```
|
||
|
||
Expected: `2 passed`.
|
||
|
||
- [ ] **Step 5: Run the full test suite to verify nothing regressed**
|
||
|
||
```bash
|
||
pytest tests/ -v
|
||
```
|
||
|
||
Expected: `10 passed` total (3 riemann + 3 tank + 2 pipe + 2 integration).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/solver.py tests/test_integration.py
|
||
git commit -m "feat(solver): coupled time loop with mass/energy conservation tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `src/output.py` — Persistence, Plotting, Animation
|
||
|
||
Utility module for `.npz` persistence, static time-series plots, and the pipe evolution GIF. No unit tests per spec §7.3 (visual outputs verified by eye).
|
||
|
||
**Files:**
|
||
- Create: `src/output.py`
|
||
|
||
- [ ] **Step 1: Write `src/output.py`**
|
||
|
||
```python
|
||
# src/output.py
|
||
"""
|
||
Output helpers: persistence (.npz), static plots (.png), 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 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 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']
|
||
W_hist = history['W_hist'] # (n_steps, 3, N)
|
||
x = pipe.x_centers
|
||
n_steps = 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
|
||
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)
|
||
|
||
fig, axes = plt.subplots(3, 1, figsize=(10, 9), 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_xlabel("x [m]")
|
||
axes[2].set_ylim(T.min() * 0.95, T.max() * 1.05)
|
||
axes[2].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])
|
||
title.set_text(
|
||
f"t = {t[frame_idx]:.5f} s (step {frame_idx + 1}/{n_steps})"
|
||
)
|
||
return line_P, line_u, line_T, 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)
|
||
```
|
||
|
||
- [ ] **Step 2: Smoke-test that the module imports without errors**
|
||
|
||
```bash
|
||
python3 -c "import sys; sys.path.insert(0,'src'); import output; print('output OK:', dir(output))"
|
||
```
|
||
|
||
Expected: `output OK: [...] 'make_pipe_animation', 'plot_tank_pressure', 'plot_tank_temperature', 'save_history', ...`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/output.py
|
||
git commit -m "feat(output): npz persistence, timeseries plots, pipe GIF animation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `src/main.py` — Entry Point + Sanity Check
|
||
|
||
Wires everything together: build objects from `config`, call `solver.run`, assert conservation, write all outputs.
|
||
|
||
**Files:**
|
||
- Create: `src/main.py`
|
||
|
||
- [ ] **Step 1: Write `src/main.py`**
|
||
|
||
```python
|
||
# src/main.py
|
||
"""
|
||
Entry point: assemble tanks + pipe from config constants, run the solver,
|
||
verify total mass/energy conservation, persist history, and generate
|
||
plots + animation.
|
||
|
||
Run from project root:
|
||
python3 src/main.py
|
||
"""
|
||
import os
|
||
import sys
|
||
|
||
# Ensure imports work when running from project root
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, _HERE)
|
||
|
||
import numpy as np
|
||
|
||
from config import (
|
||
GAMMA, R_GAS,
|
||
V1, P1_INIT, T1_INIT,
|
||
V2, P2_INIT, T2_INIT,
|
||
L, D, N_CELLS,
|
||
T_END, CFL,
|
||
ANIMATION_STRIDE, OUTPUT_DIR,
|
||
)
|
||
from tank import Tank
|
||
from pipe import Pipe
|
||
from solver import run
|
||
from output import (
|
||
save_history,
|
||
plot_tank_pressure,
|
||
plot_tank_temperature,
|
||
make_pipe_animation,
|
||
)
|
||
|
||
|
||
def _total_mass(tank1, tank2, pipe):
|
||
pipe_mass = float(np.sum(pipe.W[0, :] * pipe.area * pipe.dx))
|
||
return tank1.mass + tank2.mass + pipe_mass
|
||
|
||
|
||
def _total_energy(tank1, tank2, pipe):
|
||
pipe_energy = float(np.sum(pipe.W[2, :] * pipe.area * pipe.dx))
|
||
return tank1.U + tank2.U + pipe_energy
|
||
|
||
|
||
def main():
|
||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||
|
||
# --- Assemble ---
|
||
tank1 = Tank(V=V1, P_init=P1_INIT, T_init=T1_INIT, gamma=GAMMA, R_gas=R_GAS)
|
||
tank2 = Tank(V=V2, P_init=P2_INIT, T_init=T2_INIT, gamma=GAMMA, R_gas=R_GAS)
|
||
pipe = Pipe(L=L, D=D, N=N_CELLS, P_init=P2_INIT, T_init=T2_INIT,
|
||
gamma=GAMMA, R_gas=R_GAS)
|
||
|
||
m_init = _total_mass(tank1, tank2, pipe)
|
||
U_init = _total_energy(tank1, tank2, pipe)
|
||
print(f"Initial total mass: {m_init:.6e} kg")
|
||
print(f"Initial total energy: {U_init:.6e} J")
|
||
print(f"Initial P1 = {tank1.P/1e6:.3f} MPa, P2 = {tank2.P/1e6:.3f} MPa")
|
||
print(f"Pipe: L={L} m, D={D*1e3:.1f} mm, N={N_CELLS} cells, dx={pipe.dx*1e3:.1f} mm")
|
||
print(f"Running to t_end={T_END} s with CFL={CFL}...")
|
||
print()
|
||
|
||
# --- Run ---
|
||
history = run(tank1, tank2, pipe,
|
||
t_end=T_END, cfl=CFL,
|
||
verbose=True, log_every=200)
|
||
|
||
n_steps = len(history['t'])
|
||
print()
|
||
print(f"Simulation complete: {n_steps} steps")
|
||
|
||
# --- Conservation sanity check (per spec §6.1, §8) ---
|
||
m_final = _total_mass(tank1, tank2, pipe)
|
||
U_final = _total_energy(tank1, tank2, pipe)
|
||
rel_err_m = abs(m_final - m_init) / m_init
|
||
rel_err_U = abs(U_final - U_init) / U_init
|
||
print(f"Final total mass: {m_final:.6e} kg (rel err = {rel_err_m:.2e})")
|
||
print(f"Final total energy: {U_final:.6e} J (rel err = {rel_err_U:.2e})")
|
||
print(f"Final P1 = {tank1.P/1e6:.3f} MPa, P2 = {tank2.P/1e6:.3f} MPa")
|
||
assert rel_err_m < 1e-10, f"Total mass not conserved: rel_err={rel_err_m:.2e}"
|
||
assert rel_err_U < 1e-10, f"Total energy not conserved: rel_err={rel_err_U:.2e}"
|
||
|
||
# --- Persist + visualize ---
|
||
save_history(history, pipe,
|
||
os.path.join(OUTPUT_DIR, "history.npz"),
|
||
GAMMA, R_GAS)
|
||
plot_tank_pressure(history,
|
||
os.path.join(OUTPUT_DIR, "tank_pressure.png"))
|
||
plot_tank_temperature(history,
|
||
os.path.join(OUTPUT_DIR, "tank_temperature.png"))
|
||
make_pipe_animation(history, pipe,
|
||
os.path.join(OUTPUT_DIR, "pipe_animation.gif"),
|
||
GAMMA, R_GAS, stride=ANIMATION_STRIDE)
|
||
|
||
print(f"Outputs written to {OUTPUT_DIR}/")
|
||
print(f" - history.npz")
|
||
print(f" - tank_pressure.png")
|
||
print(f" - tank_temperature.png")
|
||
print(f" - pipe_animation.gif")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the module imports cleanly (no run)**
|
||
|
||
```bash
|
||
python3 -c "import sys; sys.path.insert(0,'src'); import main; print('main module OK')"
|
||
```
|
||
|
||
Expected: `main module OK` (importing must not trigger `main()` because of `if __name__ == '__main__'`).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/main.py
|
||
git commit -m "feat(main): entry point with conservation sanity check"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: End-to-End Verification Run
|
||
|
||
Run the full program and verify all acceptance criteria from spec §8.
|
||
|
||
**Files:**
|
||
- No new files; verifies existing ones.
|
||
|
||
- [ ] **Step 1: Run the full simulation**
|
||
|
||
```bash
|
||
python3 src/main.py
|
||
```
|
||
|
||
Expected behavior:
|
||
- Prints "Initial total mass", "Initial total energy", initial pressures
|
||
- Prints verbose step logs every 200 steps (roughly 10–15 log lines total)
|
||
- Prints "Simulation complete: NNNN steps" where NNNN ≈ 2000–3000
|
||
- Prints "Final total mass" and "rel err" < 1e-10
|
||
- Prints "Final total energy" and "rel err" < 1e-10
|
||
- Prints "Outputs written to results/"
|
||
- Exit code 0
|
||
- Total wall time < 30 seconds
|
||
|
||
- [ ] **Step 2: Verify all 4 output files exist and are non-empty**
|
||
|
||
```bash
|
||
ls -la results/history.npz results/tank_pressure.png results/tank_temperature.png results/pipe_animation.gif
|
||
```
|
||
|
||
Expected: all four files present, each with non-zero size (`history.npz` ≈ 1-3 MB, PNGs tens of KB, GIF 5-15 MB).
|
||
|
||
- [ ] **Step 3: Verify the .npz file is loadable and has correct shapes**
|
||
|
||
```bash
|
||
python3 -c "
|
||
import numpy as np
|
||
d = np.load('results/history.npz')
|
||
print('keys:', sorted(d.files))
|
||
print('t shape:', d['t'].shape)
|
||
print('W_hist shape:', d['W_hist'].shape)
|
||
print('P1 range:', float(d['P1'].min())/1e6, '->', float(d['P1'].max())/1e6, 'MPa')
|
||
print('P2 range:', float(d['P2'].min())/1e6, '->', float(d['P2'].max())/1e6, 'MPa')
|
||
# Physical monotonicity
|
||
assert d['P1'][0] > d['P1'][-1], 'P1 should decrease'
|
||
assert d['P2'][0] < d['P2'][-1], 'P2 should increase'
|
||
print('monotonicity OK')
|
||
"
|
||
```
|
||
|
||
Expected:
|
||
- `keys: ['P1', 'P2', 'R_gas', 'T1', 'T2', 'W_hist', 'area', 'dx', 'gamma', 't', 'x']`
|
||
- `t shape: (NNNN,)` where NNNN ≈ 2000–3000
|
||
- `W_hist shape: (NNNN, 3, 20)`
|
||
- `P1 range`: starts near 10, decreases toward some intermediate value
|
||
- `P2 range`: starts near 0.1, increases toward some intermediate value
|
||
- `monotonicity OK`
|
||
|
||
- [ ] **Step 4: Run the full pytest suite one more time**
|
||
|
||
```bash
|
||
pytest tests/ -v
|
||
```
|
||
|
||
Expected: `10 passed`.
|
||
|
||
- [ ] **Step 5: Commit a `.gitignore` entry for the bulky result files**
|
||
|
||
Check whether `.gitignore` already excludes `results/`:
|
||
|
||
```bash
|
||
cat .gitignore
|
||
```
|
||
|
||
If `results/` is not already ignored, update `.gitignore` to include it (but keep `.gitkeep`):
|
||
|
||
Add these two lines to `.gitignore`:
|
||
|
||
```
|
||
results/*
|
||
!results/.gitkeep
|
||
```
|
||
|
||
Then:
|
||
|
||
```bash
|
||
git add .gitignore
|
||
git commit -m "chore: ignore runtime outputs under results/"
|
||
```
|
||
|
||
If `results/` was already handled, skip this step with `git status` confirming no .gitignore changes are staged.
|
||
|
||
- [ ] **Step 6: Final status check**
|
||
|
||
```bash
|
||
git status
|
||
git log --oneline -12
|
||
```
|
||
|
||
Expected:
|
||
- `git status` shows a clean working tree
|
||
- `git log` shows the sequence of feature commits: scaffold → config → riemann → tank → pipe → solver → output → main → (optionally) .gitignore
|
||
|
||
---
|
||
|
||
## Acceptance Criteria Recap (from spec §8)
|
||
|
||
After Task 9 is complete, verify each item is satisfied:
|
||
|
||
1. [x] `python3 src/main.py` runs to completion without exceptions → Task 9 Step 1
|
||
2. [x] `pytest tests/ -v` → 10 passed → Task 9 Step 4
|
||
3. [x] main.py sanity check: total mass & total energy rel err < 1e-10 → Task 9 Step 1 output
|
||
4. [x] `P1(t)` monotone decreasing, `P2(t)` monotone increasing → Task 9 Step 3
|
||
5. [x] `pipe_animation.gif` is a valid GIF openable by standard viewers → Task 9 Step 2 + manual inspection
|
||
6. [x] `history.npz` loadable via `np.load` with all fields accessible → Task 9 Step 3
|
||
7. [x] Wall time < 30 seconds → Task 9 Step 1
|