Inverse Heat Transfer with Automatic Differentiation¶
In this tutorial, you will learn how to:
Build a Tesseract that wraps a forward model written in plain Fortran, whose derivatives are generated automatically by Enzyme at the LLVM IR level – with no hand-written adjoint code and no source modifications.
Bridge the Fortran solver into JAX with
tesseract-jax, so that it behaves as an ordinary differentiable JAX function.Verify the resulting gradients against an independently derived analytic gradient.
Use the gradients to solve a 900-parameter inverse heat-transfer problem with
scipy.optimize.
Context¶
Inverse heat-transfer problems arise across engineering: reconstructing an unknown initial temperature distribution, a heat source, or a boundary condition from a limited set of later measurements. These problems are typically ill-posed and high-dimensional, which makes gradient-based optimization the practical approach – provided gradients of the forward solver are available.
The forward model here is a 2D transient heat-conduction solver written in plain Fortran. Many established scientific solvers are written in Fortran or C and were never designed to be differentiable, so obtaining gradients usually means writing and maintaining adjoint code by hand. Enzyme avoids this by differentiating the compiled LLVM IR directly: it synthesizes forward- and reverse-mode derivatives from the solver as built, without source changes.
Wrapped as a Tesseract and bridged
into JAX with tesseract-jax, the Fortran solver becomes an ordinary differentiable
JAX function, so jax.grad flows through it like any other JAX operation. The
optimizer in this notebook only ever interacts with a JAX function; the underlying
solver and its differentiation are encapsulated behind the Tesseract.
Step 1: Build and serve the Enzyme AD Tesseract¶
The build compiles the Fortran source to LLVM IR with LFortran, runs the Enzyme AD pass to synthesize forward- and reverse-mode derivatives, and links everything into a shared library – all inside the container. This step downloads the LLVM 19 toolchain and builds Enzyme from source, so the first build takes a few minutes.
%%bash
# Render the build status statically instead of an animated progress
# spinner, which streams as noise in captured notebook output.
export TERM=dumb
tesseract build .
[i] Building image ...
[i] Built image sha256:0172263cf380, ['enzyme-thermal-2d:1.0.0', 'enzyme-thermal-2d:latest']
["enzyme-thermal-2d:1.0.0", "enzyme-thermal-2d:latest"]
from pathlib import Path
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import numpy as np
from tesseract_jax import apply_tesseract
from tesseract_core import Tesseract
# JAX needs float64 to match the Fortran solver's double precision.
jax.config.update("jax_enable_x64", True)
# Serve the Tesseract; it stays alive for the rest of the notebook.
enzyme_tess = Tesseract.from_image("enzyme-thermal-2d")
enzyme_tess.serve()
# Grid and simulation parameters (fixed throughout)
nx, ny = 30, 30
n = nx * ny
n_steps = 500
dt = 0.05 # 500 steps x 0.05s = 25s (CFL-safe up to k0 ~ 80)
Lx, Ly = 0.1, 0.05
# Fixed physical parameters
rho = 7850.0 # density [kg/m^3] (mild steel)
cp = 460.0 # specific heat [J/(kg*K)]
h_conv = 25.0 # convection coefficient [W/(m^2*K)]
T_inf = 293.15 # ambient temperature [K]
T_hot = 373.15 # hot wall temperature [K]
# Initial condition: uniform ambient temperature
T_init = np.full(n, T_inf)
Q = np.zeros(n) # no internal heating
rng = np.random.default_rng(42) # reproducible noise
# Figures are written here (gitignored); copy into docs/static/blog/
# as enzyme-<name>.png by hand when updating the blog post.
FIGURE_DIR = Path("figures")
FIGURE_DIR.mkdir(exist_ok=True)
print(f"Simulation: {n_steps} steps x {dt}s = {n_steps * dt:.0f}s total")
Simulation: 500 steps x 0.05s = 25s total
Step 2: Verify the gradients against an analytic derivative¶
Before using the gradients in an optimizer, we check them against a derivative we can compute independently, without Enzyme anywhere in the loop.
For one regime we can do exactly that. Setting the conductivity’s temperature coefficient \(k_1 = 0\) makes the solver linear: each explicit step is an affine map of the temperature field, \(T^{s+1} = A(k_0)\,T^s + c(k_0)\), where the operators \(A\) and \(c\) encode the stencil and the mixed boundary conditions and depend affinely on \(k_0\). This structure gives the exact derivative of the whole multi-step trajectory via the tangent recurrence
which we iterate in plain NumPy. We recover \(A\) and \(c\) by probing the solver at two stable \(k_0\) values (the map is exactly affine in \(k_0\), so any two recover it with no truncation error), confirm the reconstruction reproduces the solver’s trajectory to roundoff, then compare the analytic gradient against Enzyme’s VJP. For reference, we also include a finite-difference estimate swept over step size, which agrees with the analytic gradient only to limited accuracy.
# --- Independent analytic-gradient benchmark (linear regime, k1 = 0) ---
k0_bench = 45.0 # CFL r ~ 0.26; both probe values must keep r < 0.5
coef = dt / (rho * cp)
def _bench_inputs(k0, n_steps_, field):
return {
"T_init": jnp.asarray(field),
"Q": jnp.asarray(Q),
"nx": nx,
"ny": ny,
"n_steps": n_steps_,
"k0": jnp.float64(k0),
"k1": jnp.float64(0.0), # linear regime
"rho": jnp.float64(rho),
"cp": jnp.float64(cp),
"h_conv": jnp.float64(h_conv),
"T_inf": jnp.float64(T_inf),
"T_hot": jnp.float64(T_hot),
"Lx": jnp.float64(Lx),
"Ly": jnp.float64(Ly),
"dt": jnp.float64(dt),
}
def _step(field, k0):
"""One explicit solver step from `field` at conductivity k0."""
out = apply_tesseract(enzyme_tess, _bench_inputs(k0, 1, field))["T_final"]
return np.asarray(out, dtype=np.float64)
def _affine_map(k0):
"""Recover U(T;k0) = A T + c via unit probes (n+1 solver calls)."""
c = _step(np.zeros(n), k0)
A = np.zeros((n, n))
for j in range(n):
e = np.zeros(n)
e[j] = 1.0
A[:, j] = _step(e, k0) - c
return A, c
# Non-uniform initial field so the gradient is non-degenerate.
xb, yb = np.linspace(0, Lx, nx), np.linspace(0, Ly, ny)
XXb, YYb = np.meshgrid(xb, yb)
T_init_bench = (
T_inf + 40.0 * np.sin(np.pi * XXb / Lx) * np.sin(np.pi * YYb / Ly)
).ravel()
ka, kb = 45.0, 40.0
print(f"Recovering affine map at k0={ka} ({n} probes)...")
Aa, ca = _affine_map(ka)
print(f"Recovering affine map at k0={kb} ({n} probes)...")
Ab, cb = _affine_map(kb)
A1 = (Aa - Ab) / (ka - kb)
A0 = Aa - ka * A1
c1 = (ca - cb) / (ka - kb)
c0 = ca - ka * c1
A = A0 + k0_bench * A1
c = c0 + k0_bench * c1
# Exact tangent recurrence over the full 500-step trajectory (pure NumPy).
T = T_init_bench.copy()
dT = np.zeros(n)
for _ in range(n_steps):
dT = A1 @ T + A @ dT + c1
T = A @ T + c
analytic = float(np.sum(dT))
# Consistency check: does the affine model match the solver's real trajectory?
T_solver = np.asarray(
apply_tesseract(enzyme_tess, _bench_inputs(k0_bench, n_steps, T_init_bench))[
"T_final"
],
dtype=np.float64,
)
traj_rel = np.linalg.norm(T - T_solver) / np.linalg.norm(T_solver)
print(f"affine-model vs solver trajectory rel err = {traj_rel:.2e}")
# Enzyme VJP of the same scalar (cotangent = ones).
vjp = enzyme_tess.vector_jacobian_product(
inputs=_bench_inputs(k0_bench, n_steps, T_init_bench),
vjp_inputs=["k0"],
vjp_outputs=["T_final"],
cotangent_vector={"T_final": np.ones(n)},
)
enzyme_grad = float(vjp["k0"])
enzyme_err = abs(enzyme_grad - analytic) / (abs(analytic) + 1e-30)
print(f"analytic gradient = {analytic:.10e}")
print(f"enzyme gradient = {enzyme_grad:.10e}")
print(f"Enzyme rel err vs analytic = {enzyme_err:.2e}")
# Central finite differences vs the same analytic reference, swept over step size.
epsilons = np.logspace(-1, -12, 24)
fd_err = []
for eps in epsilons:
Tp = np.asarray(
apply_tesseract(
enzyme_tess, _bench_inputs(k0_bench + eps, n_steps, T_init_bench)
)["T_final"]
)
Tm = np.asarray(
apply_tesseract(
enzyme_tess, _bench_inputs(k0_bench - eps, n_steps, T_init_bench)
)["T_final"]
)
fd = np.sum(Tp - Tm) / (2 * eps)
fd_err.append(abs(fd - analytic) / (abs(analytic) + 1e-30))
fig, ax = plt.subplots(1, 1, figsize=(7, 4.5))
ax.loglog(
epsilons, fd_err, "o-", ms=4, color="#1971c2", label="Central finite differences"
)
ax.axhline(
enzyme_err, color="#e8590c", lw=2, label=f"Enzyme AD ($\\approx${enzyme_err:.0e})"
)
ax.set_xlabel("Finite difference step size $\\epsilon$")
ax.set_ylabel("Relative error vs. independent analytic gradient")
ax.set_title("Enzyme matches the analytic gradient; FD only in a narrow sweet spot")
ax.legend(framealpha=0.9)
ax.grid(True, alpha=0.2)
plt.tight_layout()
fig.savefig(FIGURE_DIR / "analytic_benchmark.png", dpi=180, bbox_inches="tight")
plt.show()
Recovering affine map at k0=45.0 (900 probes)...
Recovering affine map at k0=40.0 (900 probes)...
affine-model vs solver trajectory rel err = 1.13e-14
analytic gradient = 2.4101518794e+02
enzyme gradient = 2.4101518794e+02
Enzyme rel err vs analytic = 5.63e-12
Takeaways¶
Each jax.value_and_grad call above triggered the following chain:
Layer |
Technology |
Role |
|---|---|---|
Optimizer |
SciPy L-BFGS-B |
Quasi-Newton update loop |
AD framework |
JAX reverse-mode |
Propagates cotangents |
Tesseract bridge |
|
Registers JAX primitive, dispatches HTTP calls |
Transport |
HTTP + JSON |
Crosses the process/container boundary |
AD engine |
Enzyme (LLVM pass) |
Generates the VJP from compiled Fortran IR |
Solver |
Fortran 90 |
|
Compiled Fortran can participate in end-to-end AD. The forward solver is plain Fortran and was never modified. Enzyme differentiated it from the compiled LLVM IR, and Tesseract made it callable – and differentiable – from JAX.
No hand-coded adjoints. Both the forward- and reverse-mode derivatives are synthesized by the Enzyme pass, rather than derived and maintained by hand.
The gradients are verifiable. In the linear regime, Enzyme’s VJP matches an independently derived analytic gradient to roundoff.
Reverse-mode AD scales to high-dimensional inverse problems. The same
jax.value_and_gradcall returns all 900 gradient components from a single reverse sweep, which is what makes the 900-parameter reconstruction tractable.
What’s next¶
Change the forward model. Edit the Fortran source and rebuild; Enzyme regenerates the derivatives with no adjoint code to update.
Try a different inverse problem. Recover a boundary condition or a heat source instead of the initial field, or vary the number and placement of sensors.
Swap in a different optimizer. Replace L-BFGS-B with any other
scipy.optimizeoroptaxoptimizer to compare convergence behavior.Explore other demos. See the CFD optimization, data assimilation, and FEM shape optimization demos for other ways to compose Tesseracts with JAX.
Questions? Feedback? Please reach out through the Tesseract Community Forum.