Skip to content

Tilted bottom boundary layer ​

This example simulates a two-dimensional oceanic bottom boundary layer in a domain that's tilted with respect to gravity. We simulate the perturbation away from a constant along-slope (y-direction) velocity and a constant density stratification. This perturbation develops into a turbulent bottom boundary layer due to momentum loss at the bottom boundary modeled with a quadratic drag law.

This example illustrates

  • changing the direction of gravitational acceleration in the buoyancy model;

  • changing the axis of rotation for Coriolis forces.

Install dependencies ​

First let's make sure we have all required packages installed.

julia
using Pkg
pkg"add Oceananigans, NCDatasets, CairoMakie"

The domain ​

We create a grid with finer resolution near the bottom,

julia
using Oceananigans
using Oceananigans.Units
using Random

Random.seed!(42) # for reproducible results

Lx = 200meters
Lz = 100meters
Nx = 64
Nz = 64

refinement = 1.8 # controls spacing near the bottom (higher means finer spaced)
stretching = 10  # controls rate of stretching away from the bottom

h(k) = (Nz + 1 - k) / Nz
ζ(k) = 1 + (h(k) - 1) / refinement
Σ(k) = (1 - exp(-stretching * h(k))) / (1 - exp(-stretching))

z_faces(k) = - Lz * (ζ(k) * Σ(k) - 1)

grid = RectilinearGrid(topology = (Periodic, Flat, Bounded),
                       size = (Nx, Nz),
                       x = (0, Lx),
                       z = z_faces)
64×1×64 RectilinearGrid{Float64, Periodic, Flat, Bounded} on CPU with 3×0×3 halo
├── Periodic x ∈ [0.0, 200.0)  regularly spaced with Δx=3.125
├── Flat y                     
└── Bounded  z ∈ [-0.0, 100.0] variably spaced with min(Δz)=0.868817, max(Δz)=6.55496

Let's make sure the grid spacing is both finer and near-uniform at the bottom,

julia
using CairoMakie

scatterlines(zspacings(grid, Center()),
             axis = (ylabel = "Depth (m)",
                     xlabel = "Vertical spacing (m)"))

Tilting the domain ​

We use a domain that's tilted with respect to gravity by

julia
θ = 3 # degrees
3

so that is the across-slope direction, is the slope-normal direction that is perpendicular to the bottom, and the unit vector anti-aligned with gravity is

julia
ẑ = (sind(θ), 0, cosd(θ))
(0.052335956242943835, 0, 0.9986295347545738)

Changing the vertical direction impacts both the gravity_unit_vector for BuoyancyForce as well as the rotation_axis for Coriolis forces,

julia
buoyancy = BuoyancyForce(BuoyancyTracer(), gravity_unit_vector = .-ẑ)
coriolis = ConstantCartesianCoriolis(f = 1e-4, rotation_axis = ẑ)
ConstantCartesianCoriolis{Float64}: fx = 5.23e-06, fy = 0.00e+00, fz = 9.99e-05

where above we used a constant Coriolis parameter   . The tilting also affects the kind of density stratified flows we can model. In particular, a constant density stratification in the tilted coordinate system

julia
@inline constant_stratification(x, z, t, p) = p.N² * (x * p.ẑ[1] + z * p.ẑ[3])
constant_stratification (generic function with 1 method)

is not periodic in . Thus we cannot explicitly model a constant stratification on an -periodic grid such as the one used here. Instead, we simulate periodic perturbations away from the constant density stratification by imposing a constant stratification as a BackgroundField,

julia
N² = 1e-5 # s⁻²
B∞_field = BackgroundField(constant_stratification, parameters=(; ẑ, N²))
BackgroundField{typeof(Main.var"##8349".constant_stratification), @NamedTuple{ẑ::Tuple{Float64, Int64, Float64}, N²::Float64}}
├── func: constant_stratification (generic function with 1 method)
└── parameters: (ẑ = (0.052335956242943835, 0, 0.9986295347545738), N² = 1.0e-5)

We choose to impose a bottom boundary condition of zero total diffusive buoyancy flux across the seafloor,

This shows that to impose a no-flux boundary condition on the total buoyancy field , we must apply a boundary condition to the perturbation buoyancy ,

