Arakawa C-Grid Discretization
This document describes the numerical discretization scheme used throughout
finitevolX, explaining the same-size array convention, ghost-cell layout,
variable co-location, and the slicing patterns that implement each stencil.
Overview
finitevolX uses an Arakawa C-grid, a staggered finite-volume mesh where
different physical variables live at different grid locations. The key
departure from many textbook implementations is that every array has the
same total shape [Ny, Nx], including one ring of ghost cells on each
side. There are no separate [Ny, Nx+1] or [Nx-1, Ny-1] arrays.
This same-size convention simplifies JAX JIT compilation (all shapes are statically known from the grid parameters alone) and enables clean, uniform ghost-cell handling.
Variable Co-location
Four distinct locations are used, identified by letter:
| Symbol | Name | Physical location | Same-index meaning |
|---|---|---|---|
T |
cell centre | (j·dy, i·dx ) |
T[j, i] lives at (j, i ) |
U |
east face | (j·dy, (i+½)·dx ) |
U[j, i] lives at (j, i+½ ) |
V |
north face | ((j+½)·dy, i·dx ) |
V[j, i] lives at (j+½, i ) |
X |
NE corner | ((j+½)·dy, (i+½)·dx ) |
X[j, i] lives at (j+½, i+½ ) |
The "same-index" rule means that array index [j, i] encodes the
south-west corner of the stencil neighbourhood:
Ghost Cells
For a grid with Nx × Ny total cells, the physical interior occupies
indices [1:-1, 1:-1] (shape (Ny-2) × (Nx-2)). The outer ring —
rows 0 and Ny-1, columns 0 and Nx-1 — consists of ghost cells
reserved for boundary conditions.
T-point ghost cells
col: 0 1 2 ... Nx-2 Nx-1
row 0: [g] [g] [g] ... [g] [g] ← ghost (south)
row 1: [g] [ ] [ ] ... [ ] [g] ← first interior row
... interior ...
row Ny-2:[g][ ] [ ] ... [ ] [g] ← last interior row
row Ny-1:[g] [g] [g] ... [g] [g] ← ghost (north)
^ ^
ghost ghost
(west) (east)
U-point ghost cells
U[j, i] sits at east face (j, i+1/2).
| Index | Physical meaning | Who sets it? |
|---|---|---|
U[j, 0] |
West boundary face (j, ½) |
BC layer (e.g. periodic copy) |
U[j, 1..Nx-2] |
Interior east faces | Forward operators (diff_x_T_to_U, T_to_U) |
U[j, Nx-1] |
Outside domain | Unused / left zero |
U[0, i], U[Ny-1, i] |
South/north ghost rows | BC layer |
U[j, Nx-2] is the east boundary face — it sits inside [1:-1, 1:-1]
and is computed by forward operators using the east ghost T-cell T[j, Nx-1].
V-point ghost cells
V[j, i] sits at north face (j+½, i).
| Index | Physical meaning | Who sets it? |
|---|---|---|
V[0, i] |
South boundary face (½, i) |
BC layer |
V[1..Ny-2, i] |
Interior north faces | Forward operators (diff_y_T_to_V, T_to_V) |
V[Ny-1, i] |
Outside domain | Unused / left zero |
V[j, 0], V[j, Nx-1] |
West/east ghost cols | BC layer |
V[Ny-2, i] is the north boundary face — computed using north ghost T.
X-point ghost cells
X[j, i] sits at NE corner (j+½, i+½).
| Index | Physical meaning | Who sets it? |
|---|---|---|
X[0, i] |
South ghost X-row | BC layer |
X[j, 0] |
West ghost X-col | BC layer |
X[1..Ny-2, 1..Nx-2] |
Interior corners | Forward operators (T_to_X, diff_y_U_to_X, etc.) |
X[Ny-1, i], X[j, Nx-1] |
Outside domain | Unused / left zero |
Ghost asymmetry: forward vs backward
The same [1:-1, 1:-1] write range has different ghost-cell implications
depending on direction:
| Operator direction | Last interior output uses... | First interior output reads... |
|---|---|---|
| T→U (forward x) | east ghost T T[j, Nx-1] |
— |
| T→V (forward y) | north ghost T T[Ny-1, i] |
— |
| V→X (forward x) | east ghost V V[j, Nx-1] |
— |
| U→X (forward y) | north ghost U U[Ny-1, i] |
— |
| U→T (backward x) | — | west ghost U U[j, 0] |
| V→T (backward y) | — | south ghost V V[0, i] |
| X→U (backward y) | — | south ghost X X[0, i] |
| X→V (backward x) | — | west ghost X X[j, 0] |
| U→V (cross) | north ghost U U[Ny-1, i] |
— |
| V→U (cross) | east ghost V V[j, Nx-1] |
— |
Operators write only to [1:-1, 1:-1]. Ghost cells remain at their
initialised value (typically zero). Callers are responsible for filling
ghosts via boundary-condition helpers (pad_interior, enforce_periodic,
BoundaryConditionSet, etc.) before the next operator call.
Creating a Grid
from finitevolx import ArakawaCGrid2D
# 64 physical cells in each direction; 66×66 total array shape
grid = ArakawaCGrid2D.from_interior(nx_interior=64, ny_interior=64,
Lx=1.0, Ly=1.0)
# grid.Nx == 66, grid.Ny == 66
# grid.dx == 1/64, grid.dy == 1/64
All field arrays are then allocated as jnp.zeros((grid.Ny, grid.Nx)).
Difference Operators
Every finite-difference stencil is a one-cell shift divided by the grid spacing. The direction of the shift (forward or backward) is determined by which point type the output lives at.
Forward differences (T → face / corner)
| Method | Stencil formula | Writes to |
|---|---|---|
diff_x_T_to_U |
dh/dx[j, i+½] = (h[j, i+1] - h[j, i]) / dx |
U-points |
diff_y_T_to_V |
dh/dy[j+½, i] = (h[j+1, i] - h[j, i]) / dy |
V-points |
diff_y_U_to_X |
du/dy[j+½, i+½] = (u[j+1, i] - u[j, i]) / dy |
X-points |
diff_x_V_to_X |
dv/dx[j+½, i+½] = (v[j, i+1] - v[j, i]) / dx |
X-points |
Example slice for diff_x_T_to_U (writes to [1:-1, 1:-1]):
# dh_dx[j, i+1/2] = (h[j, i+1] - h[j, i]) / dx
out = out.at[1:-1, 1:-1].set((h[1:-1, 2:] - h[1:-1, 1:-1]) / dx)
The slice h[1:-1, 2:] gives h[j, i+1] for i ranging over interior
columns; h[1:-1, 1:-1] gives h[j, i].
Backward differences (face / corner → T)
| Method | Stencil formula | Writes to |
|---|---|---|
diff_x_U_to_T |
du/dx[j, i] = (u[j, i] - u[j, i-1]) / dx |
T-points |
diff_y_V_to_T |
dv/dy[j, i] = (v[j, i] - v[j-1, i]) / dy |
T-points |
diff_y_X_to_U |
dq/dy[j, i+½] = (q[j, i] - q[j-1, i]) / dy |
U-points |
diff_x_X_to_V |
dq/dx[j+½, i] = (q[j, i] - q[j, i-1]) / dx |
V-points |
Example slice for diff_x_U_to_T:
# du_dx[j, i] = (u[j, i+1/2] - u[j, i-1/2]) / dx
# = (u[j, i] - u[j, i-1] ) / dx
out = out.at[1:-1, 1:-1].set((u[1:-1, 1:-1] - u[1:-1, :-2]) / dx)
Divergence, Curl, and Laplacian
diff = Difference2D(grid=grid)
divergence = diff.divergence(u, v) # du/dx + dv/dy at T-points
curl = diff.curl(u, v) # dv/dx - du/dy at X-points
laplacian = diff.laplacian(h) # d²h/dx² + d²h/dy² at T-points
Discrete-non-divergence property: A velocity derived from a
corner-streamfunction ψ via
u = -diff_y_X_to_U(ψ), v = diff_x_X_to_V(ψ)
is exactly non-divergent at interior T-points.
Interpolation Operators
Each interpolation moves a field between co-location points by averaging the two nearest neighbours along the relevant axis.
Averaging table
| Method | Formula | Source → Target |
|---|---|---|
T_to_U |
h[j, i+½] = ½(h[j,i] + h[j,i+1]) |
T → U |
T_to_V |
h[j+½, i] = ½(h[j,i] + h[j+1,i]) |
T → V |
T_to_X |
h[j+½, i+½] = ¼(h[j,i]+h[j,i+1]+h[j+1,i]+h[j+1,i+1]) |
T → X |
U_to_T |
u[j, i] = ½(u[j,i+½] + u[j,i-½]) = ½(u[j,i]+u[j,i-1]) |
U → T |
V_to_T |
v[j, i] = ½(v[j+½,i] + v[j-½,i]) = ½(v[j,i]+v[j-1,i]) |
V → T |
X_to_T |
4-point average over q[j,i], q[j-1,i], q[j,i-1], q[j-1,i-1] |
X → T |
U_to_X |
u[j+½,i+½] = ½(u[j,i] + u[j+1,i]) |
U → X |
V_to_X |
v[j+½,i+½] = ½(v[j,i] + v[j,i+1]) |
V → X |
X_to_U |
q[j,i+½] = ½(q[j,i] + q[j-1,i]) |
X → U |
X_to_V |
q[j+½,i] = ½(q[j,i] + q[j,i-1]) |
X → V |
U_to_V |
4-point average over u[j,i], u[j+1,i], u[j,i-1], u[j+1,i-1] |
U → V |
V_to_U |
4-point average over v[j,i], v[j-1,i], v[j,i+1], v[j-1,i+1] |
V → U |
Example slice for T_to_U:
# h_on_u[j, i+1/2] = 0.5 * (h[j, i] + h[j, i+1])
out = out.at[1:-1, 1:-1].set(0.5 * (h[1:-1, 1:-1] + h[1:-1, 2:]))
The key: h[1:-1, 1:-1] is h[j, i] and h[1:-1, 2:] is h[j, i+1]
for interior column indices i = 1 … Nx-2.
Kinetic Energy and Bernoulli Potential
These operators live in finitevolx._src.operators.diagnostics and
follow the same conventions.
Kinetic energy at T-points
where the face-squared fields are averaged to cell centres:
u²_on_T[j, i] = ½ (u[j, i+½]² + u[j, i-½]²)
= ½ (u[j, i]² + u[j, i-1]²)
v²_on_T[j, i] = ½ (v[j+½, i]² + v[j-½, i]²)
= ½ (v[j, i]² + v[j-1, i]²)
# ke[j, i] at T-points
u2 = u**2; v2 = v**2
u2_on_T = 0.5 * (u2[1:-1, 1:-1] + u2[1:-1, :-2]) # U → T in x
v2_on_T = 0.5 * (v2[1:-1, 1:-1] + v2[:-2, 1:-1]) # V → T in y
out = out.at[1:-1, 1:-1].set(0.5 * (u2_on_T + v2_on_T))
Bernoulli potential at T-points
Both ke and h are at T-points, so this is a simple elementwise sum
restricted to the interior:
Slicing Reference
The table below collects all the slice patterns used in stencil
implementations. Ny, Nx denote the total array sizes.
| Pattern | Meaning (rows = j, cols = i) | Shape |
|---|---|---|
arr[1:-1, 1:-1] |
interior at (j, i), both directions |
(Ny-2, Nx-2) |
arr[1:-1, 2:] |
interior rows, one step east | (Ny-2, Nx-2) |
arr[1:-1, :-2] |
interior rows, one step west | (Ny-2, Nx-2) |
arr[2:, 1:-1] |
interior cols, one step north | (Ny-2, Nx-2) |
arr[:-2, 1:-1] |
interior cols, one step south | (Ny-2, Nx-2) |
arr[2:, 2:] |
one step north-east | (Ny-2, Nx-2) |
arr[:-2, :-2] |
one step south-west | (Ny-2, Nx-2) |
Notice that every shifted slice has the same shape as the unshifted interior slice. This is possible because arrays have one ghost-cell ring on each side, so a single-step shift never reads outside the array bounds.
Ghost-Cell Interaction at Stencil Boundaries
The first and last interior points see one ghost neighbour. For example,
diff_x_T_to_U at the first interior U-column (i=1, output out[j, 1])
uses h[j, 1] (interior T-point) and h[j, 2] (second interior
T-point) — no ghosts involved.
However, diff_x_U_to_T at i=1 uses u[j, 1] and u[j, 0]. If
u[j, 0] is a ghost (zero by default), the result at i=1 reflects that
zero ghost rather than a physical extrapolation. This is intentional: the
caller sets ghost values via the boundary-condition layer before calling
operators.
A consequence: the composition
diff_x_U_to_T(diff_x_T_to_U(h)) equals d²h/dx² only at columns
i = 2 … Nx-3 (deep interior), because the intermediate dh_u field has
a zero ghost at i = 0 that pollutes column i = 1.
Concrete example
For h[j, i] = c·i·dx and no boundary conditions applied:
dh_u = diff_x_T_to_U(h)
# dh_u[j, 0] = 0 ← ghost U-face, NOT written by the operator
# dh_u[j, 1] = c ← first interior U-face
# dh_u[j, 2..Nx-2] = c ← rest of interior
result = diff_x_U_to_T(dh_u)
# result[j, 1] = (c - 0) / dx = c/dx ← non-zero, ghost pollutes i=1
# result[j, 2..Nx-3] = (c - c) / dx = 0 ← correct 2nd derivative
This is correct operator behaviour. The ghost U-face at i=0 is the
west boundary face U[j, ½]; its value must be supplied by the BC layer
(periodic, no-slip, etc.), not by the forward-difference stencil. Always
apply boundary conditions to intermediate fields before chaining operators.
Example: Advection Step
A typical shallow-water advection step uses all four co-location points:
from finitevolx import (
ArakawaCGrid2D, Difference2D, Interpolation2D, Vorticity2D,
enforce_periodic,
)
import jax.numpy as jnp
grid = ArakawaCGrid2D.from_interior(64, 64, 1.0, 1.0)
diff = Difference2D(grid=grid)
interp = Interpolation2D(grid=grid)
vort = Vorticity2D(grid=grid)
# ---- assume u, v, h are shape (66, 66) with updated ghosts ----
# 1. Potential vorticity at X-points
f = jnp.zeros((grid.Ny, grid.Nx)) # Coriolis (T-points)
q = vort.potential_vorticity(u, v, h, f) # X-points
# 2. PV flux (Arakawa-Lamb)
qu, qv = vort.pv_flux_arakawa_lamb(q, u, v) # U- and V-points
# 3. Kinetic energy and Bernoulli potential
from finitevolx._src.operators.diagnostics import bernoulli_potential
p = bernoulli_potential(h=h, u=u, v=v) # T-points
# 4. Tendencies
du_dt = -diff.diff_x_T_to_U(p) + qv # U-points
dv_dt = -diff.diff_y_T_to_V(p) - qu # V-points
dh_dt = -diff.divergence(h_u, h_v) # T-points (h*u fluxes)
Every intermediate array has the same shape (66, 66). The ghost ring
carries boundary-condition data; the interior [1:-1, 1:-1] carries the
physical solution.