Skip to content

Python assembly: closures on a pipe

Part of the pure-Python assembly ladder. Read that page first — it covers Pipe, junctions, boundary conditions and the two surfaces. This one covers the source terms a pipe carries: friction, wall heat and porous drag, plus the seam for supplying your own.

Every number below was produced by the code above it, on one duct: L = 0.2 m, N = 24, A = 1e-3 m², 8 g/s of 800 K air in at the left, 1 bar out at the right, advanced to steady state at t = 0.05 s.


The three built-in seams

Closures are configured through their spec objects and materialised by build_network. Each is independent — a pipe can carry all three.

from aeolus1d.io.case import run_case
from aeolus1d.io.schema import (
    BCSpec, Case, CaseHeader, FrictionSpec, GasSpec, HeatSpec, PipeSpec,
    PorousSpec, UniformInit,
)

GAMMA, R_GAS = 1.4, 287.05

def duct(**closures):
    return PipeSpec(id="duct", length=0.2, n_cells=24, area=1.0e-3,
                    init=UniformInit(p=1.0e5, T=800.0, u=20.0), **closures)

def case_of(pipe):
    return Case(
        case=CaseHeader(name="closures", t_end=0.05, cfl=0.4),
        gas=GasSpec(gamma=GAMMA, R_gas=R_GAS),
        pipes=[pipe], junctions=[],
        bcs=[BCSpec(pipe="duct", end="left", kind="mass_flow_inlet",
                    mdot=8.0e-3, T0=800.0),
             BCSpec(pipe="duct", end="right", kind="pressure_outlet",
                    p_back=1.0e5)])

Now add one closure at a time:

bare     = run_case(case_of(duct()))[0]["duct"]
friction = run_case(case_of(duct(
    friction=FrictionSpec(kind="darcy_const", f=0.02, D_h=0.02))))[0]["duct"]
heat     = run_case(case_of(duct(
    heat=HeatSpec(kind="constant_twall", h=300.0, T_wall=400.0,
                  D_h=0.02))))[0]["duct"]
porous   = run_case(case_of(duct(
    porous=PorousSpec(kind="darcy_forchheimer",
                      alpha=5.0e3, beta=2.0e2))))[0]["duct"]
Pipe Δp (Pa) outlet T (K)
bare 0.0006 799.832
+ friction (darcy_const, f = 0.02) 14.098 799.834
+ wall heat (constant_twall, 400 K wall) −99.543 492.170
+ porous (darcy_forchheimer) 31350.464 802.691

Three things in that table are worth reading rather than skimming.

Friction raises the gas temperature, 799.832 → 799.834 K. That is not a bug and it is not a wall-heat term: in the total-energy formulation the kinetic energy that s_mom removes becomes internal energy by conservation. Porous drag does the same thing harder — 802.691 K, because it removes 31 kPa rather than 14 Pa. If you write your own friction closure, this is the trap: subtracting u * s_mom from the energy equation as well double-debits it and silently sheds the frictional heat. See UDF: friction.

The bare pipe's Δp is 0.0006 Pa, not 0. That is the discrete residual of a converged solve, and it is the scale against which the other rows should be read — a "no closure" row that came out at exactly zero would more likely mean the run never started than that the physics is clean.

Wall heat gives a negative Δp. The gas cools from 800 K to 492 K, so it densifies, so ρu² falls and pressure rises along the duct — a Rayleigh-type effect, not a mistake. tests/integration/test_driver_steady_parity.py leans on the same behaviour.

Supplying your own closure

Two seams take user code today, and the honest status table is in the UDF overview: friction and porous have a kind = "python" path; wall heat does not — that seam is live but only built-in closures write it, for reasons that page records.

# my_models.py — importable by the interpreter running aeolus1d
import numpy as np

def rough_wall(state):
    """Reimplements darcy_const: -(f / (2*D_h)) * rho * |u| * u."""
    f = 0.02
    s_mom = -(f / (2.0 * state.D_h)) * state.rho * np.abs(state.u) * state.u
    return s_mom, np.zeros_like(s_mom)
spec = FrictionSpec(kind="python", path="my_models:rough_wall", D_h=0.02)

That function is the built-in darcy_const model written out, so the two ought to agree. On the duct above they do not — 14.098 Pa against 7.903 Pa:

A = 1e-3 m²  (the duct above)
  built-in dp =   14.097987 Pa
  python   dp =    7.903302 Pa
  max|dp_cell| = 6.335471e+00 Pa   bit-identical=False

state.D_h and FrictionSpec.D_h are different numbers

They have the same name and no relationship. FrictionSpec.D_h is the value you set. state.D_h — what a UDF receives — is geometric, derived from the pipe's area as sqrt(4A/π):

FrictionSpec.D_h            = 0.02
geometric sqrt(4A/π), A=1e-3 = 0.035682
ratio                        = 1.784124

1.784 is exactly 14.098 / 7.903. The closure was right; it was handed a hydraulic diameter 78 % larger than the one the built-in used, because the duct's area does not correspond to a 20 mm round pipe.

Give the pipe an area that does — A = πD²/4 = 3.141593e-04 m² — and the two agree to twelve significant figures:

A = π D²/4  (geometric D_h == 0.02 exactly)
  built-in dp =  144.318782 Pa
  python   dp =  144.318782 Pa
  max|dp_cell| = 5.820766e-11 Pa   bit-identical=False

The 5.8e-11 Pa residual is floating-point ordering, not physics: the built-in is a fused @njit kernel and the UDF is NumPy, so the two sum the same terms in different orders. Agreement to 12 figures is the correct expectation here; bit-identity is not.

Two things to take from this. Use the number you mean — if your closure wants a specified hydraulic diameter, write it in the closure rather than reading state.D_h, which answers a different question. And start by reproducing a built-in you can compare against, confirm you get its answer, then change the model: a closure wrong by a factor of 1.78 is much harder to notice once it is the only thing computing that term.

The permission model is not decorative

Resolving "my_models:rough_wall" imports and executes that module, so it is off unless you turn it on:

import os
os.environ["AEOLUS_ALLOW_USER_CODE"] = "1"    # the API equivalent of
                                              # the CLI's --allow-user-code

Without it the run refuses, rather than quietly falling back to no friction:

UserCodeError: case names a user closure ('my_models:rough_wall') but user
code is not enabled.

The permission is deliberately an environment variable and not a CaseHeader field — a case file that can grant itself permission is not a permission, since the whole point is that opening someone else's TOML must not run their code.

Where to go next

Next Page
Species columns and chemistry on a pipe Species and chemistry
Catalysts, filters, and the quasi-steady driver Aftertreatment
Every closure kind and its fields Closures — reference table
The shared UDF contract User-defined functions