julia
b_bcs = FieldBoundaryConditions(bottom = GradientBoundaryCondition(-N² * cosd(θ)))
Oceananigans.FieldBoundaryConditions, with boundary conditions
├── west: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
├── east: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
├── south: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
├── north: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
├── bottom: GradientBoundaryCondition: -9.9863e-6
├── top: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
└── immersed: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)

Bottom drag and along-slope interior velocity ​

We impose bottom drag that follows Monin–Obukhov theory. We use BulkDrag to create the drag boundary conditions, which computes a quadratic drag proportional to the total velocity (including the background velocity):

julia
V∞ = 0.1 # m s⁻¹
ℓ = 0.1  # roughness length (m)
ϰ = 0.4  # von Kármán constant

z₁ = first(znodes(grid, Center())) # height of the grid center closest to the bottom
cᴰ = (ϰ / log(z₁ / ℓ))^2

drag_bc = BulkDrag(coefficient=cᴰ, background_velocities=(0, V∞, 0))

u_bcs = FieldBoundaryConditions(bottom=drag_bc)
v_bcs = FieldBoundaryConditions(bottom=drag_bc)
Oceananigans.FieldBoundaryConditions, with boundary conditions
├── west: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
├── east: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
├── south: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
├── north: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
├── bottom: FluxBoundaryCondition: BulkDragFunction(QuadraticFormulation(), Nothing, Cᴰ=0.07416276026775863)
├── top: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)
└── immersed: DefaultBoundaryCondition (FluxBoundaryCondition: nothing)

Note that, similar to the buoyancy boundary conditions, we had to include the background flow in the drag calculation.

Let us also create a BackgroundField for the along-slope interior velocity:

julia
V∞_field = BackgroundField(V∞)
BackgroundField{Float64, Nothing}
├── func: 0.1
└── parameters: nothing

Create the NonhydrostaticModel ​

We are now ready to create the model. We create a NonhydrostaticModel with a fifth-order UpwindBiased advection scheme and a constant viscosity and diffusivity. Here we use a smallish value of   .

julia
closure = ScalarDiffusivity(ν=1e-4, κ=1e-4)

model = NonhydrostaticModel(grid; buoyancy, coriolis, closure,
                            advection = UpwindBiased(order=5),
                            tracers = :b,
                            boundary_conditions = (u=u_bcs, v=v_bcs, b=b_bcs),
                            background_fields = (; b=B∞_field, v=V∞_field))
NonhydrostaticModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0)
├── grid: 64×1×64 RectilinearGrid{Float64, Periodic, Flat, Bounded} on CPU with 3×0×3 halo
├── timestepper: RungeKutta3TimeStepper
├── advection scheme:
│   ├── momentum: UpwindBiased(order=5)
│   └── b: UpwindBiased(order=5)
├── tracers: b
├── closure: ScalarDiffusivity{ExplicitTimeDiscretization}(ν=0.0001, κ=(b=0.0001,))
├── buoyancy: BuoyancyTracer with ĝ = (-0.052336, 0.0, -0.99863)
└── coriolis: ConstantCartesianCoriolis{Float64}

Let's introduce a bit of random noise at the bottom of the domain to speed up the onset of turbulence:

julia
noise(x, z) = 1e-3 * randn() * exp(-(10z)^2 / grid.Lz^2)
set!(model, u=noise, w=noise)

Create and run a simulation ​

We are now ready to create the simulation. We begin by setting the initial time step conservatively, based on the smallest horizontal grid spacing and the interior velocity.

julia
Δt₀ = 0.5 * minimum_xspacing(grid) / V∞
simulation = Simulation(model, Δt = Δt₀, stop_time = 1day)
Simulation of NonhydrostaticModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0)
├── Next time step: 15.625 seconds
├── run_wall_time: 0 seconds
├── run_wall_time / iteration: NaN days
├── stop_time: 1 day
├── stop_iteration: Inf
├── wall_time_limit: Inf
├── minimum_relative_step: 0.0
├── callbacks: OrderedDict with 4 entries:
│   ├── stop_time_exceeded => Callback of stop_time_exceeded on IterationInterval(1)
│   ├── stop_iteration_exceeded => Callback of stop_iteration_exceeded on IterationInterval(1)
│   ├── wall_time_limit_exceeded => Callback of wall_time_limit_exceeded on IterationInterval(1)
│   └── nan_checker => Callback of NaNChecker for u on IterationInterval(100)
└── output_writers: OrderedDict with no entries

