Skip to content
cfd-lab:~/en/posts/2026-06-17-curvilinear-c…● online

Translated from the author's Korean original. Read the original (한국어)

NOTE #075DAY WED CFD기법DATE 2026.06.17UPDATED 2026.10.19READ 6 min read#Curvilinear#Grid-Metrics#Jacobian#Mesh#FVM

Wrapping a Rectangular Grid Around a Wing — Curvilinear Coordinate Transforms and Grid Metrics

Writing Fhat from y_η and x_η lets the mixed differences cancel, down to roundoff. Divide by J and multiply back, and that cancel breaks.

An engineer wants to solve the flow around a wing. The solver in hand only understands Cartesian grids. But the wing is curved. No matter how finely you chop the rectangular cells, a curved surface still comes out looking like a staircase. The fix is not to bend the grid but to bend space itself. You map the warped physical grid back onto a perfectly straight logical grid, then solve the equations there. This post walks through the coordinate transform and grid metrics that make that round trip possible. By the end, you will see why the Jacobian flags grid folding, and what it takes for a uniform flow to stay uniform on a curved grid — verified directly in numpy.

Raise shear and amplitude on the grid mapper and the cells twist. The Jacobian heatmap paints each cell by J/J0J/J_0.

shear (η 기울임) 0.30
amplitude 0.15
wave number 2
grid n = 16
J/J₀ min = 1.00, max = 1.00 · 접힘 없음 (J>0)

Push amplitude near 0.3 and wave number to 4 or higher and red cells show up. JJ in those cells is negative. Cross that line and a metric denominator passes through zero.

Physical and Logical Domains#

Curvilinear methods start with two domains. The physical domain is where the warped grid wrapping the wing lives. Its coordinates are (x,y)(x, y). The logical domain is that same grid flattened into a unit square. Its coordinates are (ξ,η)(\xi, \eta).

The mapping is the function that links them.

x=x(ξ,η),y=y(ξ,η)x = x(\xi, \eta), \qquad y = y(\xi, \eta)

Here ξ\xi is the index along one grid direction and η\eta along the other. In the logical domain every cell is a square of side Δξ=Δη=1/N\Delta\xi = \Delta\eta = 1/N. The solver computes differences only on this straight grid. All of the warping is absorbed into the mapping function.

Metrics: The Exchange Rate the Grid Builds#

The trouble is that the equation we want to solve is written in derivatives with respect to x,yx, y. The grid moves along ξ,η\xi, \eta. The exchange-rate table that connects them is the set of grid metrics.

Start from the chain rule.

∂∂x=ξx∂∂ξ+ηx∂∂η,∂∂y=ξy∂∂ξ+ηy∂∂η\frac{\partial}{\partial x} = \xi_x \frac{\partial}{\partial \xi} + \eta_x \frac{\partial}{\partial \eta}, \qquad \frac{\partial}{\partial y} = \xi_y \frac{\partial}{\partial \xi} + \eta_y \frac{\partial}{\partial \eta}

Terms like ξx=∂ξ/∂x\xi_x = \partial\xi/\partial x are the metric coefficients. What we actually hold is the reverse: xξ,xη,yξ,yηx_\xi, x_\eta, y_\xi, y_\eta, because differentiating the mapping with respect to ξ,η\xi, \eta gives them directly. The two are tied through the inverse-matrix relation.

ξx=yηJ,ξy=−xηJ,ηx=−yξJ,ηy=xξJ\xi_x = \frac{y_\eta}{J}, \quad \xi_y = -\frac{x_\eta}{J}, \quad \eta_x = -\frac{y_\xi}{J}, \quad \eta_y = \frac{x_\xi}{J}

Each symbol is a first derivative of the mapping, and JJ is the Jacobian from the next section. In short, a metric is "the easy-to-get xξx_\xi family divided by the Jacobian."

The Jacobian and Grid Folding#

The Jacobian determinant measures how much the mapping stretches or shrinks area.

J=det⁡(xξxηyξyη)=xξyη−xηyξJ = \det \begin{pmatrix} x_\xi & x_\eta \\ y_\xi & y_\eta \end{pmatrix} = x_\xi y_\eta - x_\eta y_\xi

