Polar vortex crystal
Self-organisation of cyclones into a stable ring around a central cyclone in rotating shallow water on a polar disk — a regime studied by Siegelman, Young & Ingersoll (2022) as a model for Jupiter's polar vortex clusters observed by Juno's JIRAM instrument.
The grid is a LambertConformalConicGrid centred exactly on the North Pole with standard_parallel = 90 — the polar stereographic limit, where the cone is tangent to the sphere at the pole and the projection has no antemeridian wedge.
using Oceananigans
using Oceananigans.OrthogonalSphericalShellGrids
using Oceananigans.Units
using PrintfGrid
A 128² horizontal × 1 vertical-cell grid covering a 3200 km box on the pole, with a circular wall (an ImmersedBoundaryGrid with GridFittedBoundary) at 1500 km from the pole forming the polar disk.
Nx = Ny = 128
Δ = 25kilometers
H = 1000meters
grid = LambertConformalConicGrid(Float64;
size = (Nx, Ny, 1),
center = (0, 90),
spacing = Δ,
standard_parallel = 90,
latitude_of_origin = 90,
central_longitude = 0,
z = (-H, 0),
halo = (7, 7, 7))
R_earth = grid.radius
R_bowl = 1500kilometers
bowl_mask(λ, φ, z) = R_earth * (π/2 - deg2rad(φ)) > R_bowl
ibg = ImmersedBoundaryGrid(grid, GridFittedBoundary(bowl_mask))128×128×1 ImmersedBoundaryGrid{Float64, Bounded, Bounded, Bounded} on CPU with 7×7×7 halo:
├── immersed_boundary: GridFittedBoundary{Field{Center, Center, Center, Nothing, Oceananigans.Grids.ZRegOrthogonalSphericalShellGrid{Float64, Bounded, Bounded, Bounded, Oceananigans.Grids.StaticVerticalDiscretization{OffsetArrays.OffsetVector{Float64, StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}, Int64}}, OffsetArrays.OffsetVector{Float64, StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}, Int64}}, Float64, Float64}, LambertConformalConic{Float64}, OffsetArrays.OffsetMatrix{Float64, Matrix{Float64}}, OffsetArrays.OffsetMatrix{Float64, Matrix{Float64}}, OffsetArrays.OffsetMatrix{Float64, Matrix{Float64}}, OffsetArrays.OffsetMatrix{Float64, Matrix{Float64}}, CPU, Float64}, Tuple{Colon, Colon, Colon}, OffsetArrays.OffsetArray{Bool, 3, Array{Bool, 3}}, Bool, FieldBoundaryConditions{Oceananigans.BoundaryConditions.NoFluxBoundaryCondition, Oceananigans.BoundaryConditions.NoFluxBoundaryCondition, Oceananigans.BoundaryConditions.NoFluxBoundaryCondition, Oceananigans.BoundaryConditions.NoFluxBoundaryCondition, Oceananigans.BoundaryConditions.NoFluxBoundaryCondition, Oceananigans.BoundaryConditions.NoFluxBoundaryCondition, Nothing, @NamedTuple{bottom_and_top::KernelAbstractions.Kernel{KernelAbstractions.CPU, KernelAbstractions.NDIteration.StaticSize{(16, 16)}, Oceananigans.Utils.OffsetStaticSize{(1:128, 1:128)}, typeof(Oceananigans.BoundaryConditions.cpu__fill_bottom_and_top_halo!)}, south_and_north::KernelAbstractions.Kernel{KernelAbstractions.CPU, KernelAbstractions.NDIteration.StaticSize{(128, 1)}, Oceananigans.Utils.OffsetStaticSize{(1:128, 1:1)}, typeof(Oceananigans.BoundaryConditions.cpu__fill_south_and_north_halo!)}, west_and_east::KernelAbstractions.Kernel{KernelAbstractions.CPU, KernelAbstractions.NDIteration.StaticSize{(128, 1)}, Oceananigans.Utils.OffsetStaticSize{(1:128, 1:1)}, typeof(Oceananigans.BoundaryConditions.cpu__fill_west_and_east_halo!)}}, @NamedTuple{bottom_and_top::Tuple{Oceananigans.BoundaryConditions.NoFluxBoundaryCondition, Oceananigans.BoundaryConditions.NoFluxBoundaryCondition}, south_and_north::Tuple{Oceananigans.BoundaryConditions.NoFluxBoundaryCondition, Oceananigans.BoundaryConditions.NoFluxBoundaryCondition}, west_and_east::Tuple{Oceananigans.BoundaryConditions.NoFluxBoundaryCondition, Oceananigans.BoundaryConditions.NoFluxBoundaryCondition}}}, Nothing, Nothing}}
├── underlying_grid: 128×128×1 OrthogonalSphericalShellGrid{Float64, Bounded, Bounded, Bounded} on CPU with 7×7×7 halo
├── centered at: North Pole, (λ, φ) = (0.0, 90.0)
├── longitude: Bounded extent 28.6285 degrees variably spaced with min(Δλ)=0.217957, max(Δλ)=0.22483
├── latitude: Bounded extent 28.6285 degrees variably spaced with min(Δφ)=0.217957, max(Δφ)=0.22483
└── z: Bounded z ∈ [-1000.0, 0.0] regularly spaced with Δz=1000.0Model
Single-layer barotropic shallow-water-style dynamics via a HydrostaticFreeSurfaceModel with one vertical level. We use HydrostaticSphericalCoriolis (so f ≈ 2Ω near the pole), a SplitExplicitFreeSurface whose number of substeps is set from the barotropic-wave CFL, and WENOVectorInvariant momentum advection.
Δt = 20minutes
model = HydrostaticFreeSurfaceModel(ibg;
coriolis = HydrostaticSphericalCoriolis(),
free_surface = SplitExplicitFreeSurface(ibg; cfl = 0.7, fixed_Δt = Δt),
momentum_advection = WENOVectorInvariant())HydrostaticFreeSurfaceModel{CPU, ImmersedBoundaryGrid}(time = 0 seconds, iteration = 0)
├── grid: 128×128×1 ImmersedBoundaryGrid{Float64, Bounded, Bounded, Bounded} on CPU with 7×7×7 halo
├── timestepper: QuasiAdamsBashforth2TimeStepper
├── tracers: ()
├── closure: Nothing
├── buoyancy: Nothing
├── free surface: SplitExplicitFreeSurface with gravitational acceleration 9.80665 m s⁻²
│ └── substepping: FixedSubstepNumber(14)
├── advection scheme:
│ └── momentum: WENOVectorInvariant{5, Float64}(vorticity_order=9, vertical_order=5)
├── vertical_coordinate: ZCoordinate
└── coriolis: HydrostaticSphericalCoriolis{Oceananigans.Advection.EnstrophyConserving{Float64}, Float64, Oceananigans.Coriolis.HydrostaticFormulation}Initial condition
Six Gaussian cyclones at radius r_ring = 900 km from the pole, evenly spaced in longitude, plus one central cyclone at the pole. The depression amplitude η₀ = -13 m and width σ = 200 km give an initial Rossby number Ro ≈ -2gη₀/(f²σ²) ≈ 0.3 at each vortex centre.
Working in projected (xp, yp) coordinates of the polar stereographic limit lets us write η and the geostrophic velocities (u, v) as plain Gaussians of distance from each vortex centre. set! is told these are in the grid's intrinsic frame.
N_ring = 6
r_ring = 900kilometers
σ_vortex = 200kilometers
η₀ = -13
ring_λ = [360k/N_ring for k in 0:N_ring-1]
ring_φ = fill(90 - rad2deg(r_ring/R_earth), N_ring)
vortex_λ = vcat(ring_λ, [0])
vortex_φ = vcat(ring_φ, [90])
vortex_xpyp = [lcc_forward(grid.conformal_mapping, λv, φv)
for (λv, φv) in zip(vortex_λ, vortex_φ)]
g_const = Oceananigans.defaults.gravitational_acceleration
f_pole = 2 * Oceananigans.defaults.planet_rotation_rate
geo_coef = g_const * η₀ / (f_pole * σ_vortex^2)
gaussian(xp, yp, xv, yv) = exp(-((xp - xv)^2 + (yp - yv)^2) / (2σ_vortex^2))
function η_init(λ, φ, z)
xp, yp = lcc_forward(grid.conformal_mapping, λ, φ)
return sum(η₀ * gaussian(xp, yp, xv, yv) for (xv, yv) in vortex_xpyp)
end
function u_init(λ, φ, z)
xp, yp = lcc_forward(grid.conformal_mapping, λ, φ)
return sum( geo_coef * (yp - yv) * gaussian(xp, yp, xv, yv) for (xv, yv) in vortex_xpyp)
end
function v_init(λ, φ, z)
xp, yp = lcc_forward(grid.conformal_mapping, λ, φ)
return sum(-geo_coef * (xp - xv) * gaussian(xp, yp, xv, yv) for (xv, yv) in vortex_xpyp)
end
set!(model, η = η_init, u = u_init, v = v_init; intrinsic_velocities = true)Simulation
120-day run at Δt = 20 minutes (outer timestep limited by the advective CFL; the split-explicit substeps handle the much faster barotropic gravity waves internally). The progress callback reports the advective CFL alongside the maximum velocity and free-surface displacement. Snapshots are saved every 12 hours.
simulation = Simulation(model; Δt, stop_time = 120days)
advective_cfl = AdvectiveCFL(simulation.Δt)
progress(sim) = @printf("iter %5d, t = %s, max|u| = %.3f m/s, max|η| = %.2f m, CFL = %.3f\n",
iteration(sim), prettytime(time(sim)),
maximum(abs, sim.model.velocities.u),
maximum(abs, sim.model.free_surface.displacement),
advective_cfl(sim.model))
simulation.callbacks[:progress] = Callback(progress, IterationInterval(1000))
u, v, w = model.velocities
η = model.free_surface.displacement
ζ = ∂x(v) - ∂y(u)
s = sqrt(u^2 + v^2)
filename = "polar_vortex_crystal.jld2"
simulation.output_writers[:fields] = JLD2Writer(model, (; η, ζ, s);
filename,
schedule = TimeInterval(12hours),
overwrite_existing = true)
run!(simulation)[ Info: Initializing simulation...
iter 0, t = 0 seconds, max|u| = 2.643 m/s, max|η| = 12.97 m, CFL = 0.189
[ Info: ... simulation initialization complete (1.108 minutes)
[ Info: Executing initial time step...
[ Info: ... initial time step complete (41.316 seconds).
iter 1000, t = 13.889 days, max|u| = 2.738 m/s, max|η| = 13.73 m, CFL = 0.195
iter 2000, t = 27.778 days, max|u| = 2.784 m/s, max|η| = 13.61 m, CFL = 0.194
iter 3000, t = 41.667 days, max|u| = 2.742 m/s, max|η| = 13.67 m, CFL = 0.196
iter 4000, t = 55.556 days, max|u| = 2.817 m/s, max|η| = 13.78 m, CFL = 0.200
iter 5000, t = 69.444 days, max|u| = 3.126 m/s, max|η| = 14.14 m, CFL = 0.199
iter 6000, t = 83.333 days, max|u| = 2.894 m/s, max|η| = 14.14 m, CFL = 0.215
iter 7000, t = 97.222 days, max|u| = 2.834 m/s, max|η| = 14.30 m, CFL = 0.213
iter 8000, t = 111.111 days, max|u| = 2.860 m/s, max|η| = 14.51 m, CFL = 0.223
[ Info: Simulation is stopping after running for 5.061 minutes.
[ Info: Simulation time 120 days equals or exceeds stop time 120 days.Visualization
Animate η, |u|, and ζ over the 5-day evolution.
using CairoMakie
CairoMakie.activate!(type = "png")
ηts = FieldTimeSeries(filename, "η")
ζts = FieldTimeSeries(filename, "ζ")
sts = FieldTimeSeries(filename, "s")
times = ηts.times
Nt = length(times)
η_lim = maximum(maximum(abs, interior(ηts[n])) for n in 1:Nt)
ζ_lim = maximum(maximum(abs, interior(ζts[n])) for n in 1:Nt) * 0.5
s_lim = maximum(maximum(abs, interior(sts[n])) for n in 1:Nt)
n = Observable(1)
title = @lift @sprintf("Polar vortex crystal — t = %.2f d", times[$n] / 86400)
ηₙ = @lift ηts[$n]
ζₙ = @lift ζts[$n]
sₙ = @lift sts[$n]
fig = Figure(size = (1500, 540))
Label(fig[0, 1:6], title; fontsize = 18, tellwidth = false)
ax_η = Axis(fig[1, 1], aspect = 1, title = "η (m)")
hm_η = heatmap!(ax_η, ηₙ; colormap = :balance, colorrange = (-η_lim, η_lim))
Colorbar(fig[1, 2], hm_η)
ax_ζ = Axis(fig[1, 3], aspect = 1, title = "ζ (1/s)")
hm_ζ = heatmap!(ax_ζ, ζₙ; colormap = :balance, colorrange = (-ζ_lim, ζ_lim))
Colorbar(fig[1, 4], hm_ζ)
ax_s = Axis(fig[1, 5], aspect = 1, title = "|u| (m/s)")
hm_s = heatmap!(ax_s, sₙ; colormap = :speed, colorrange = (0, s_lim))
Colorbar(fig[1, 6], hm_s)
for ax in (ax_η, ax_ζ, ax_s)
hidedecorations!(ax)
end
record(fig, "polar_vortex_crystal.mp4", 1:Nt; framerate = 12) do i
n[] = i
endJulia version and environment information
This example was executed with the following version of Julia:
using InteractiveUtils: versioninfo
versioninfo()Julia Version 1.12.4
Commit 01a2eadb047 (2026-01-06 16:56 UTC)
Build Info:
Official https://julialang.org release
Platform Info:
OS: Linux (x86_64-linux-gnu)
CPU: 128 × AMD EPYC 9374F 32-Core Processor
WORD_SIZE: 64
LLVM: libLLVM-18.1.7 (ORCJIT, znver4)
GC: Built with stock GC
Threads: 1 default, 1 interactive, 1 GC (on 128 virtual cores)
Environment:
LD_LIBRARY_PATH =
JULIA_MAX_NUM_PRECOMPILE_FILES = 24
JULIA_PKG_SERVER_REGISTRY_PREFERENCE = eager
JULIA_DEPOT_PATH = /var/lib/buildkite-agent/.julia:/var/lib/buildkite-agent/.julia/juliaup/julia-1.12.4+0.x64.linux.gnu/local/share/julia:/var/lib/buildkite-agent/.julia/juliaup/julia-1.12.4+0.x64.linux.gnu/share/julia
JULIA_PROJECT = /var/lib/buildkite-agent/Oceananigans.jl-32087/docs/
JULIA_VERSION = 1.12.4
JULIA_CUDA_USE_COMPAT = false
JULIA_LOAD_PATH = @:@v#.#:@stdlib
JULIA_VERSION_ENZYME = 1.10.10
JULIA_DEBUG = LiterateThese were the top-level packages installed in the environment:
import Pkg
Pkg.status()Status `~/Oceananigans.jl-32087/docs/Project.toml`
[79e6a3ab] Adapt v4.6.1
⌅ [052768ef] CUDA v6.1.0
[13f3f980] CairoMakie v0.15.12
[e30172f5] Documenter v1.17.0
[daee34ce] DocumenterCitations v1.4.1
[4710194d] DocumenterVitepress v0.3.4
[033835bb] JLD2 v0.6.4
[63c18a36] KernelAbstractions v0.9.41
[98b081ad] Literate v2.21.0
[da04e1cc] MPI v0.20.26
[85f8d34a] NCDatasets v0.14.15
[9e8cae18] Oceananigans v0.110.4 `..`
[f27b6e38] Polynomials v4.1.1
[6038ab10] Rotations v1.7.1
[d496a93d] SeawaterPolynomials v0.3.10
[09ab397b] StructArrays v0.7.3
[bdfc003b] TimesDates v0.3.3
[0a941bbe] Zarr v0.10.0
[b77e0a4c] InteractiveUtils v1.11.0
[37e2e46d] LinearAlgebra v1.12.0
[44cfe95a] Pkg v1.12.1
Info Packages marked with ⌅ have new versions available but compatibility constraints restrict them from upgrading. To see why use `status --outdated`This page was generated using Literate.jl.