We use a TimeStepWizard to adapt our time-step,

julia
conjure_time_step_wizard!(simulation, IterationInterval(4), max_change=1.1, cfl=0.7)

and also we add another callback to print a progress message,

julia
using Printf

progress_message(sim) =
    @printf("Iteration: %04d, time: %s, Δt: %s, max|w|: %.1e m s⁻¹, wall time: %s\n",
            iteration(sim), prettytime(sim), prettytime(sim.Δt),
            maximum(abs, sim.model.velocities.w), prettytime(sim.run_wall_time))

add_callback!(simulation, progress_message, IterationInterval(200))

Add outputs to the simulation ​

We output the total buoyancy , the total along-slope velocity , and the -component of vorticity    using the NetCDFWriter, which needs NCDatasets to be loaded:

julia
u, v, w = model.velocities
b = model.tracers.b
B∞ = model.background_fields.tracers.b

B = b + B∞
V = v + V∞
ωy = ∂z(u) - ∂x(w)

outputs = (; u, V, w, B, ωy)

using NCDatasets

filename = joinpath(@__DIR__, "tilted_bottom_boundary_layer.nc")

simulation.output_writers[:fields] = NetCDFWriter(model, outputs; filename,
                                                  schedule = TimeInterval(20minutes),
                                                  overwrite_files = true)
NetCDFWriter scheduled on TimeInterval(20 minutes):
├── filepath: tilted_bottom_boundary_layer.nc
├── dimensions: time(0), x_faa(64), x_caa(64), z_aaf(65), z_aac(64)
├── 5 outputs: (u, V, w, B, ωy)
├── array_type: Array{Float32}
├── file_splitting: NoFileSplitting
└── file size: (file not yet created)

Now we just run it!

