Skip to content

Elliptic Solver for the Implicit Free Surface

Overview

The implicit free-surface formulation in NeuralPOM reduces the barotropic (external) mode to a 2D elliptic boundary value problem for the sea surface elevation ηn+1. This approach eliminates the restrictive CFL condition associated with fast barotropic gravity waves, allowing larger time steps for the 3D baroclinic mode.

The solver is implemented in src/neuralpom/cores/implicit/implicit_barotropic.py as the ImplicitBarotropicMixin class, with an optimized version in src/neuralpom/cores/implicit/core.py (POM2K_Core) that adds cached geometry terms, Jacobi-preconditioned PCG, and reusable scratch buffers.

Derivation of the 2D Elliptic Equation

Starting Equations

The depth-integrated continuity equation is:

ηt+(Du¯)=0

where D=h+η is the total water depth and u¯ is the depth-averaged horizontal velocity.

The depth-averaged momentum equation, discretized with a semi-implicit treatment of the surface pressure gradient, gives the provisional velocity (predictor):

Dnu¯=Dn1u¯n12ΔtiF(u¯n,ηn,ηn1)

where F contains all explicitly evaluated terms: advection, Coriolis, baroclinic pressure gradient, wind stress, bottom friction, and the explicit portion of the surface pressure gradient (weighted by 12α and α for ηn and ηn1 respectively).

The final velocity is obtained by adding the implicit pressure gradient correction:

Dn+1u¯n+1=Dnu¯2ΔtigαDnηn+1

Forming the Elliptic Equation

Substituting the momentum corrector into the continuity equation evaluated at time n+1 and using a leapfrog discretization for the time derivative:

ηn+1ηn12Δti=(Dnu¯2ΔtigαDnηn+1)

Rearranging to isolate ηn+1:

ηn+1(2Δti)2gα(Dnηn+1)=RHS

where the right-hand side is:

RHS=ηn12Δti(Dnu¯)

This is the fundamental elliptic equation:

η4Δti2gα(Dη)=RHS

where we write η=ηn+1 and D=Dn for brevity.

The Elliptic Operator

On the Arakawa-C grid, the operator L(η)=η4Δti2gα(Dη) is discretized using a 5-point stencil with fluxes at U- and V-faces.

Discretized Form

The divergence term is computed as the sum of four fluxes:

L(η)i,j=ηi,j4Δti2gα1arti,j[Fi+1/2,jxFi1/2,jx+Fi,j+1/2yFi,j1/2y]

where the fluxes are:

East flux (at U-face i+1/2,j):

Fi+1/2,jx=Di+1,j+Di,j2ηi+1,jηi,j(dxi+1,j+dxi,j)/2dyi+1,j+dyi,j2dumi+1,j

West flux (at U-face i1/2,j):

Fi1/2,jx=Di,j+Di1,j2ηi,jηi1,j(dxi,j+dxi1,j)/2dyi,j+dyi1,j2dumi,j

North flux (at V-face i,j+1/2):

Fi,j+1/2y=Di,j+1+Di,j2ηi,j+1ηi,j(dyi,j+1+dyi,j)/2dxi,j+1+dxi,j2dvmi,j+1

South flux (at V-face i,j1/2):

Fi,j1/2y=Di,j+Di,j12ηi,jηi,j1(dyi,j+dyi,j1)/2dxi,j+dxi,j12dvmi,j

The land masks dum, dvm enforce zero flux across land boundaries.

Coefficient

The implicit coefficient is:

coeff=4Δti2gα

For the standard configuration:

  • Δti=900 s
  • g=9.81 m/s2
  • α=0.333

This gives coeff=4×9002×9.81×0.3331.06×107 m2.

Python Implementation

The reference implementation in apply_elliptic_operator:

python
def apply_elliptic_operator(self, eta, d_2d):
    # H at U/V faces
    H_u_east = 0.5 * (d_2d[i+1,j] + d_2d[i,j])
    H_u_west = 0.5 * (d_2d[i,j] + d_2d[i-1,j])
    H_v_north = 0.5 * (d_2d[i,j+1] + d_2d[i,j])
    H_v_south = 0.5 * (d_2d[i,j] + d_2d[i,j-1])

    # Fluxes with land mask
    flux_x_east = H_u_east * (eta[i+1,j] - eta[i,j]) / dx_east * dy_east * dum[i+1,j]
    flux_x_west = H_u_west * (eta[i,j] - eta[i-1,j]) / dx_west * dy_west * dum[i,j]
    flux_y_north = H_v_north * (eta[i,j+1] - eta[i,j]) / dy_north * dx_north * dvm[i,j+1]
    flux_y_south = H_v_south * (eta[i,j] - eta[i,j-1]) / dy_south * dx_south * dvm[i,j]

    div_flux = (flux_x_east - flux_x_west + flux_y_north - flux_y_south) / art[i,j]
    coeff = 4.0 * (self.dti ** 2) * self.grav * self.alpha
    out = eta.clone()
    out[i,j] = eta[i,j] - coeff * div_flux
    return out