Here JJ is the ratio between a unit area in the logical domain and the area it occupies in the physical domain. If J>0J > 0 the mapping preserves orientation. If J=0J = 0 a cell collapses to a point or a line. If J<0J < 0 the cell flips over. That is grid folding. On a folded grid the metric denominators cross zero and blow up, and the solver spits out NaN.

J=xξyη−xηyξJ = x_\xi y_\eta - x_\eta y_\xi is the cross product of the two cell edges. Same term as the cell area measured by a diagonal cross product. A cell that went red on the heatmap above is where J<0J < 0.

The Transformed Conservation Law#

Now move the equation into the logical domain. Take a 2D conservation law.

∂Q∂t+∂F∂x+∂G∂y=0\frac{\partial Q}{\partial t} + \frac{\partial F}{\partial x} + \frac{\partial G}{\partial y} = 0

QQ is the conserved quantity and F,GF, G are the fluxes in x,yx, y. Substituting the metrics and multiplying by JJ yields the strong conservation form.

∂(JQ)∂t+∂F^∂ξ+∂G^∂η=0\frac{\partial (JQ)}{\partial t} + \frac{\partial \hat F}{\partial \xi} + \frac{\partial \hat G}{\partial \eta} = 0

The transformed fluxes are

F^=J(ξxF+ξyG),G^=J(ηxF+ηyG)\hat F = J(\xi_x F + \xi_y G), \qquad \hat G = J(\eta_x F + \eta_y G)

The key point is that products like Jξx=yηJ\xi_x = y_\eta and Jξy=−xηJ\xi_y = -x_\eta fold back into plain derivatives of the mapping. So the transformed flux never has to divide by a metric and then multiply it back. Keep this form and mass and momentum stay exactly conserved even on a curved grid.

Computing and Verifying Metrics in Code#

Port the theory to numpy. Define a wavy physical grid, recover the metrics and Jacobian with central differences, and check whether a uniform flux is really conserved.

import numpy as np
 
def body_fitted_map(xi, eta, amp=0.15, shear=0.3, wavek=2.0):
    """Map logical coords (xi, eta) in [0,1]^2 to physical coords (x, y)."""
    x = xi + shear * eta + amp * np.sin(np.pi * wavek * eta)
    y = eta + amp * np.sin(np.pi * wavek * xi)
    return x, y
 
def compute_metrics(x, y, dxi, deta):
    """Recover Jacobian J and inverse metrics by central differences."""
    x_xi  = np.gradient(x, dxi,  axis=1)   # dx/dxi
    x_eta = np.gradient(x, deta, axis=0)   # dx/deta
    y_xi  = np.gradient(y, dxi,  axis=1)
    y_eta = np.gradient(y, deta, axis=0)
    J = x_xi * y_eta - x_eta * y_xi        # Jacobian determinant
    xi_x, xi_y  =  y_eta / J, -x_eta / J
    eta_x, eta_y = -y_xi / J,  x_xi / J
    return J, (xi_x, xi_y, eta_x, eta_y)
 
# logical grid
N = 41
xi  = np.linspace(0, 1, N)
eta = np.linspace(0, 1, N)
dxi, deta = xi[1] - xi[0], eta[1] - eta[0]
XI, ETA = np.meshgrid(xi, eta)             # axis0=eta, axis1=xi
 
X, Y = body_fitted_map(XI, ETA, amp=0.15, shear=0.3, wavek=2.0)
J, _ = compute_metrics(X, Y, dxi, deta)
 
print(f"J min/max = {J.min():.4f} / {J.max():.4f}")
print("grid:", "folded (J<=0)" if J.min() <= 0 else "valid (J>0)")

Next is the freestream-preservation test. Feed in constant fluxes F,GF, G and the residual should be zero. Writing the transformed flux directly as Jξx=yηJ\xi_x = y_\eta lets the mixed central differences commute, so the residual drops to round-off.

def freestream_residual(x, y, dxi, deta, F=1.0, G=0.5):
    """Freestream residual for constant flux (F,G). Should be zero."""
    x_xi  = np.gradient(x, dxi,  axis=1)
    x_eta = np.gradient(x, deta, axis=0)
    y_xi  = np.gradient(y, dxi,  axis=1)
    y_eta = np.gradient(y, deta, axis=0)
    Fhat =  F * y_eta - G * x_eta          # = J(xi_x F + xi_y G)
    Ghat = -F * y_xi  + G * x_xi           # = J(eta_x F + eta_y G)
    dFhat = np.gradient(Fhat, dxi,  axis=1)
    dGhat = np.gradient(Ghat, deta, axis=0)
    return dFhat + dGhat
 
