User-defined functions (UDFs)¶
Write a closure in Python, name it from a case file, and have the solver call it per cell, per step.
This chapter is one section per seam. Read this page first — the permission model, the state object and the return contract are shared by all of them; the per-seam pages only cover what differs.
Status of each seam¶
The solver carries several py_* seams. They are at different stages, and
this table is the honest version — a seam existing in the solver is not the
same as a TOML path reaching it.
| Seam | Section | TOML path for your own code? |
|---|---|---|
| Pipe friction | Friction | Yes — kind = "python", dotted path |
| Wall heat | Wall heat | No — seam is live, but only built-in closures produce it |
| Porous drag | Porous | Yes — kind = "python", dotted path |
| Junction BC | Junction BC | Partly — a fixed selector, not an arbitrary path |
| Surface reactions | Reactions | No — internal to the mechanism closures |
Friction and porous were first because they were the two seams the solver consumed that nothing else wrote — no built-in closure and no TOML path. Adding an arm to either is purely additive: it cannot change the result of any case that does not opt in. The remaining seams are not in that position, and each section says why.
The permission model¶
Resolving a dotted path imports and executes the module it names. Case files get emailed, committed, attached to bug reports and pasted into issues, so a format that silently runs whatever it names is one you cannot accept from a stranger.
aeolus1d run case.toml --allow-user-code
Two properties that matter more than the flag itself:
- Off by default. No flag, no import — you get a refusal naming the path, not a stack trace about a missing module.
- The case file cannot grant it. The permission is process-level
(
AEOLUS_ALLOW_USER_CODE), never a key in the TOML. A file that could setallow_user_code = trueabout itself would be deciding whether to trust itself, which is not a check at all.
The module must be importable from your interpreter — PYTHONPATH, or
pip-installed. aeolus1d deliberately does not add the case file's own
directory to sys.path: a case's meaning should not depend on where the
file happens to sit.
PipeState — what your function receives¶
Every per-cell UDF receives a PipeState
(aeolus1d.closures.user_code.PipeState) describing the pipe's
interior cells; ghosts are excluded.
| field | type | meaning |
|---|---|---|
rho, u, p |
array, length N |
density, velocity, static pressure |
T |
array | static temperature — precomputed, see below |
x, A, D_h |
array | cell centre, area, hydraulic diameter |
dx |
float | cell width |
R_gas, gamma |
float | per-pipe gas constant; cell-mean γ |
t |
float | simulation time |
N |
int | number of interior cells |
All arrays are read-only. Mutating solver state from a closure is not a supported way to influence the solution, and silently succeeding at it would make runs irreproducible.
Do not recompute T¶
The one rule worth stating loudly.
T looks like one line — p / (rho * R) — and the trap is the p. The
solver recovers cell pressure through a three-way branch: real-gas
EOS, per-cell γ, then the scalar (γ−1)ρe. The scalar form is wrong for
the first two. A closure that reaches for state.gamma gets the cell
mean, which is exactly the two-γ seam that once hid 26 % of a
cylinder's heat release — while conserving energy perfectly, so nothing
downstream complained.
state.T is computed by the owner from the same pressure the flux kernel
used. Use it. ADR-0013 applies at this boundary: the owner hands over
primitives, the far side does not re-derive them.
The return contract¶
Each seam documents its own return shape, but the validation is common and deliberate: returns are checked, not trusted.
R[:, 1] += s_mom broadcasts — a scalar return would be added to every
cell and produce a plausible wrong answer instead of an error. A UDF is
the one place in the solver where the array on the right of that += was
written by someone who has never seen the residual layout. So:
- wrong shape →
UserCodeErrornaming the shape it got and the one it wanted; - any non-finite value →
UserCodeErrorat the source. NaN loses every comparison, so a downstreamdtguard would pass it straight through (see ADR-0011); - an exception inside your model → wrapped in
UserCodeErrornaming the closure, so it is obvious whose code failed.
Performance¶
Your closure is called once per pipe per step with whole arrays. Keep it
vectorised — write np.abs(state.u), not a Python loop over state.N.
Measured at 200k cells, the same model three ways:
| form | time |
|---|---|
| njit kernel calling an njit function | 0.58 ms |
| vectorised numpy | 1.90 ms |
| per-cell Python loop | 190 ms |
A compiled tier that accepts an @njit function directly is the natural
next step and is not built yet. When it is, it must compute T before
dispatch for the reason above — the three-way branch must not be
duplicated on the far side of the boundary.