AMReX: from symbolic model to generated C++#
AMReX is Zoomy’s block-structured adaptive-mesh backend. The model you write is the same one the NumPy tutorial uses — only the final step differs: instead of lambdifying into NumPy, the operators are printed as C++ and compiled against AMReX.
Before you start — you need the AMReX container#
The codegen cells below run anywhere zoomy_core is installed. Compiling and
running needs the AMReX toolchain, which ships as a container:
# Apptainer (recommended)
apptainer pull zoomy_amrex.sif oras://ghcr.io/zoomylab/zoomy_amrex_sif:latest
apptainer run --bind $PWD:/workspace zoomy_amrex.sif jupyter
# -> open the printed http://127.0.0.1:8888/?token=... and run this notebook there
# Docker
docker run --rm -p 8888:8888 -v "$PWD:/workspace" \
ghcr.io/zoomylab/zoomy_amrex:latest jupyter
See Installation for all containers and the three run modes. Nothing on this page needs a local AMReX build.
1. The model#
Identical to the NumPy tutorial — that is the point. Shallow water as
SME(level=0), wall boundaries, dam-break initial state.
import numpy as np
from zoomy_core.model.models import SME, Newtonian, NavierSlip, StressFree
import zoomy_core.model.boundary_conditions as BC
import zoomy_core.model.initial_conditions as IC
model = SME(
level=0,
closures=[Newtonian(), NavierSlip(), StressFree()],
boundary_conditions=BC.BoundaryConditions([
BC.Wall(tag="left"), BC.Wall(tag="right"),
]),
initial_conditions=IC.RP(
high=lambda n: np.array([0.0, 2.0] + [0.0] * (n - 2)),
low=lambda n: np.array([0.0, 1.0] + [0.0] * (n - 2)),
jump_position_x=5.0,
),
)
2. Freeze and inspect#
describe() is the cheapest sanity check there is. Read the slots before you
spend a compile cycle: an empty hydrostatic_pressure row on a free-surface
model means the derivation went wrong, and you can see that here in seconds
rather than after a build.
from zoomy_core.systemmodel import SystemModel
sm = SystemModel.from_model(model)
# Display, do NOT print: describe() has a _repr_markdown_ that renders the
# operator matrices as real LaTeX. print() would dump the raw markup.
sm.describe()
SystemModel — 3 equations, 1 spatial dimension
Parameters: \(g = 9.81\), \(\rho = 1.0\), \(\nu = 0.0\), \(\lambda_{s} = 0.0\), \(e_{x} = 0.0\)
System form:
State \(Q\):
Auxiliary \(Q_{aux}\):
Mass matrix \(M\):
Flux \(F\):
Hydrostatic pressure \(P\):
Diffusive flux \(A\) \(= 0\)
NCP \(B\):
Source \(S\) (implicit):
Operations: from_model
3. Attach the numerics#
Same NumericalSystemModel as every other backend. The scheme lives here, at
the symbolic level — not in the AMReX code. That is deliberate: every
backend gets the same well-balancing, positivity and tolerances, emitted from
one place, so they cannot drift apart.
from zoomy_core.numerics import NumericalSystemModel, ReconstructionSpec
nsm = NumericalSystemModel.from_system_model(
sm, reconstruction=ReconstructionSpec(order=1))
riemann = nsm.riemann
print("Riemann solver :", getattr(riemann, "__name__", None) or type(riemann).__name__)
print("reconstruction :", nsm.reconstruction)
Riemann solver : NonconservativeRusanov
reconstruction : ReconstructionSpec(order=1, limiter='venkatakrishnan', free_surface_aware=False, positivity='')
4. Print the C++#
Two headers: Model.H (the operators) and Numerics.H (the scheme). Both come
straight off the NumericalSystemModel.
from zoomy_core.transformation.to_amrex import AmrexModel, AmrexNumerics
model_src = AmrexModel(nsm).create_code()
numerics_src = AmrexNumerics(nsm.build_numerics()).create_code()
print(f"Model.H : {len(model_src):,} chars")
print(f"Numerics.H : {len(numerics_src):,} chars")
Model.H : 13,563 chars
Numerics.H : 5,638 chars
A quick structural check worth keeping: no unresolved symbolic Integral may
survive into generated code. If one does, a projection was left unresolved
upstream and the C++ will not compile.
for name, src in (("Model.H", model_src), ("Numerics.H", numerics_src)):
assert "Integral" not in src, f"unresolved Integral leaked into {name}"
print(f"{name}: no unresolved Integral")
Model.H: no unresolved Integral
Numerics.H: no unresolved Integral
Here is the head of the generated model header — ordinary, readable C++:
print("\n".join(model_src.splitlines()[:40]))
#pragma once
#include <AMReX_Array4.H>
#include <AMReX_Vector.H>
#include <AMReX_SmallMatrix.H>
#include <vector>
#include <string>
#include <algorithm>
#ifdef __CUDACC__
#define PORTABLE_FN __host__ __device__
#else
#define PORTABLE_FN
#endif
struct Model {
using T = amrex::Real;
static constexpr int n_dof_q = 3;
static constexpr int n_dof_qaux = 4;
static constexpr int n_parameters = 5;
static constexpr int dimension = 1;
static constexpr double dt_max = 5.0;
static constexpr int n_dof_gradQ = 0;
static constexpr bool has_diffusion = false;
static constexpr bool fluctuations_are_zero = false;
static constexpr const char* implicit_mode = "local_source";
static constexpr bool has_free_surface = true;
static constexpr int idx_b = 0;
static constexpr int idx_h = 1;
static constexpr int n_boundary_tags = 2;
static const std::vector<std::string> get_boundary_tags() { return { "left", "right" }; }
static const std::vector<std::string> parameter_names() { return { "g", "rho", "nu", "lambda_s", "e_x" }; }
static const std::vector<T> default_parameters() { return { 9.81, 1.0, 0.0, 0.0, 0.0 }; }
AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE
static amrex::SmallMatrix<amrex::Real,3,1> flux_x(
const amrex::Real* Q,
const amrex::Real* Qaux,
const amrex::Real* p) noexcept
Where the flux ended up#
Worth seeing once: the symbolic flux you never typed, rendered as a device function.
lines = model_src.splitlines()
start = next((i for i, l in enumerate(lines) if "flux" in l.lower() and "(" in l), None)
print("\n".join(lines[start:start + 22]) if start is not None
else "(flux symbol not found — print model_src to inspect)")
static amrex::SmallMatrix<amrex::Real,3,1> flux_x(
const amrex::Real* Q,
const amrex::Real* Qaux,
const amrex::Real* p) noexcept
{
amrex::SmallMatrix<amrex::Real,3,1> res;
res(0, 0) = 0;
res(1, 0) = Q(2, 0);
res(2, 0) = Qaux(3, 0)*amrex::Math::powi<2>(Q(2, 0));
return res;
}
AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE
static amrex::SmallMatrix<amrex::Real,9,1> nonconservative_matrix_x(
const amrex::Real* Q,
const amrex::Real* Qaux,
const amrex::Real* p) noexcept
{
amrex::SmallMatrix<amrex::Real,9,1> res;
res(0, 0) = 0;
res(1, 0) = 0;
5. Running it#
Writing the headers to a case directory and building is a compile step, so it belongs in the container rather than in a rendered notebook page. Inside the AMReX container:
from pathlib import Path
out = Path("case"); out.mkdir(exist_ok=True)
(out / "Model.H").write_text(model_src)
(out / "Numerics.H").write_text(numerics_src)
cd case && make && ./run
The maintained end-to-end examples are the AMReX test suite itself —
library/zoomy_amrex/tests/ — which builds and runs these same generated
headers:
cd library/zoomy_amrex && python3 -m pytest -m small -q
That suite covers a wet dam break (Stoker), a dry one (Ritter), 2-D, a lake-at-rest well-balancing check, NumPy parity, and a two-rank parallel run — each a better starting point for a real case than a hand-assembled directory.
What carries over#
Nothing about the model changed to target AMReX. Swap the last step and the same
nsm drives NumPy, JAX, or OpenFOAM — see
Backends.