5-Point Stencil Structure

The discretization produces a 5-point Laplacian-like stencil on the Arakawa-C grid. For each T-point (i,j), the operator involves:

  • Center: ηi,j with a coefficient modified by the divergence of fluxes
  • East: ηi+1,j through Fi+1/2,jx
  • West: ηi1,j through Fi1/2,jx
  • North: ηi,j+1 through Fi,j+1/2y
  • South: ηi,j1 through Fi,j1/2y

The effective stencil is a symmetric, diagonally dominant, positive-definite linear system. The system matrix is never explicitly formed -- the operator is applied matrix-free, which is ideal for iterative solvers.

Conjugate Gradient (CG) Method

Reference Implementation

The reference CG solver (solve_implicit_eta_cg in ImplicitBarotropicMixin) implements the standard Conjugate Gradient algorithm:

Algorithm: Conjugate Gradient for L(eta) = rhs

1. Warm start: x_0 = 2 * eta^n - eta^{n-1}  (linear extrapolation)
2. Apply BC to x_0
3. r_0 = rhs - L(x_0)
4. p_0 = r_0
5. for k = 1, 2, ..., max_iter:
       Enforce BC on p_{k-1}
       Ap = L(p_{k-1})
       alpha = (r_{k-1} · r_{k-1}) / (p_{k-1} · Ap)
       x_k = x_{k-1} + alpha * p_{k-1}
       Enforce BC on x_k
       r_k = r_{k-1} - alpha * Ap
       if ||r_k||^2 < tol^2: break
       beta = (r_k · r_k) / (r_{k-1} · r_{k-1})
       p_k = r_k + beta * p_{k-1}
       Enforce BC on p_k
6. return x_k

Warm Start

The initial guess uses linear extrapolation in time:

η(0)=2ηnηn1

This significantly reduces the number of CG iterations compared to a zero initial guess, typically cutting the iteration count by 50-70%.

Convergence Criteria

Convergence is declared when:

rkinterior2max(tol2,rel_tol2r02)

The default parameters are:

  • tol = 1e-12 (absolute tolerance)
  • rel_tol = 1e-6 (relative tolerance, in the accelerated solver)
  • max_iter = 200

Typical convergence for the Double Gyre configuration (250 × 250 grid) requires 15-30 iterations.

Preconditioned Conjugate Gradient (PCG)

The accelerated implicit core (POM2K_Core) implements Jacobi (diagonal) preconditioning for faster convergence.

Jacobi Preconditioner

The diagonal of the system matrix L is:

diagi,j=1+4Δti2gα1arti,jfaces(Hfdyfdxfmaskf)

where the sum runs over all four faces (east, west, north, south).

The preconditioner is M=diag(L), and the preconditioned system is:

M1L(η)=M1RHS

The PCG algorithm replaces the simple residual inner product with a preconditioned one:

Algorithm: Preconditioned Conjugate Gradient

rho_old = r · z              where z = M^{-1} r
p = z

for k = 1, 2, ...:
    alpha = rho_old / (p · Ap)
    x = x + alpha * p
    r = r - alpha * Ap
    z = M^{-1} r
    rho_new = r · z
    beta = rho_new / rho_old
    p = z + beta * p
    rho_old = rho_new

Implementation in the Accelerated Core

The accelerated CG solver uses pre-allocated work arrays to avoid repeated tensor allocations:

python
def solve_implicit_eta_cg(self, rhs, d_2d, max_iter=200, tol=1e-12, ...):
    # Reuse scratch arrays: ap, r, p, z, diag from self._cg_work
    ap = self._cg_work["ap"]
    r = self._cg_work["r"]
    p = self._cg_work["p"]
    z = self._cg_work["z"]
    diag = self._cg_work["diag"]

    # Build Jacobi preconditioner
    self._build_jacobi_preconditioner_into(d_2d, diag)

    # Apply preconditioner: z = M^{-1} * r
    z[s_i, s_j] = r[s_i, s_j] / (diag[s_i, s_j] + 1e-30)

    # PCG iteration loop
    for it in range(1, max_iter + 1):
        ...

