Skip to content
cfd-lab:~/en/posts/2026-07-17-least-squares…● online

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

NOTE #104DAY FRI CFD기법DATE 2026.07.17UPDATED 2026.10.19READ 6 min read#FVM#Gradient-Reconstruction#Least-Squares#Unstructured#OpenFOAM

When Gradients Go Wrong on Skewed Meshes — Green–Gauss vs. Weighted Least-Squares Reconstruction

A linear field stays exact to six decimals on a messy stencil. A curved field still needs n=1, or one far neighbor drags the fitted plane.

The residuals leaned over in a strange way, then blew up. Digging through the logs, I found one cell near the wall where the temperature gradient pointed almost perpendicular to the real one. I looked at the mesh: that one cell was oddly flat. The values were fine. The method that builds the gradient had tripped over the shape of the mesh.

In the finite volume method (FVM), the cell gradient feeds the diffusion flux, second-order reconstruction, and the limiter. Cell-center values alone are not enough. You have to gather neighbor information to estimate ∇ϕC\nabla\phi_C. Two estimates sit on the table: Green–Gauss and least-squares. The run below shows where distortion swings one of them, and what the distance weight pulls back.

Drop anisotropy to about 0.2 and the stencil flattens. Raise the weight exponent from 0 to 1 and the orange arrow swings back toward the green truth.

angle error 16.8° · |∇φ| 0.64 vs 1.08 true — the reconstructed gradient is skewed

Raise curvature κ at the same time and the gap between the arrows opens. On a flat stencil, watch whether the weight restores the direction.

Green–Gauss: wrap the cell in faces and average#

Apply the divergence theorem directly to the cell. Turn the volume integral into a face integral.

∇ϕC=1VC∑fϕf nf Af\nabla \phi_C = \frac{1}{V_C} \sum_{f} \phi_f \, \mathbf{n}_f \, A_f

VCV_C is the cell volume, nf\mathbf{n}_f is the outward face normal, AfA_f is the face area, and ϕf\phi_f is the value on the face.

You cannot know the face value ϕf\phi_f directly. You interpolate it from cell-center values. The most common linear interpolation looks like this.

ϕf=gf ϕC+(1−gf) ϕN\phi_f = g_f\, \phi_C + (1 - g_f)\, \phi_N

gfg_f is a weight set by the distance ratio from the face to the neighbor center NN. It is cheap and intuitive. On structured meshes it gives second-order accuracy.

Skewed meshes shake Green–Gauss#

The trouble hides in gfg_f. Linear interpolation assumes the face center and the two cell centers all lie on one straight line. On unstructured meshes that assumption almost always breaks.

When the face center strays off the line joining the two cell centers (skewness\text{skewness}), the interpolated ϕf\phi_f becomes biased. If cells are flat or neighbor sizes differ a lot, the bias grows. This error accumulates over iterations. In the end the gradient points the wrong way.

Adding a correction term eases it. But the correction needs a gradient again. That creates a loop. Iterative correction is expensive, and if the distortion is severe, convergence slows down.

Least-squares: fit a plane to the neighbors#

Change the idea. Skip the face entirely. Fit a single plane straight to the value distribution around cell CC.

If neighbor kk sits at offset dk=xk−xC\mathbf{d}_k = \mathbf{x}_k - \mathbf{x}_C, the linear approximation is ϕk≈ϕC+∇ϕC⋅dk\phi_k \approx \phi_C + \nabla\phi_C \cdot \mathbf{d}_k. Minimize the squared sum of this error over all neighbors.

min⁡∇ϕC∑kwk(ϕk−ϕC−∇ϕC⋅dk)2\min_{\nabla\phi_C} \sum_k w_k \left( \phi_k - \phi_C - \nabla\phi_C \cdot \mathbf{d}_k \right)^2

wkw_k is a per-neighbor weight. Differentiate and set to zero, and you get the normal equations.

(∑kwk dkdkT)∇ϕC=∑kwk(ϕk−ϕC)dk\left( \sum_k w_k\, \mathbf{d}_k \mathbf{d}_k^{\mathsf T} \right) \nabla\phi_C = \sum_k w_k \left( \phi_k - \phi_C \right) \mathbf{d}_k

In two dimensions this is a 2×22\times2 system. Written out, it looks like this.

(∑w dx2∑w dxdy∑w dxdy∑w dy2)(∂xϕ∂yϕ)=(∑w Δϕ dx∑w Δϕ dy)\begin{pmatrix} \sum w\, d_x^2 & \sum w\, d_x d_y \\[2pt] \sum w\, d_x d_y & \sum w\, d_y^2 \end{pmatrix} \begin{pmatrix} \partial_x\phi \\[2pt] \partial_y\phi \end{pmatrix} = \begin{pmatrix} \sum w\, \Delta\phi\, d_x \\[2pt] \sum w\, \Delta\phi\, d_y \end{pmatrix}

Δϕ=ϕk−ϕC\Delta\phi = \phi_k - \phi_C. The left-hand matrix depends only on neighbor placement. You can compute it once per cell and store it. The upside is large. There is no face-interpolation assumption. It is insensitive to distortion. With three or more neighbors it always has a solution.

The face interpolation breaks in the same place as Green–Gauss versus least squares on a skewed cell. Three neighbors are enough to solve the normal equations, but if those three sit on a line the transverse answer is empty.

Weight by distance#

The weight wkw_k decides the accuracy. A common choice is the inverse distance.

wk=1∣dk∣ nw_k = \frac{1}{\lvert \mathbf{d}_k \rvert^{\,n}}

