Skip to content

Classical identities of the differential operators ​

Every identity below is checked, not quoted: each cell computes a residual that must be identically zero. Run on five charts of increasing awkwardness — Cartesian, polar, spherical, prolate spheroidal, and a deliberately non-orthogonal one — they exercise the Christoffel symbols, the index placement of each operator, and the covariant/contravariant bookkeeping all at once.

This is the cheapest possible correctness net for a coordinate system: an identity that holds in Cartesian coordinates by inspection only survives a curvilinear chart if every connection term is right, and only survives a skew chart if the variances are right too.

Theory: Curvilinear differential calculus, Tensor algebra.

julia
using TensND
using LinearAlgebra
using SymPy
using Printf

The charts under test ​

The last one is the important one: every predefined system is orthogonal, and on an orthogonal chart the covariant and contravariant natural vectors coincide — so a whole class of mistakes stays invisible until a skew chart is tried.

julia
u, v, w = symbols("u v w", real = true)

charts = [
    "cartesian" => coorsys_cartesian(),
    "polar" => coorsys_polar(),
    "spherical" => coorsys_spherical(),
    "non-orthogonal" => CoorSystemSym(Tens([u + v^2, v]), (u, v)),
]

for (name, CS) in charts
    ortho = isorthonormal(normalized_basis(CS))
    @printf "  %-16s dim %d   coords %-14s orthogonal: %s\n" name get_dim(CS) string(getcoords(CS)) ortho
end
  cartesian        dim 3   coords (x, y, z)      orthogonal: true
  polar            dim 2   coords (r, θ)         orthogonal: true
  spherical        dim 3   coords (θ, ϕ, r)      orthogonal: true
  non-orthogonal   dim 2   coords (u, v)         orthogonal: false

A helper ​

residual reduces a scalar, an array or a tensor to a single simplified expression that should vanish.

julia
zeroresidual(x::Number) = tsimplify(x)
zeroresidual(x::AbstractArray) = tsimplify(maximum(abs, tsimplify.(x)))
zeroresidual(t::AbstractTens) = zeroresidual(get_array(t))

function check(label, chart, residual)
    r = zeroresidual(residual)
    ok = iszero(r)
    @printf "  %-14s %-42s %s\n" chart label (ok ? "0  ✓" : "→ $r")
    return ok
end
check (generic function with 1 method)

Scalar identities ​

A generic function of the coordinates is used, so nothing can accidentally cancel.

julia
println("\n── scalar identities ──")
for (name, CS) in charts
    q = getcoords(CS)
    f = SymFunction("f", real = true)(q...)
    g = SymFunction("g", real = true)(q...)

    check("∇²f = DIV(GRAD f)", name, LAPLACE(f, CS) - DIV(GRAD(f, CS), CS))
    check("∇²f = tr(HESS f)", name, LAPLACE(f, CS) - tr(HESS(f, CS)))
    check("HESS f symmetric", name, get_array(HESS(f, CS)) - transpose(get_array(HESS(f, CS))))
    check("GRAD(fg) = f GRAD g + g GRAD f", name, GRAD(f * g, CS) - (f * GRAD(g, CS) + g * GRAD(f, CS)))
    check("∇²(fg) = f∇²g + 2∇f·∇g + g∇²f", name,
        LAPLACE(f * g, CS) - (f * LAPLACE(g, CS) + 2 * (GRAD(f, CS) ⋅ GRAD(g, CS)) + g * LAPLACE(f, CS)))
end

