Baroclinic Coupling
Cocoa can couple a depth-averaged (2DDI) hydrodynamic solve to an external 3D ocean climatology through three terms: a horizontal baroclinic pressure gradient (BPG) with its free-surface correction, an internal-tide wave-drag friction enhancement, and a dispersion friction correction. All three read a single preprocessor-generated nodal forcing file (Configuration) and share a common activation ramp.
The method follows the 2DDI approach of Pringle et al. (2019)
[Pringle2019] and its global application [Pringle2021], as refined in
ADCIRC by C. Blakely, whose global tidal dissipation analysis
[Blakely2022] underpins the internal-tide drag’s role in the global
model. The internal-tide and dispersion terms are carried over from
ADCIRC’s nodalattr.F and internaltide.F90.
Fig. 6 Depth-averaged velocity magnitude on a 3.7M-node global shell, forced by tides plus a single frozen (diagnostic-mode) RTOFS density snapshot, at day 30 of spin-up. The western boundary currents and the Antarctic Circumpolar Current are resolved.
Overview
A depth-averaged model has no vertical structure, so it cannot represent
the pressure gradient a real 3D density field produces, the drag a
stratified ocean exerts on the barotropic tide through internal-wave
generation, or the extra bottom friction implied by unresolved vertical
shear. Cocoa closes these gaps with fields computed offline from a 3D
ocean climatology (HYCOM GOFS 3.1) by the cocoa_ocean3d Python
preprocessor and interpolated onto mesh nodes as a CF NetCDF time series:
BPG + free-surface correction (Baroclinic pressure gradient): enters GWCE and momentum as an extra pressure-gradient forcing, exactly like the barotropic term.
Internal-tide wave drag (Internal-tide wave drag): an additional symmetric 2x2 friction-tensor contribution, active only where internal tides can propagate freely.
Dispersion as friction (Dispersion as friction): a further friction-tensor contribution representing depth-averaging error.
All three are gated independently under forcing.baroclinic (see
Configuration) and share the file’s BPG, sigma-t, buoyancy-frequency,
and mixed-layer products; enabling internal-tide drag or dispersion
without the parent enabled: true is rejected at startup.
Theory
Baroclinic pressure gradient
The 2DDI BPG at a mesh node is a depth-averaged, horizontal derivative of the baroclinic pressure anomaly
computed by the preprocessor from a TEOS-10 (GSW) in-situ density profile
via one-sided horizontal differences and a trapezoidal vertical integral
(cocoa_ocean3d.bpg). \(\mathrm{BPG}_y\) is defined identically in
the meridional direction. Both are staggered, with BPGX at longitude
midpoints and BPGY at latitude midpoints, and the vertical integral is
truncated at the shallower of any two neighboring wet columns, never
extrapolated past the shallower column’s seabed. The preprocessor clamps
the horizontal density-anomaly bracket to \(\pm 0.1\) kg/m
3 before scaling by \(g/\rho_0\), bounding
\(|\mathrm{BPG}| \le g/\rho_0 \times 0.1 \approx 9.8\times10^{-4}\)
m/s 2.
The BPG enters GWCE and momentum with the same sign convention as the
barotropic pressure gradient
(Physics::BaroclinicGradient::compute_momentum_contribution /
compute_gwce_contribution): the momentum contribution is area-weighted
and Crank-Nicolson time-centered,
where \(\overline{\cdot}\) is the element’s 3-node average, and the GWCE contribution is the negative depth-weighted nodal average \(-\overline{H_i\,\mathrm{BPG}_i}\). Time-centering the BPG matches Cocoa’s other forcing terms (atmospheric pressure, tide potential, wind stress); the nodal reader already brackets two snapshots, so time-centering costs nothing and keeps the RHS assembly internally consistent.
Free-surface correction
The BPG above is computed from a fixed reference density profile and does not know the model’s own free surface. Pringle et al. [Pringle2019] correct for this by adding the elemental gradient of the surface potential-density anomaly (sigma-t) times the live free-surface elevation,
evaluated with the linear-basis element gradient coefficients already
resident for the pressure-gradient term (MomentumRhsKernels.cpp’s
spatial_grad_a/b). The term is recomputed every step from the current
eta and the time-interpolated sigma_t and added into the
effective bpg_x/bpg_y fields before either consumer reads them,
so the momentum and GWCE contributions both include it. A node whose
surface sigma-t is the missing-value sentinel (see Nodal forcing
file) zeroes the whole element’s correction.
Internal-tide wave drag
A barotropic tide flowing over rough topography in a stratified ocean
radiates internal waves, extracting momentum from the barotropic flow.
Cocoa represents this as an additional symmetric 2x2 contribution to the
friction tensor (Momentum Equations), driven by a user-supplied
topographic nodal attribute and the file’s runtime bottom/mean buoyancy
frequencies (\(N_b\), \(N_m\)). Three forms are supported,
selected by the attribute’s column count
(physics.internal_tide_friction: mesh, an existing
NodalAttributeReader variable named internal_tide_friction):
Nycander (2005) [Nycander2005], 5-column (“nonlocal”): columns are the coefficient \(C\) and two topographic-slope pairs \((H_x, H_y)\), \((J_x, J_y)\),
\[\mathrm{Fit} = C\,\frac{N_b}{H}\sqrt{1 - f^2/\omega^2}, \qquad \gamma^2 = \frac{H_x^2 + H_y^2}{(\omega^2-f^2)/(N_b^2-\omega^2)}, \qquad \mathrm{Fit} \leftarrow \frac{\mathrm{Fit}}{\max(1,\gamma^2)},\]with tensor add \((2\,\mathrm{Fit}\,H_xJ_x,\ 2\,\mathrm{Fit}\,H_yJ_y,\ \mathrm{Fit}(H_xJ_y+H_yJ_x))\) for \((xx, yy, xy)\).
Lyard et al. (2004) [Lyard2004], 3-column (“local”) and its Zaron & Egbert (2006) [ZaronEgbert2006] 2-column scalar form share one drag coefficient,
\[\mathrm{Fit} = C\,\frac{\sqrt{(N_b^2-\omega^2)(N_m^2-\omega^2)}}{\omega} \Big/ \max\Bigl(1,\ \frac{\omega^2-f^2}{N_b^2-\omega^2}\Bigr),\]isotropic (\(C_{xx}=C_{yy}=\mathrm{Fit}\cdot\text{slope}^2\), \(C_{xy}=0\)) for the scalar form, or the full tensor (\(C_{xx}=\mathrm{Fit}H_x^2\), etc.) for the 3-column form.
Both forms act only where internal tides can propagate freely:
\(\omega < N_b\) (supercritical stratification) and
\(\omega > |f|\) (above the local inertial frequency), \(\omega\)
being the M2 tidal frequency (Constants::omega_m2(),
\(1.4052\times10^{-4}\) rad/s) throughout. Outside those bounds the
tensor add is exactly zero. A 1/3-column attribute without baroclinic
coupling (forcing.baroclinic.internal_tide.enabled: false) is the
static form: the raw attribute values are added to the friction tensor
directly, with no \(N_b\)/\(N_m\)/de-tiding dependence; a 5-column
attribute always requires the coupling (there is no static Nycander
equivalent) and is rejected at startup otherwise.
Two conventions differ from the published formulation so that an ADCIRC
fort.13 converted by utils/cocoa_mesh_tools.py keeps working with
the values it already has.
First, the coefficient: [Pringle2019] (Eq. 5) writes the drag tensor with a \(4\pi\) in the denominator and reports a tuned \(C_{it} = 4.0\). Neither ADCIRC nor Cocoa carries the \(4\pi\); both fold it into the attribute value, so the number in column 1 is \(C_{it}/4\pi\) (about 0.318 for the paper’s 4.0), and taking \(C_{it}\) straight from the paper overstates the drag by a factor of about 12.6.
Second, the shelf cutoff: [Pringle2019] sets \(\mathcal{C} = 0\)
where \(h < 100\) m to prevent excessive dissipation on the
continental shelves, while Cocoa applies no such depth gate (the only
cutoffs are the propagation bounds above). internal_tide_friction is
a user-supplied mesh attribute that cocoa_ocean3d does not generate,
so zeroing it on the shelf is the responsibility of whatever produced
the attribute. An ADCIRC fort.13 prepared for the published method
already carries that zeroing; an attribute built from raw topographic
slopes does not, and will dissipate energy across the shelf.
De-tiding
The internal-tide and dispersion formulas above act on the non-tidal
(subtidal) velocity, not the raw depth-averaged velocity, which is
dominated by the barotropic tide itself. Cocoa extracts the non-tidal
component with a 25-hour, 12-minute-sampled lagged boxcar mean
(equivalent to ADCIRC’s UNTIDE, including its partial mean during the
first 25 hours of spin-up), then forms a capped de-tiding ratio
that scales the internal-tide tensor contribution: a steady (non-tidal) flow has \(D_{\mathrm{tidal}}=0\), so the drag reduces to bare friction, while a pure oscillation has the de-tided velocity go to zero, so \(\mathrm{adju}\to1\). The ring state and the effective BPG pair are persisted, so a mid-window restart reproduces an uninterrupted run exactly (see Checkpoint format).
The ring buffer’s sampling is entirely device-resident: float ring
samples and double running accumulators (~1.6 GB and 25.6 MB at 1.6M
nodes). The window-crossing decision (whether this step is the ~1-in-360
step that samples) is made on the host from a mirror of the last-sample
time, so the per-node sampling kernel launches only on crossing steps and
no per-step scalar deep_copy fence exists on either path.
Dispersion as friction
Depth averaging discards a vertical shear dispersion term. Cocoa adds a
further diagonal friction-tensor contribution, active only in deep water
(dispersion.deep_water_depth, default 1000 m, tested against the
local bathymetric depth rather than total depth so the gate does not
chatter with the tide):
with \(\mathrm{adj}_u,\mathrm{adj}_v\) the same capped de-tiding
ratio form as above, under a symmetric \([-100,100]\) cap since this
ratio, unlike the internal-tide ratio, is a raw mean-over-instantaneous
quotient that can be negative during a tidal reversal. Both ratios are
applied under a single joint \(|u|,|v|>10^{-3}\) guard: a large
\(u\) with a near-zero \(v\) still gets
\(\mathrm{adj}_u=1\), not the ratio. The off-diagonal entry is never
touched by this term. \(c_d\) comes from one of two modes
(dispersion.mode):
mld(mixed-layer-depth power law): \(c_d = a\,(x^b - 1)\), where \(x\) is the file’s dimensionlessmixed_layer_ratio, floored at \(4\times10^{-3}\) before the power.a/bare required config keys with no default; appropriate values are case-specific.cdisp(direct coefficient): \(c_d\) is the file’sdispersion_cdvalue directly, clamped to \(\le 1.0\) at read time.
mixed_layer_ratio is the mixed-layer depth divided by the local wet
depth and is at most 1 by construction (see Nodal forcing file), so
with positive a and b the power law gives \(c_d \le 0\) and
the term reduces friction. The positive-semidefinite clamp below keeps
the combined tensor physical when this reduction, together with a
tidal-reversal sign flip in \(\mathrm{adj}_u\)/\(\mathrm{adj}_v\),
would otherwise drive a diagonal entry negative.
Positive-semidefinite clamp
Internal-tide drag and dispersion are both added on top of the base
Manning-friction tensor; either can, in principle, push the combined
symmetric 2x2 tensor non-positive-semidefinite, an unphysical friction
that would inject energy. Cocoa applies a closed-form eigenvalue floor
(physics/FrictionTensorPsd.hpp) after every additive contribution:
with any negative eigenvalue floored at zero and the tensor reconstructed from the unchanged eigenvectors. The clamp is an exact no-op on the pure Manning tensor (\(xy=0\) makes both eigenvalues equal the non-negative diagonal entries), so the path with no internal tide and no dispersion reproduces Cocoa’s pre-baroclinic references exactly, a standing regression test.
Preprocessor pipeline
The cocoa_ocean3d Python package (utils/cocoa_ocean3d) produces
the nodal forcing file in three stages tied together by
pipeline.SnapshotRegridder:
Compute (
compute.compute_snapshot): TEOS-10 (via thegswpackage) in-situ density from practical salinity and in-situ temperature, usinggsw.p_from_zfor the depth-to-pressure conversion. BPGX/BPGY, surface sigma-t, \(N_b\)/\(N_m\) (bottom and depth-mean buoyancy frequency viagsw.Nsquared), and the mixed-layer ratio (a de Boyer Montegut et al. 2004 [DeBoyerMontegut2004] fixed 0.03 kg/m3 sigma-t-anomaly criterion) are all computed here.Regrid (
regrid.compute_weights/apply_weights): bilinear by default, falling back to a box average when the mesh’s local neighboring-element-centroid footprint spans more source grid cells than a configurable threshold. A fill guard (any contributing source cell non-finite) NaNs the regridded node value.Write (
writer.NodalForcingWriter): the CF nodal file (see Nodal forcing file), one call per snapshot, append-safe (an existing file’s time axis is validated and extended, never silently overwritten).
The cocoa-ocean3d CLI wraps download/compute/regrid/all
subcommands against HYCOM GOFS 3.1 (experiment IDs isolated in a
module-level table). No live-network tests run in CI; download logic is
tested against recorded fixtures.
Configuration
Key |
Default |
Description |
|---|---|---|
|
|
Master switch. There is no scalar-constant fallback: the nodal file is either present and read, or the feature is off entirely. |
|
(required if enabled) |
Nodal forcing NetCDF path (see Nodal forcing file). |
|
enabled, |
One-sided cold-start spinup ramp (normalized tanh, 0 at
|
|
|
Activates the dynamic (\(N_b\)/\(N_m\)-driven) internal-tide
drag. Requires |
|
|
Activates dispersion-as-friction. Requires
|
|
|
|
|
|
Deep-water gate on local bathymetric depth. |
|
(required if mode: mld) |
Power-law coefficients applied to the dimensionless
|
forcing:
baroclinic:
enabled: true
filename: "baroclinic_forcing.nc"
ramp:
enabled: true
duration: 12h
internal_tide:
enabled: true
dispersion:
enabled: true
mode: mld
deep_water_depth: 1000.0
mld_coefficients:
a: 0.01
b: 0.5
tide:
# ... tidal forcing, unaffected by baroclinic coupling ...
physics:
internal_tide_friction: mesh # required when internal_tide.enabled
Nodal forcing file
CF-1.8 NetCDF, dims (time, node), written by the preprocessor and read
by a reader modeled on FlowTimeSeries’s snapshot-bracketing shape
(distributed owned+ghost read, linear time interpolation, persistent device
Views allocated once).
Variable |
Units |
Required when |
|---|---|---|
|
CF numeric (seconds since an epoch the file itself declares) |
always |
|
m s-2 |
always |
|
kg m-3 |
always |
|
s-1 |
|
|
1 (dimensionless) |
|
|
1 (dimensionless) |
|
One file covers every product; the conditional variables above let a run
enable any subset without forking the file format. A frozen file (exactly
one time value) applies for the whole simulation window (diagnostic
mode) and is exempt from the reader’s time-coverage validation.
Missing-value convention (binding on both the writer and the
reader): every variable declares _FillValue = NaN, but only
sigma_t_surface may legally carry it in a well-formed file. The
preprocessor zero-fills BPG/N/dispersion products at dry columns (so a
coastal bilinear blend fades toward zero forcing), while surface sigma-t
is NaN at any node whose regridding stencil touches a dry/land source
cell. NaN in any other variable is a fail-loud file error naming the
variable and the first offending node; the free-surface-correction
kernel is the one consumer of the sigma_t_surface NaN sentinel (see
Free-surface correction).
Global attributes: Conventions (“CF-1.8”), title/institution/
source/history (CF provenance), mesh_num_nodes (validated
against the reader’s own count, fail loud on mismatch),
mesh_id (the cm1P.. fingerprint, when the writer could compute
one; validated when present, not required), source_dataset,
utility_version, generation_command.
The file stores every product in fp64. In the model the nodal fields are
narrowed to float (Types::promote/Types::narrow at the read/write
boundary, the same convention the friction and lateral-stress tensors use);
all arithmetic on them stays double, including the free-surface-correction
accumulation, which keeps a dedicated fp64 scratch pair. Float storage sits
below the information content of the source data (an OGCM analysis
differentiated onto the mesh carries four to five significant digits), and
the momentum element kernel’s per-node gather of the Crank-Nicolson pair
was the single largest per-step cost the coupling added. The same kernel
reads that pair pre-summed (the compose pass forms
bpg_effective_*_prev + bpg_effective_* in the node sweep it already
runs), so momentum gathers two nodal arrays rather than four.
Regression checks
Every layer pins a “zeros reproduce the pre-baroclinic baseline”
invariant: an all-zero nodal forcing file with baroclinic enabled must
reproduce the same-config baroclinic-disabled run exactly
(Integration_WNAT_baroclinic_zero_forcing), a static internal-tide
attribute of zeros must reproduce baseline, and a disabled dispersion
path must reproduce baseline. These checks prove the gating is correct
independent of the physics itself. The composed integration test
(Integration_WNAT_baroclinic_composed) runs the opposite direction:
with the BPG, free-surface correction, internal-tide drag, and dispersion
all active, the run must differ measurably from its baroclinic-disabled
counterpart, backed by a committed reference at the same 1e-6 cross-run
tolerance used by the other WNAT implicit references.
Checkpoint format
The internal-tide de-tiding ring buffer’s state (ring samples as float
with two velocity components, the double running accumulator, the ring
write position, whether the buffer has completed a full cycle, and the
last-sample simulation time) is persisted in Cocoa’s checkpoint format
(Checkpoint / Restart), gated behind internal_tide.enabled like the
existing meteorological/tide-potential state. The effective BPG pair and
its previous level are likewise persisted (GWCE consumes the value
composed during the previous step and momentum centers against the step
before that, so both are live state carried across a step boundary),
gated behind the baroclinic runtime state, and stay f8 on disk
regardless of the in-model float storage. Both are additive checkpoint
format changes (version 4); a checkpoint from a run without the
corresponding forcing carries none of these fields. With every carried
level in the file, a mid-window restart reproduces an uninterrupted run
exactly.
Full citations are collected in References.