Cached Geometry Terms

To avoid redundant computation, the accelerated core pre-computes all time-invariant geometric factors during initialization:

python
self._accel_cache = {
    "dy_over_dx_east": dy_east / (dx_east + 1e-20),
    "dy_over_dx_west": dy_west / (dx_west + 1e-20),
    "dx_over_dy_north": dx_north / (dy_north + 1e-20),
    "dx_over_dy_south": dx_south / (dy_south + 1e-20),
    "mask_u_east": self.dum[s_i_p1, s_j],
    "mask_u_west": self.dum[s_i, s_j],
    "mask_v_north": self.dvm[s_i, s_j_p1],
    "mask_v_south": self.dvm[s_i, s_j],
    "inv_art": 1.0 / (self.art[s_i, s_j] + 1e-20),
    "implicit_coeff": 4.0 * (self.dti ** 2) * self.grav * self.alpha,
    ...
}

Boundary Conditions

Dirichlet (Closed Basin)

For the Double Gyre problems (iproblem == 4 or iproblem == 5), the boundaries are closed (rigid walls) with zero free-surface elevation:

η=0onΩ
python
if self.iproblem == 4 or self.iproblem == 5:
    eta[0, :] = 0.0
    eta[im-1, :] = 0.0
    eta[:, 0] = 0.0
    eta[:, jm-1] = 0.0

The RHS is also zeroed at boundaries for consistency.

Neumann (Periodic or Open)

For other problem types (Seamount, Box, File), the boundary condition is Neumann (zero normal gradient):

ηn=0onΩ

Implemented as ghost-cell copying:

python
eta[0, :] = eta[1, :]
eta[im-1, :] = eta[im-2, :]
eta[:, 0] = eta[:, 1]
eta[:, jm-1] = eta[:, jm-2]

Computational Cost and GPU Considerations

Matrix-Free Operation

The elliptic operator is applied matrix-free: L(η) is computed directly from the stencil without ever assembling the sparse N×N matrix. For a grid of size im×jm, each evaluation of the elliptic operator requires:

  • 4 flux computations (one per face)
  • Total: approximately O(imjm) operations per CG iteration

Native PyTorch Operations

All operations use native PyTorch tensor operations:

python
# Flux computations are fully vectorized
flux_x_east = (
    h_u_east * (eta[s_i_p1, s_j] - eta[s_i, s_j])
    * cache["dy_over_dx_east"] * cache["mask_u_east"]
)

This enables:

  • GPU acceleration: All operations run directly on CUDA tensors.
  • Automatic differentiation: The solver is compatible with torch.autograd for gradient-based optimization in hybrid AI-physics training.
  • Batch processing: Multiple ensemble members can be solved simultaneously by adding a batch dimension.

Performance Characteristics

ConfigurationGrid SizeCG Iterations (typical)Time per Solve (GPU)
Double Gyre (old)354 × 32220-40~5 ms
Bire et al. (2025)250 × 25015-30~3 ms
Box64 × 648-15~0.5 ms

The Jacobi preconditioning typically reduces the CG iteration count by 30-50% compared to unpreconditioned CG.

Autograd Compatibility

When gradient tracking is active (e.g., during model training), the accelerated solver falls back to the reference CG implementation:

python
def solve_implicit_eta_cg(self, rhs, d_2d, ...):
    if torch.is_grad_enabled() and (rhs.requires_grad or d_2d.requires_grad):
        return _ReferencePOM2KCore.solve_implicit_eta_cg(
            self, rhs, d_2d, max_iter=max_iter, tol=tol, ...
        )

This ensures that the computational graph is properly constructed through the iterative solver. The reference implementation uses .clone() for all intermediate tensors so that in-place operations do not break the autograd tape. During pure forward simulation (no gradients), the accelerated PCG with pre-allocated buffers is used for maximum performance.

Shapiro Spatial Filter

After the CG solve, a 2D Shapiro spatial filter is optionally applied to the free-surface solution to remove grid-scale noise:

ηi,jsmooth=ηi,j+γ4(ηi+1,j+ηi1,j+ηi,j+1+ηi,j14ηi,j)

with γ=0.05. This is a 2nd-order Shapiro filter that acts as a weak biharmonic smoother, damping 2Δx modes while preserving larger-scale features. The filter is reapplied at the end, after which boundary conditions are enforced again via bcond(1, ...).

Released under the MIT License.