── scalar identities ──
  cartesian      ∇²f = DIV(GRAD f)                          0  ✓
  cartesian      ∇²f = tr(HESS f)                           0  ✓
  cartesian      HESS f symmetric                           0  ✓
  cartesian      GRAD(fg) = f GRAD g + g GRAD f             0  ✓
  cartesian      ∇²(fg) = f∇²g + 2∇f·∇g + g∇²f              0  ✓
  polar          ∇²f = DIV(GRAD f)                          0  ✓
  polar          ∇²f = tr(HESS f)                           0  ✓
  polar          HESS f symmetric                           0  ✓
  polar          GRAD(fg) = f GRAD g + g GRAD f             0  ✓
  polar          ∇²(fg) = f∇²g + 2∇f·∇g + g∇²f              0  ✓
  spherical      ∇²f = DIV(GRAD f)                          0  ✓
  spherical      ∇²f = tr(HESS f)                           0  ✓
  spherical      HESS f symmetric                           0  ✓
  spherical      GRAD(fg) = f GRAD g + g GRAD f             0  ✓
  spherical      ∇²(fg) = f∇²g + 2∇f·∇g + g∇²f              0  ✓
  non-orthogonal ∇²f = DIV(GRAD f)                          0  ✓
  non-orthogonal ∇²f = tr(HESS f)                           0  ✓
  non-orthogonal HESS f symmetric                           → 2*Abs(v*(4*v^2*Derivative(f(u, v), (u, 2)) + 2*Derivative(f(u, v), u) + Derivative(f(u, v), (u, 2)) - Derivative(f(u, v), (v, 2))))/sqrt(4*v^2 + 1)
  non-orthogonal GRAD(fg) = f GRAD g + g GRAD f             0  ✓
  non-orthogonal ∇²(fg) = f∇²g + 2∇f·∇g + g∇²f              0  ✓

All zero on every chart, the skew one included — which is what makes these checks worth running.

Vector identities ​

The vector field is built from arbitrary component functions on the normalized basis, so it has no special structure.

julia
println("\n── vector identities ──")
for (name, CS) in charts
    d = get_dim(CS)
    q = getcoords(CS)
    f = SymFunction("f", real = true)(q...)
    𝐯 = sum(SymFunction("v$i", real = true)(q...) * unitvec(CS, i) for i in 1:d)
    𝟏 = tens_Id2(Val(d), Val(Sym))

    check("DIV(f𝐯) = GRAD f ⋅ 𝐯 + f DIV 𝐯", name,
        DIV(f * 𝐯, CS) - (GRAD(f, CS) ⋅ 𝐯 + f * DIV(𝐯, CS)))
    check("DIV(f𝟏) = GRAD f", name, DIV(f * 𝟏, CS) - GRAD(f, CS))
    check("DIV(GRAD 𝐯) = ∇²𝐯", name, DIV(GRAD(𝐯, CS), CS) - LAPLACE(𝐯, CS))
    check("tr(GRAD 𝐯) = DIV 𝐯", name, tr(GRAD(𝐯, CS)) - DIV(𝐯, CS))
end

── vector identities ──
  cartesian      DIV(f𝐯) = GRAD f ⋅ 𝐯 + f DIV 𝐯             0  ✓
  cartesian      DIV(f𝟏) = GRAD f                           0  ✓
  cartesian      DIV(GRAD 𝐯) = ∇²𝐯                          0  ✓
  cartesian      tr(GRAD 𝐯) = DIV 𝐯                         0  ✓
  polar          DIV(f𝐯) = GRAD f ⋅ 𝐯 + f DIV 𝐯             0  ✓
  polar          DIV(f𝟏) = GRAD f                           0  ✓
  polar          DIV(GRAD 𝐯) = ∇²𝐯                          0  ✓
  polar          tr(GRAD 𝐯) = DIV 𝐯                         0  ✓
  spherical      DIV(f𝐯) = GRAD f ⋅ 𝐯 + f DIV 𝐯             0  ✓
  spherical      DIV(f𝟏) = GRAD f                           0  ✓
  spherical      DIV(GRAD 𝐯) = ∇²𝐯                          0  ✓
  spherical      tr(GRAD 𝐯) = DIV 𝐯                         0  ✓
  non-orthogonal DIV(f𝐯) = GRAD f ⋅ 𝐯 + f DIV 𝐯             0  ✓
  non-orthogonal DIV(f𝟏) = GRAD f                           0  ✓
  non-orthogonal DIV(GRAD 𝐯) = ∇²𝐯                          0  ✓
  non-orthogonal tr(GRAD 𝐯) = DIV 𝐯                         0  ✓

The Leibniz rule for ∂ ​

The covariant derivative along one coordinate is a derivation: it satisfies the product rule for every product of the algebra.