With n=0n=0, every neighbor counts equally. A far neighbor has a large value difference, and with it a large curvature error. That neighbor pulls the plane fit. Raise nn to 1 and the far neighbor loses its voice. Weight shifts to nearby neighbors, where the local linear assumption holds well. For inviscid problems, n=1n=1 is the usual choice. It cuts the far neighbor's vote and trims the curvature error.

Python — exact on linear fields, weighted on curved ones#

We confirm the power of least-squares in two parts. First, reconstruct the gradient of the linear field ϕ=1.3x−0.7y+2\phi = 1.3x - 0.7y + 2 on a randomly scattered stencil. In theory the error should be zero. Next, on the curved surface ϕ=e0.7xcos⁡(0.9y)\phi = e^{0.7x}\cos(0.9y), we pit the unweighted case against n=1n=1 weighting.

import numpy as np
 
def weighted_lsq_gradient(offsets, dphi, n):
    # offsets: (K,2) neighbor offsets d_k; dphi: (K,) diffs phi_k - phi_C
    w = 1.0 / np.maximum(np.linalg.norm(offsets, axis=1), 1e-12) ** n
    M = (offsets[:, :, None] * offsets[:, None, :] * w[:, None, None]).sum(axis=0)
    b = (offsets * (dphi * w)[:, None]).sum(axis=0)
    return np.linalg.solve(M, b)
 
xc = np.array([0.4, -0.2])
 
# (A) linear field — least-squares is exact for any stencil
lin = lambda x, y: 1.30 * x - 0.70 * y + 2.0
scatter = np.array([[0.45, 0.30], [-0.40, 0.12], [0.05, -0.48], [-0.33, -0.29],
                    [0.90, 0.15], [-0.11, 0.44], [0.28, -0.09]])   # irregular stencil
dlin = np.array([lin(*(xc + d)) - lin(*xc) for d in scatter])
gA = weighted_lsq_gradient(scatter, dlin, 1.0)
print(f"linear field   LSQ = ({gA[0]:+.6f}, {gA[1]:+.6f})   exact = (+1.300000, -0.700000)")
 
# (B) curved surface — distance weighting trims the reconstruction error
phi  = lambda x, y: np.exp(0.7*x) * np.cos(0.9*y)
grad = lambda x, y: np.array([0.7*np.exp(0.7*x)*np.cos(0.9*y),
                             -0.9*np.exp(0.7*x)*np.sin(0.9*y)])
near = np.array([[0.10, 0.05], [-0.08, 0.09], [0.06, -0.10], [-0.09, -0.06], [0.11, 0.10]])
offs = np.vstack([near, [0.90, 0.70]])          # one far-away neighbor
dphi = np.array([phi(*(xc + d)) - phi(*xc) for d in offs])
gt   = grad(*xc)
for n in (0.0, 1.0):
    g = weighted_lsq_gradient(offs, dphi, n)
    err = np.linalg.norm(g - gt)
    print(f"curved field   n={n:.0f}  LSQ = ({g[0]:+.3f}, {g[1]:+.3f})   |error| = {err:.4f}")
print(f"curved field   true = ({gt[0]:+.3f}, {gt[1]:+.3f})")

The output comes out like this.

linear field   LSQ = (+1.300000, -0.700000)   exact = (+1.300000, -0.700000)
curved field   n=0  LSQ = (+0.885, +0.200)   |error| = 0.0293
curved field   n=1  LSQ = (+0.891, +0.205)   |error| = 0.0222
curved field   true = (+0.911, +0.213)

On a linear field the stencil can be as twisted as you like and the gradient is still good to six decimals. The printout (+1.300000, -0.700000) is that value. A linear function matches the dot product of offset and gradient at every neighbor, so the irregular point (0.90, 0.15) adds nothing to the error. Distortion does not shove the answer on this field.

A curved field does. The true gradient is (+0.911, +0.213). At n=0 the reconstruction (+0.885, +0.200) is short by 0.026 in x and 0.013 in y. At n=1, (+0.891, +0.205) is short by 0.020 and 0.008. The norm drops from 0.0293 to 0.0222. The gap, 0.0071, is about 24 percent, the one-quarter cut. The far neighbor (0.90, 0.70) has components much larger than the 0.1 scale of the five near points. At n=0 that point votes with the same weight as the rest. At n=1 distance cuts its vote.

When the neighbors line up, do not trust the inverse#

Least-squares being robust to distortion does not make it a cure-all. The left-hand matrix M=∑wkdkdkTM = \sum w_k \mathbf{d}_k \mathbf{d}_k^{\mathsf T} can become ill-conditioned. When the neighbors line up along a single straight line, MM approaches singular. The gradient is well determined along the stencil direction, but almost free in the transverse direction. The condition number explodes.

Push Collinearity toward 0.9 and the neighbors bunch into one line. cond(M) jumps from the tens to infinity.

cond(M) = 1.4 (λ_max 3.14 / λ_min 2.271) — well-conditioned

The long thin axis of the ellipse is the axis the gradient cannot pin down. Boundary-layer cells pressed to the wall look like that. The diagonal entries are sums of squared offsets, so a stencil stretched one way leaves the other diagonal empty. On a boundary cell the neighbors sit on the fluid side. Adding the neighbor across the wall, or a face center, fills a little of that empty axis and the condition number drops. If the mesh is fixed, MM does not care that the velocity field changed. Store the inverse, or the QR factorization, instead of rebuilding it every step. The exponent nn is a problem choice. When the viscous term is large, n=2n=2 is sometimes the better one.

Moving least squares rebuilds that weight neighbor by neighbor. Green–Gauss is cheap and weak to skew. Weighted least squares handles skew, until the stencil collapses onto a line and the matrix goes bad first. Before picking a method, check whether that cell's neighbors actually span the plane.

Share if you found it helpful.