Translated from the author's Korean original. Read the original (한국어)
The Star That Started Moving — Well-Balanced FVM and Angular Momentum in Polar Coordinates
A quiet polar star stays quiet only when the pressure source is the face-area jump, not the textbook 2P/r times V.
Same equations, same slope limiter, same Riemann solver. Only the grid changed from Cartesian to polar. Gas began bleeding out of the center of a quiescent star. Pressure was uniform. Every velocity was zero.
Drag up and the textbook source pulls away from the face-area jump .
Naive 2P/r · V is only a Taylor approximation of P·(Sout − Sin); the residual grows with Δr, so coarser radial grids produce stronger spurious accelerations.
The gap shrinks as and still is not zero. A coarser mesh opens it first.
The grid is the culprit. Curvilinear coordinates add geometric terms to the momentum equations, and one bad line in that discretization kills a static equilibrium. The swap from φ-momentum to angular momentum sits next to that line.
What polar coordinates add to the momentum equation#
In polar coordinates the radial momentum equation reads
where are the velocity components and is pressure. Two differences from the Cartesian form: ① the divergence is wrapped by , and ② the right side carries a pseudo-force .
These pseudo-forces are not real accelerations. The local basis (the vector frame attached to each point) rotates with position, so a momentum vector that points the same way globally has different components in the local frame. But numerically they enter the cell update as if they were forces, and the way we discretize them matters.
Where 2P/r really comes from — face-area mismatch#
If we move inside the conservative divergence, the discrete radial momentum equation becomes
The flux is exactly what your Riemann solver returns. The source is today's story.
We get it by rewriting as . The first piece slides into the flux divergence; the second becomes the source. Discretized cell-by-cell, the source reads
That looks innocent. Now drop in a static solution — pressure constant, velocities zero — and check the books. The flux side gives , which is not zero because the inner and outer face areas of a polar cell differ. The source side, the Taylor-derived , only matches that difference approximately. The mismatch leaks into the cell as a spurious acceleration every step.
Back to the printed numbers. , radius from 1 to 4, 200 steps. Naive is . Balanced prints . The initial state is , , , . . The code steps with and . Two hundred of those steps pile a spurious velocity up to . The balanced form does not move inside the same loop.
Refining to 200 cells cuts the velocity residual as , not to zero. The figure quotes an gap. One order apart. The cell source error is the Taylor remainder of the area mismatch, so it is smaller relative to the volume. Divide by and it is an acceleration. Repeat and it becomes a velocity. That is why the velocity residual looks one order softer. Swap in and the order talk ends, because the flux difference and the source are the same expression.
Volume is and area is . The factor is omitted on both sides, as the code comment says. Equal factors keep the cancellation. Change only the area definition and no longer matches the flux difference. The same hole shows up when a curved-cell area comes from a diagonal cross product: leaves 1. Discrete divergence and face area have to be a pair, or a static solution will not hold.
One-line fix that restores equilibrium#
Replace the source term with the same expression as the flux difference:
Now the flux contribution and the source cancel to machine precision for a static solution. The star stops leaking.
The same trick applies to the source in the -momentum equation:
The -momentum equation has no such geometric pressure source — the two azimuthal faces share the same area.
This is the simplest example of a well-balanced scheme: the discrete divergence's fake acceleration is subtracted by the same discrete expression on the other side.
Sixty lines of NumPy — does the equilibrium hold?#
On a 1D radial grid we start with , , and watch the momentum residual under each formulation.
import numpy as np
def polar_cell_volume(rL: float, rR: float) -> float:
"""spherical-shell volume between rL and rR (sin θ Δθ Δφ omitted)."""
return (rR**3 - rL**3) / 3.0
def polar_r_surface(r: float) -> float:
"""r²-weighted face area in spherical coords."""
return r * r
def naive_pressure_source(P: float, r_mid: float, V: float) -> float:
"""The textbook 2P/r · V form — wrong at machine precision for P=const."""
return 2.0 * P / r_mid * V
def balanced_pressure_source(P: float, S_in: float, S_out: float) -> float:
"""Same expression as the flux difference, so they cancel exactly."""
return P * (S_out - S_in)
def radial_static_run(N: int = 40, scheme: str = "balanced", steps: int = 200):
rIn, rOut = 1.0, 4.0
dr = (rOut - rIn) / N
rho = np.ones(N)
u = np.zeros(N)
P = np.ones(N)
gamma = 1.4
c0 = np.sqrt(gamma * P[0] / rho[0])
dt = 0.4 * dr / c0
r_face = np.linspace(rIn, rOut, N + 1)
S = polar_r_surface(r_face)
V = np.array([polar_cell_volume(r_face[i], r_face[i + 1]) for i in range(N)])
r_mid = 0.5 * (r_face[:-1] + r_face[1:])
for _ in range(steps):
# pressure flux through faces (P constant → exact value)
flux_diff = P * (S[1:] - S[:-1])
# the source-term line we want to scrutinise
if scheme == "naive":
src = naive_pressure_source(P, r_mid, V)
else:
src = balanced_pressure_source(P, S[:-1], S[1:])
# momentum update: d(ρ u V)/dt = -flux_diff + src
du = dt / (rho * V) * (src - flux_diff)
u += du
return r_mid, u
r1, u_naive = radial_static_run(N=40, scheme="naive", steps=200)
r2, u_balanced = radial_static_run(N=40, scheme="balanced", steps=200)
print(f"naive : max |u| = {np.abs(u_naive).max():.3e}")
print(f"balanced : max |u| = {np.abs(u_balanced).max():.3e}")After 200 steps on a 40-cell grid:
naive : max |u| = 1.834e-03
balanced : max |u| = 0.000e+00Initial state: P = 1, ρ = 1, u = 0 across r ∈ [1, 4]. The pressure flux through each spherical face is P · Sr. The discrete source on the right-hand side should cancel it exactly. Toggle the scheme to see who fails.
Leave the toggle on naive and the curve walks off zero as steps accumulate. Switch to well-balanced and it stays on the axis. Drop cells and only the naive residual grows. On 40 cells and 200 steps that walk is ; balanced prints .
Replace φ-momentum with angular momentum#
Pressure is not the only source. The -momentum equation carries . In strongly rotating problems — stars, accretion disks, atmospheres — these terms erode accuracy and conservation.
The cure is a change of variable. Define — plain angular momentum — and the source terms in the -momentum equation vanish entirely:
Discretely we take the Riemann fluxes , , , multiply by to get , and difference them. No corrections, no source-term juggling. Even on a coarse mesh angular momentum is conserved by construction (Kley 1998, A&A 338, L37).
Differentially rotating systems (Keplerian disks) need an additional rotating-frame trick — FARGO — to relax the CFL bound. That is a separate post.
A decision guide#
| Concern | naive 2P/r · V | well-balanced P · ΔS_r | angular-momentum form |
|---|---|---|---|
| static equilibrium | ❌ residual | ✅ bit-exact | ✅ (in φ direction only) |
| extra code | none | swap one line in the source | new flux + extra variable |
| rotating systems | ⚠ drift builds up | △ pressure source only | ✅ conserved by design |
| debugging cost | high (silent failure) | low | medium |
| where you meet it | "why is the star's core draining?" | spherical hydro standard | accretion disks |
Standing in front of the star again#
A Cartesian grid hides this trap. Curvilinear coordinates add terms to the momentum equation, and unless their discrete form exactly matches the discrete flux difference, a quiet gas will not stay quiet. The one-line edit of this post is
One line, one commit. The star is quiet again.
Keep 2P/r, or replace it with ΔS?#
If only the pressure balance wobbles, replace with . That is the edit that turns on 40 cells into 0. The source takes the same swap, . The direction does not need it: the two azimuthal faces share one area.
Strong rotation is a different decision. still sits on the -momentum equation. Defining removes that source from the equation. That is the form in Kley 1998 (A&A 338, L37). A radially varying rotation still wants FARGO on top. A static interface that starts to flow on its own is the same disease as parasitic currents. Unless the fake force from the discrete divergence is subtracted with the same discrete expression, the equilibrium does not come back.
Related
Share if you found it helpful.