Numerical APIs¶
fno.Math provides backend-neutral array algebra, while fno.Field describes OpenFOAM fields and finite-volume operations. NumPy arrays remain in NumPy, JAX arrays and tracers remain in JAX, and symbolic field expressions are evaluated by OpenFOAM at the declared solver boundary.
Execution lanes¶
| API | Operations | OpenFOAM native graph |
|---|---|---|
fno.Math scalar algebra |
abs, sqrt, square, maximum, minimum, clip, positive dyadic powers |
Yes |
fno.Math selection |
where(field > value, a, b) |
Yes |
fno.Math tensor algebra |
mag, symm, dev, trace, outer, dot, ddot, matmul |
Yes |
fno.Math.einsum |
"...ii->...", "...ij,...ij->..." |
Yes |
fno.Math general element-wise |
exp, expm1, log, log1p, sin, cos, tan, tanh |
Resident |
fno.Math reductions and layouts |
sum, mean, min, max, reshape, transpose, stack, concatenate, other einsum patterns |
Resident |
fno.Field declarations |
Field(name), Field.delta |
Yes |
fno.Field geometry |
Field.coordinate |
Resident |
fno.Field finite-volume expressions |
Field.grad, Field.div, Field.laplacian, Field.curl |
Yes |
fno.Field solver control |
Field.transport |
Yes, kEqnFjord |
fno.Context runtime values |
Context.delta_time |
Yes |
Yes means that a fully symbolic function executes inside OpenFOAM. Resident means that the operation executes in ModelHost for NumPy or JAX inputs. A mixed function uses the resident lane.
Math reference¶
All Math functions return the native result type of their operands: NumPy input produces NumPy output, JAX input remains in JAX, and a symbolic FieldExpression produces another expression. Arguments named axis, axes, shape and subscripts follow NumPy conventions on the resident path.
| Group | Functions | Default or constraint |
|---|---|---|
| Elementwise | abs(x), sqrt(x), square(x) |
sqrt requires a non-negative numerical value or scalar field expression |
| Elementwise | exp(x), expm1(x), log(x), log1p(x), sin(x), cos(x), tan(x), tanh(x) |
Resident path |
| Bounds and selection | maximum(a, b), minimum(a, b), clip(x, minimum=None, maximum=None), where(condition, when_true, when_false) |
At least one clip bound is required |
| Reduction | sum(x, axis=None, keepdims=False), mean(...), min(...), max(...) |
Resident path |
| Layout | reshape(x, shape), transpose(x, axes=None), stack(values, axis=0), concatenate(values, axis=0) |
stack and concatenate require a non-empty iterable |
| Tensor algebra | einsum(subscripts, *operands), matmul(a, b), dot(a, b), ddot(a, b), outer(a, b), trace(A), mag(x), symm(A), dev(A) |
Native patterns are listed above |
fno.Math.outer(a, b) maps two vector fields to a tensor field, and fno.Math.trace(A) maps a tensor field to a scalar field. fno.Context.delta_time() reads the active OpenFOAM time-step size at the invocation boundary.
Native graphs map matmul(A, B), einsum("...ii->...", A) and einsum("...ij,...ij->...", A, B) to OpenFOAM contractions. Positive dyadic powers, such as x**1.5, and scalar where(x > value, a, b) also remain native.
A dyadic exponent is a positive integer divided by a power of two. Other powers, einsum patterns and arbitrary Python control flow use the resident path.
Mathematical operators¶
The same mathematical function can operate on ordinary arrays for local checks or on symbolic FieldExpression values during closure declaration.
# Define the Smagorinsky function
def smagorinsky_function(velocity_grad, delta, C_k=0.094, C_e=1.048):
strain_rate = fno.Math.symm(velocity_grad)
strain_rate_trace = fno.Math.trace(strain_rate)
dev_strain_rate = fno.Math.dev(strain_rate)
coefficient_a = C_e / delta
coefficient_b = (2.0 / 3.0) * strain_rate_trace
coefficient_c = (
2.0
* C_k
* delta
* fno.Math.ddot(dev_strain_rate, strain_rate)
)
discriminant = coefficient_b**2 + 4.0 * coefficient_a * coefficient_c
sqrt_k = (
-coefficient_b
+ fno.Math.sqrt(fno.Math.maximum(discriminant, 0.0))
) / (2.0 * coefficient_a)
sqrt_k = fno.Math.maximum(sqrt_k, 0.0)
return C_k * delta * sqrt_k
Numerical safeguards belong to the function or trained model that defines the closure. FoamNordic does not silently clip a resident model output after inference.
OpenFOAM field expressions¶
Stored fields and derived fields use the same input declaration.
# Define OpenFOAM closure inputs
inputs = {
"velocity_grad": fno.Field.grad("U"),
"convection": fno.Field.div("phi", "U"),
"thermal_diffusion": fno.Field.laplacian("alphaEff", "T"),
"vorticity": fno.Field.curl("U"),
"delta": fno.Field.delta(),
}
Field expressions compose without manually constructing OpenFOAM expression strings.
# Define the strain-rate magnitude
velocity_grad = fno.Field.grad("U")
strain = fno.Math.symm(velocity_grad)
strain_rate = fno.Math.sqrt(2.0 * fno.Math.ddot(strain, strain))
Field expressions support matching-field addition and subtraction. Multiplication accepts a scalar field or finite dimensionless scalar on either side; division accepts a scalar field or finite dimensionless scalar as its denominator. sqrt requires a scalar field. Field.laplacian(diffusivity, field) accepts a scalar field expression as its diffusivity.
Field and Context reference¶
| API | Signature | Result or constraint |
|---|---|---|
| Stored field | fno.Field(name) |
Field named by a non-empty OpenFOAM word |
| Explicit stored field | fno.Field.field(name) |
Equivalent to fno.Field(name) |
| Boundary patch | fno.Field.patch(name, patch_name) |
One named patch of a stored field |
| Field family | fno.Field.fields(pattern) |
Wildcard selection containing at least one * |
| Gradient | fno.Field.grad(field) |
Gradient expression |
| Filter width | fno.Field.delta() |
Active LES filter-width field |
| Coordinate | fno.Field.coordinate(axis) |
Coordinate scalar; axis is x, y or z |
| Divergence | fno.Field.div(field) or fno.Field.div(flux, field) |
Explicit divergence expression |
| Laplacian | fno.Field.laplacian(field) or fno.Field.laplacian(diffusivity, field) |
Explicit Laplacian expression |
| Curl | fno.Field.curl(field) |
Curl expression |
| Runtime timestep | fno.Context.delta_time() |
Active OpenFOAM deltaT at invocation |
Field.fields() is output metadata rather than a finite-volume expression. It expands against the solver object registry and fails if the pattern matches no fields. Patch expressions identify boundary data explicitly and remain subject to the selected adapter contract.
fno.Field.transport(value, *, diffusivity, source, implicit_sink,
lower_bound=0.0)
| Parameter | Type | Default | Constraint |
|---|---|---|---|
value |
field name or FieldExpression |
required | Scalar transported field |
diffusivity |
field name or FieldExpression |
required | Scalar effective diffusivity |
source |
field name or FieldExpression |
required | Explicit source term |
implicit_sink |
field name or FieldExpression |
required | Scalar implicit sink coefficient |
lower_bound |
float |
0.0 |
Finite post-solve lower bound |
Finite-volume operators use the copied system/fvSchemes. Exact schemes and usable section defaults are preserved. Convenient mode adds a fallback only when both are absent; strict mode rejects the missing scheme.
Fallbacks are Gauss linear for gradient, curl and divergence, and Gauss linear corrected for Laplacians. The source case stays unchanged.
Native algebra¶
A symbolic Operator.function can run directly in OpenFOAM when every operation is supported and it uses complete fields. Functions that use patches or unsupported Python operations run through ModelHost instead. Native closures and transforms support Observe and profile_every without exchanging fields.
# Define the Smagorinsky closure
closure = fno.Closure(
name="nutFjord",
operator=fno.Operator.function(smagorinsky_function),
inputs={
"velocity_grad": fno.Field.grad("U"),
"delta": fno.Field.delta(),
},
outputs={
"nut": fno.Field("nut"),
},
)
Functions containing Python control flow, NumPy, JAX or model evaluation remain on the resident path. Their OpenFOAM input expressions are still evaluated before packing, so derived fields do not move to Python merely because the model is resident.
Native transport¶
Field.transport() marks one implicit scalar finite-volume solve inside a mathematical Operator.function. The graph is traced during declaration and executed by kEqnFjord without a ModelHost in the corrector loop.
# Define the k-equation function
def keqn_function(k, velocity_grad, nu, delta, C_k=0.094, C_e=1.048):
k_positive = fno.Math.maximum(k, 1.0e-10)
sqrt_k = fno.Math.sqrt(k_positive)
strain = fno.Math.dev(2.0 * fno.Math.symm(velocity_grad))
nut = C_k * delta * sqrt_k
production = nut * fno.Math.ddot(velocity_grad, strain)
dissipation = C_e * sqrt_k / delta
k_new = fno.Field.transport(
value=k,
diffusivity=nu + nut,
source=production,
implicit_sink=dissipation,
lower_bound=1.0e-10,
)
return {
"k": k_new,
"nut": C_k * delta * fno.Math.sqrt(k_new),
}
# Define the k-equation closure
closure = fno.Closure(
name="kEqnFjord",
operator=fno.Operator.function(keqn_function),
inputs={
"k": fno.Field("k"),
"velocity_grad": fno.Field.grad("U"),
"nu": fno.Field("nu"),
"delta": fno.Field.delta(),
},
outputs={
"k": fno.Field("k"),
"nut": fno.Field("nut"),
},
)
The current native transport contract permits one scalar solve and fixes the kEqnFjord outputs as k and nut. Use the value returned by Field.transport() for post-solve calculations; mixing it with the original k in one output is rejected. Observe samples the requested OpenFOAM fields after completed corrections, and profile_every measures native algebra, matrix assembly, solving and field publication.
Random keys¶
FoamNordic uses immutable, backend-neutral keys for stochastic closures and transforms.
key = fno.Random.key(42, scope="global")
def perturb(velocity, *, key):
scale_key, noise_key = fno.Random.split(key, 2)
scale = fno.Random.uniform(scale_key, low=0.995, high=1.005)
noise = fno.Random.normal(noise_key, shape=velocity.shape, std=1.0e-6)
return {"velocity": velocity * scale + noise}
Reusing a key repeats a draw; split keys for independent draws. Distributions are uniform, normal, integers and bernoulli.
scope="global" shares an invocation key across ranks; scope="rank" folds in the negotiated rank. to_jax() creates a JAX key. Reproducibility holds within each backend, not bit-for-bit across NumPy and JAX.
| API | Principal arguments | Defaults or constraint |
|---|---|---|
Random.key |
seed, scope |
scope="global"; scope is global or rank |
Random.split |
key, count |
count=2; positive integer |
Random.uniform |
key, low, high, shape |
low=0.0, high=1.0, shape=None |
Random.normal |
key, mean, std, shape |
mean=0.0, std=1.0, shape=None |
Random.integers |
key, low, high, shape |
high=None, shape=None; follows NumPy half-open interval semantics |
Random.bernoulli |
key, probability, shape |
probability=0.5, shape=None; probability lies in [0, 1] |
Random.to_jax |
key |
Returns a JAX PRNG key |