julia
run!(simulation)
[ Info: Initializing simulation...
Iteration: 0000, time: 0 seconds, Δt: 17.188 seconds, max|w|: 1.0e-03 m s⁻¹, wall time: 0 seconds
[ Info:     ... simulation initialization complete (6.843 seconds)
[ Info: Executing initial time step...
[ Info:     ... initial time step complete (1.044 seconds).
Iteration: 0200, time: 4.523 hours, Δt: 1.637 minutes, max|w|: 5.2e-03 m s⁻¹, wall time: 8.033 seconds
Iteration: 0400, time: 9.325 hours, Δt: 1.647 minutes, max|w|: 5.1e-03 m s⁻¹, wall time: 8.184 seconds
Iteration: 0600, time: 13.834 hours, Δt: 1.087 minutes, max|w|: 6.3e-03 m s⁻¹, wall time: 8.337 seconds
Iteration: 0800, time: 17.714 hours, Δt: 1.427 minutes, max|w|: 4.5e-03 m s⁻¹, wall time: 8.486 seconds
Iteration: 1000, time: 21.144 hours, Δt: 1.216 minutes, max|w|: 6.6e-03 m s⁻¹, wall time: 8.628 seconds
[ Info: Simulation is stopping after running for 8.739 seconds.
[ Info: Simulation time 1 day equals or exceeds stop time 1 day.

Visualize the results ​

We load the output as FieldTimeSeries and create an animation showing the -component of vorticity and the along-slope velocity, with buoyancy contours overlaid.

julia
fig = Figure(size = (800, 600))

axis_kwargs = (xlabel = "Across-slope distance (m)",
               ylabel = "Slope-normal\ndistance (m)",
               limits = ((0, Lx), (0, Lz)))

ax_ω = Axis(fig[2, 1]; title = "Along-slope vorticity", axis_kwargs...)
ax_v = Axis(fig[3, 1]; title = "Along-slope velocity (v)", axis_kwargs...)

n = Observable(1)

ωy_timeseries = FieldTimeSeries(filename, "ωy")
B_timeseries  = FieldTimeSeries(filename, "B")
V_timeseries  = FieldTimeSeries(filename, "V")

ωyn = @lift ωy_timeseries[$n]
Bn  = @lift B_timeseries[$n]
Vn  = @lift V_timeseries[$n]

buoyancy_levels = -1e-3:5e-5:1e-3

hm_ω = heatmap!(ax_ω, ωyn, colorrange = (-0.015, +0.015), colormap = :balance)
Colorbar(fig[2, 2], hm_ω; label = "s⁻¹")
contour!(ax_ω, Bn, levels=buoyancy_levels, color=:black)

hm_v = heatmap!(ax_v, Vn, colorrange = (-V∞, +V∞), colormap = :balance)
Colorbar(fig[3, 2], hm_v; label = "m s⁻¹")
contour!(ax_v, Bn, levels=buoyancy_levels, color=:black)

times = ωy_timeseries.times
title = @lift "t = " * prettytime(times[$n])
fig[1, :] = Label(fig, title, fontsize=20, tellwidth=false)

fig

Finally, we record a movie.

julia
frames = 1:length(times)

record(fig, "tilted_bottom_boundary_layer.mp4", frames, framerate=12) do i
    n[] = i
end


Julia version and environment information ​

This example was executed with the following version of Julia:

julia
using InteractiveUtils: versioninfo
versioninfo()
Julia Version 1.13.0
Commit d1c37793dd2 (2026-09-09 19:00 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-20.1.8 (ORCJIT, znver4)
  GC: Built with stock GC
Threads: 1 default, 1 interactive, 1 GC (on 128 virtual cores)
Environment:
  JULIA_LOAD_PATH = @:@v#.#:@stdlib
  JULIA_DEPOT_PATH = /var/lib/buildkite-agent/.julia:/var/lib/buildkite-agent/.julia/juliaup/julia-1.13.0+0.x64.linux.gnu/local/share/julia:/var/lib/buildkite-agent/.julia/juliaup/julia-1.13.0+0.x64.linux.gnu/share/julia
  JULIA_VERSION_ENZYME = 1.13.0
  JULIA_PKG_SERVER_REGISTRY_PREFERENCE = eager
  LD_LIBRARY_PATH = 
  JULIA_MAX_NUM_PRECOMPILE_FILES = 24
  JULIA_VERSION = 1.13.0
  JULIA_CUDA_USE_COMPAT = false
  JULIA_PROJECT = /var/lib/buildkite-agent/Oceananigans.jl-34814/docs/
  JULIA_DEBUG = Literate

These were the top-level packages installed in the environment:

julia
import Pkg
Pkg.status()
Status `~/Oceananigans.jl-34814/docs/Project.toml`
  [79e6a3ab] Adapt v4.7.4
⌃ [052768ef] CUDA v6.2.2
  [13f3f980] CairoMakie v0.15.15
  [8e50ac2c] ConservativeRegridding v0.2.16
⌅ [e30172f5] Documenter v1.17.0
  [daee34ce] DocumenterCitations v1.5.0
  [4710194d] DocumenterVitepress v0.3.7
  [7da242da] Enzyme v0.13.216
  [033835bb] JLD2 v0.6.7
⌅ [63c18a36] KernelAbstractions v0.9.45
  [98b081ad] Literate v2.21.0
  [da04e1cc] MPI v0.20.28
  [85f8d34a] NCDatasets v0.14.15
  [9e8cae18] Oceananigans v0.114.0 `..`
  [429524aa] Optim v2.3.2
  [f27b6e38] Polynomials v4.1.3
  [3c362404] Reactant v0.2.292
  [6038ab10] Rotations v1.7.1
  [d496a93d] SeawaterPolynomials v0.3.11
  [09ab397b] StructArrays v0.7.3
  [bdfc003b] TimesDates v0.3.3
⌃ [0a941bbe] Zarr v0.10.2
  [b77e0a4c] InteractiveUtils v1.11.0
  [37e2e46d] LinearAlgebra v1.13.0
  [44cfe95a] Pkg v1.13.0
Info Packages marked with ⌃ and ⌅ have new versions available. Those with ⌃ may be upgradable, but those with ⌅ are restricted by compatibility constraints from upgrading. To see why use `status --outdated`

This page was generated using Literate.jl.