R = freestream_residual(X, Y, dxi, deta)
print(f"freestream residual max|R| (interior) = {np.abs(R[1:-1, 1:-1]).max():.2e}")
# e.g. J min/max = 0.62 / 1.38, freestream residual max|R| ~ 1e-16

A common mistake is solving in the non-conservative form, dividing the metric by JJ and then multiplying it back onto the flux. In three dimensions that mismatch grows and seeds a spurious source in a uniform flow. The symmetric conservative metrics of Thomas and Lombard (1979) prevent it. In 2D, writing F^\hat F directly in terms of yη,xηy_\eta, x_\eta as above is enough.

Start from the numbers in the code comment. With amp=0.15, shear=0.3, wavek=2, and N=41, JJ runs from 0.62 to 1.38. The max is 1.38/0.621.38/0.62, about 2.23 times the min. Cell area wobbles by more than a factor of two and every JJ stays positive. Nothing has folded. On that same mesh the freestream residual max⁡∣R∣\max|R| is about 1e-16, for F=1F=1 and G=0.5G=0.5.

Follow the residual through the formula. F^=Fyη−Gxη\hat F = F y_\eta - G x_\eta and G^=−Fyξ+Gxξ\hat G = -F y_\xi + G x_\xi. Add the ξ\xi difference to the η\eta difference. As long as the central operator swaps mixed derivatives, the ξ\xi derivative of yηy_\eta cancels the η\eta derivative of yξy_\xi. FF and GG are constant, so they factor out. What is left is the difference of two mixed derivatives of the map. The same operator makes that difference zero. Divide ξx\xi_x by JJ and multiply by JJ again and, on paper, you are back at yηy_\eta. On a mesh the division and the product can touch JJ at different places. That gap is a fake source in a uniform stream. In 2D, writing F^\hat F straight from yηy_\eta and xηx_\eta never opens it. The 1e-16 in the comment is the cancellation that survived.

β\beta produces a number from the same formula. At the wall, η=0\eta=0, dy/dη=β/(eβ−1)dy/d\eta = \beta/(e^\beta-1). At η=1\eta=1 it is βeβ/(eβ−1)\beta e^\beta/(e^\beta-1). The ratio of the two slopes is eβe^\beta. At β=4\beta=4 that is about 54.6. The "tens of times" in the wall section is this ratio. The limit β→0\beta \to 0 is a slope of 1, a uniform mesh.

Clustering Cells Near the Wall#

To resolve a boundary layer you have to pack cells near the wall. That is the same coordinate transform in one dimension. Stretch a uniform η\eta with an exponential to build the physical coordinate yy.

y(η)=eβη−1eβ−1y(\eta) = \frac{e^{\beta \eta} - 1}{e^{\beta} - 1}

Here β\beta is the clustering parameter. The 1D Jacobian is dy/dη=βeβη/(eβ−1)dy/d\eta = \beta e^{\beta\eta}/(e^\beta - 1), and the grid tightens wherever that value is small.

Play with β\beta in the simulation below.

clustering β = 2.5
nodes n = 16
첫 간격 Δy₀ = 0.0000 · 마지막 간격 = 0.0000 · 비율 = —×

Raise β\beta from 0 to 4 and the first spacing near the wall (y=0y=0) shrinks sharply, opening the ratio against the last spacing to tens of times. The same cell count resolves the boundary layer far better.

Divide by J and multiply back, and a uniform flow grows a source#

A cell with negative JJ does not get a flux. On the default wave, amp=0.15, shear=0.3, wavek=2, N=41, JJ sits between 0.62 and 1.38. Nothing is folded. If the generator paints even one red cell, that mesh does not go into the solver. NaN comes out of that denominator.

One form stays. Write F^\hat F from yηy_\eta and xηx_\eta. Dividing by JJ and multiplying back opens a gap even in 2D, and in 3D it is the spurious source Thomas–Lombard blocked. Turning the stiffness itself with the same metric is a separate case, in the coordinate transform of the constitutive tensor.

Share if you found it helpful.