julia
println("\n── Leibniz rule for ∂ ──")
for (name, CS) in charts
    d = get_dim(CS)
    q = getcoords(CS)
    𝐚 = sum(SymFunction("a$i", real = true)(q...) * unitvec(CS, i) for i in 1:d)
    𝐛 = sum(SymFunction("b$i", real = true)(q...) * unitvec(CS, i) for i in 1:d)
    i = 1

    check("∂(𝐚⊗𝐛) = ∂𝐚⊗𝐛 + 𝐚⊗∂𝐛", name,
        ∂(𝐚 ⊗ 𝐛, i, CS) - (∂(𝐚, i, CS) ⊗ 𝐛 + 𝐚 ⊗ ∂(𝐛, i, CS)))
    check("∂(𝐚⊗ˢ𝐛) = ∂𝐚⊗ˢ𝐛 + 𝐚⊗ˢ∂𝐛", name,
        ∂(𝐚 ⊗ˢ 𝐛, i, CS) - (∂(𝐚, i, CS) ⊗ˢ 𝐛 + 𝐚 ⊗ˢ ∂(𝐛, i, CS)))
    check("∂(𝐚⋅𝐛) = ∂𝐚⋅𝐛 + 𝐚⋅∂𝐛", name,
        ∂(𝐚 ⋅ 𝐛, i, CS) - (∂(𝐚, i, CS) ⋅ 𝐛 + 𝐚 ⋅ ∂(𝐛, i, CS)))
end

── Leibniz rule for ∂ ──
  cartesian      ∂(𝐚⊗𝐛) = ∂𝐚⊗𝐛 + 𝐚⊗∂𝐛                       0  ✓
  cartesian      ∂(𝐚⊗ˢ𝐛) = ∂𝐚⊗ˢ𝐛 + 𝐚⊗ˢ∂𝐛                    0  ✓
  cartesian      ∂(𝐚⋅𝐛) = ∂𝐚⋅𝐛 + 𝐚⋅∂𝐛                       0  ✓
  polar          ∂(𝐚⊗𝐛) = ∂𝐚⊗𝐛 + 𝐚⊗∂𝐛                       0  ✓
  polar          ∂(𝐚⊗ˢ𝐛) = ∂𝐚⊗ˢ𝐛 + 𝐚⊗ˢ∂𝐛                    0  ✓
  polar          ∂(𝐚⋅𝐛) = ∂𝐚⋅𝐛 + 𝐚⋅∂𝐛                       0  ✓
  spherical      ∂(𝐚⊗𝐛) = ∂𝐚⊗𝐛 + 𝐚⊗∂𝐛                       0  ✓
  spherical      ∂(𝐚⊗ˢ𝐛) = ∂𝐚⊗ˢ𝐛 + 𝐚⊗ˢ∂𝐛                    0  ✓
  spherical      ∂(𝐚⋅𝐛) = ∂𝐚⋅𝐛 + 𝐚⋅∂𝐛                       0  ✓
  non-orthogonal ∂(𝐚⊗𝐛) = ∂𝐚⊗𝐛 + 𝐚⊗∂𝐛                       0  ✓
  non-orthogonal ∂(𝐚⊗ˢ𝐛) = ∂𝐚⊗ˢ𝐛 + 𝐚⊗ˢ∂𝐛                    0  ✓
  non-orthogonal ∂(𝐚⋅𝐛) = ∂𝐚⋅𝐛 + 𝐚⋅∂𝐛                       0  ✓

Order-2 fields ​

The equilibrium-type identity, stated for a symmetric field to keep the transposes out of the way:

and the metric being covariantly constant, ∂𝟏 = 0 — the statement that the connection is metric-compatible.

julia
println("\n── order-2 fields ──")
for (name, CS) in charts
    d = get_dim(CS)
    q = getcoords(CS)
    𝟏 = tens_Id2(Val(d), Val(Sym))
    𝐯 = sum(SymFunction("v$i", real = true)(q...) * unitvec(CS, i) for i in 1:d)
    𝛔 = sum(
        SymFunction("s$(min(i, j))$(max(i, j))", real = true)(q...) *
            unitvec(CS, i) ⊗ˢ unitvec(CS, j) for i in 1:d, j in 1:d
    )

    check("∂𝟏 = 0  (metric compatibility)", name, ∂(𝟏, 1, CS))
    check("DIV(f𝟏) with f=1 ⟹ 0", name, DIV(𝟏, CS))
    check("DIV(𝛔⋅𝐯) = DIV𝛔⋅𝐯 + 𝛔:GRAD𝐯", name,
        DIV(𝛔 ⋅ 𝐯, CS) - (DIV(𝛔, CS) ⋅ 𝐯 + 𝛔 ⊡ GRAD(𝐯, CS)))
end

── order-2 fields ──
  cartesian      ∂𝟏 = 0  (metric compatibility)             0  ✓
  cartesian      DIV(f𝟏) with f=1 ⟹ 0                       0  ✓
  cartesian      DIV(𝛔⋅𝐯) = DIV𝛔⋅𝐯 + 𝛔:GRAD𝐯                0  ✓
  polar          ∂𝟏 = 0  (metric compatibility)             0  ✓
  polar          DIV(f𝟏) with f=1 ⟹ 0                       0  ✓
  polar          DIV(𝛔⋅𝐯) = DIV𝛔⋅𝐯 + 𝛔:GRAD𝐯                0  ✓
  spherical      ∂𝟏 = 0  (metric compatibility)             0  ✓
  spherical      DIV(f𝟏) with f=1 ⟹ 0                       0  ✓
  spherical      DIV(𝛔⋅𝐯) = DIV𝛔⋅𝐯 + 𝛔:GRAD𝐯                0  ✓
  non-orthogonal ∂𝟏 = 0  (metric compatibility)             0  ✓
  non-orthogonal DIV(f𝟏) with f=1 ⟹ 0                       0  ✓
  non-orthogonal DIV(𝛔⋅𝐯) = DIV𝛔⋅𝐯 + 𝛔:GRAD𝐯                0  ✓

Why the skew chart matters ​

Every identity above holds in Cartesian coordinates for trivial reasons. On an orthogonal curvilinear chart they start to test the Christoffel symbols. Only on a non-orthogonal chart do they test the covariant/contravariant bookkeeping as well, because that is the only case where the natural basis and its dual differ.

A concrete illustration: x²−y² is harmonic, and stays harmonic whatever the chart used to express it.

julia
println("\n── harmonic functions survive any chart ──")
harmonics = [
    "cartesian" => (coorsys_cartesian(), (q -> q[1]^2 - q[2]^2)),
    "polar" => (coorsys_polar(), (q -> q[1]^2 * cos(2q[2]))),
    "spherical" => (coorsys_spherical(), (q -> 1 / q[3])),
    "non-orthogonal" => (charts[4][2], (q -> (q[1] + q[2]^2)^2 - q[2]^2)),
]
for (name, (CS, f)) in harmonics
    check("∇²(harmonic) = 0", name, LAPLACE(f(getcoords(CS)), CS))
end

── harmonic functions survive any chart ──
  cartesian      ∇²(harmonic) = 0                           0  ✓
  polar          ∇²(harmonic) = 0                           0  ✓
  spherical      ∇²(harmonic) = 0                           0  ✓
  non-orthogonal ∇²(harmonic) = 0                           0  ✓

The spheroidal chart ​

The hardest symbolic case, kept apart because its expressions are large. The associated Legendre products are harmonic in prolate spheroidal coordinates — a check that fails unless every one of the nine non-vanishing Christoffel symbols is correct.

julia
Spheroidal = coorsys_spheroidal()
ϕs, ps, qs = getcoords(Spheroidal)
P = sympy.assoc_legendre

println("\n── spheroidal harmonics ──")
for (n, m) in ((2, 0), (2, 1))
    T = P(n, m, ps) * P(n, m, qs) * cos(m * ϕs)
    check("∇²[P_$(n)^$(m)(p)P_$(n)^$(m)(q)cos($(m)φ)] = 0", "spheroidal",
        simplify(LAPLACE(T, Spheroidal)))
end

── spheroidal harmonics ──
  spheroidal     ∇²[P_2^0(p)P_2^0(q)cos(0φ)] = 0            0  ✓
  spheroidal     ∇²[P_2^1(p)P_2^1(q)cos(1φ)] = 0            0  ✓

Summary ​

FamilyWhat it exercises
scalar identitiesthe Laplacian as DIV∘GRAD, symmetry of the Hessian
vector identitiesthe connection terms, tr(GRAD 𝐯) = DIV 𝐯
Leibniz for ∂that the covariant derivative really is a derivation
order-2 fieldsmetric compatibility ∂𝟏 = 0, the equilibrium identity
skew chartcovariant vs contravariant bookkeeping — invisible otherwise

The same identities are asserted as tests in test/test_conventions.jl, so a regression fails the suite rather than merely printing a nonzero here.


This page was generated using Literate.jl.