==================== 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 (:doc:`../getting_started/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``. .. figure:: ../_static/images/baroclinic_global_velocity_30day.png :alt: Global depth-averaged velocity after 30 days of tidal plus baroclinic forcing :width: 100% :align: center 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. .. contents:: On This Page :local: :depth: 2 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 .. math:: \mathrm{BPG}_x = \frac{g}{\rho_0 H}\int_{-H}^{0} \int_{-H}^{z}\frac{\partial \rho}{\partial x}\,dz'\,dz, 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``). :math:`\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 :math:`\pm 0.1` kg/m :sup:`3` before scaling by :math:`g/\rho_0`, bounding :math:`|\mathrm{BPG}| \le g/\rho_0 \times 0.1 \approx 9.8\times10^{-4}` m/s :sup:`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, .. math:: \Delta(\text{momentum RHS}) = -\frac{\Delta t}{2}\, A_e\, \bigl(\overline{\mathrm{BPG}}^{\,n} + \overline{\mathrm{BPG}}^{\,n+1}\bigr), where :math:`\overline{\cdot}` is the element's 3-node average, and the GWCE contribution is the negative depth-weighted nodal average :math:`-\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, .. math:: \Delta\mathrm{BPG}_{\mathrm{FS}} = \frac{g}{\rho_0}\, \nabla\bigl(\sigma_t\,\eta\bigr), 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 (:doc:`../theory/momentum`), driven by a user-supplied topographic nodal attribute and the file's runtime bottom/mean buoyancy frequencies (:math:`N_b`, :math:`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 :math:`C` and two topographic-slope pairs :math:`(H_x, H_y)`, :math:`(J_x, J_y)`, .. math:: \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 :math:`(2\,\mathrm{Fit}\,H_xJ_x,\ 2\,\mathrm{Fit}\,H_yJ_y,\ \mathrm{Fit}(H_xJ_y+H_yJ_x))` for :math:`(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, .. math:: \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 (:math:`C_{xx}=C_{yy}=\mathrm{Fit}\cdot\text{slope}^2`, :math:`C_{xy}=0`) for the scalar form, or the full tensor (:math:`C_{xx}=\mathrm{Fit}H_x^2`, etc.) for the 3-column form. Both forms act only where internal tides can propagate freely: :math:`\omega < N_b` (supercritical stratification) and :math:`\omega > |f|` (above the local inertial frequency), :math:`\omega` being the M2 tidal frequency (``Constants::omega_m2()``, :math:`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 :math:`N_b`/:math:`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 :math:`4\pi` in the denominator and reports a tuned :math:`C_{it} = 4.0`. Neither ADCIRC nor Cocoa carries the :math:`4\pi`; both fold it into the attribute value, so the number in column 1 is :math:`C_{it}/4\pi` (about 0.318 for the paper's 4.0), and taking :math:`C_{it}` straight from the paper overstates the drag by a factor of about 12.6. Second, the shelf cutoff: [Pringle2019]_ sets :math:`\mathcal{C} = 0` where :math:`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 .. math:: \mathrm{adju} = \min\!\Bigl(\frac{D_{\mathrm{tidal}}}{D_{\mathrm{total}}},\ 100\Bigr), \qquad D = C_{xx}u^2 + 2C_{xy}uv + C_{yy}v^2\ \text{(evaluated at raw and de-tided velocity)}, that scales the internal-tide tensor contribution: a steady (non-tidal) flow has :math:`D_{\mathrm{tidal}}=0`, so the drag reduces to bare friction, while a pure oscillation has the de-tided velocity go to zero, so :math:`\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): .. math:: \mathrm{add}_{xx} = \mathrm{ramp}\cdot c_d\cdot \mathrm{adj}_u\cdot \frac{\lVert(\bar u,\bar v)\rVert}{H}, \qquad \mathrm{add}_{yy} = \mathrm{ramp}\cdot c_d\cdot \mathrm{adj}_v\cdot \frac{\lVert(\bar u,\bar v)\rVert}{H}, with :math:`\mathrm{adj}_u,\mathrm{adj}_v` the same capped de-tiding ratio form as above, under a symmetric :math:`[-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 :math:`|u|,|v|>10^{-3}` guard: a large :math:`u` with a near-zero :math:`v` still gets :math:`\mathrm{adj}_u=1`, not the ratio. The off-diagonal entry is never touched by this term. :math:`c_d` comes from one of two modes (``dispersion.mode``): - ``mld`` (mixed-layer-depth power law): :math:`c_d = a\,(x^b - 1)`, where :math:`x` is the file's dimensionless ``mixed_layer_ratio``, floored at :math:`4\times10^{-3}` before the power. ``a``/``b`` are required config keys with no default; appropriate values are case-specific. - ``cdisp`` (direct coefficient): :math:`c_d` is the file's ``dispersion_cd`` value directly, clamped to :math:`\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 :math:`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 :math:`\mathrm{adj}_u`/:math:`\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: .. math:: \lambda_{1,2} = \frac{\mathrm{tr}}{2} \pm \sqrt{\left(\frac{xx-yy}{2}\right)^2 + xy^2}, 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 (:math:`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``: 1. **Compute** (``compute.compute_snapshot``): TEOS-10 (via the ``gsw`` package) in-situ density from practical salinity and in-situ temperature, using ``gsw.p_from_z`` for the depth-to-pressure conversion. BPGX/BPGY, surface sigma-t, :math:`N_b`/:math:`N_m` (bottom and depth-mean buoyancy frequency via ``gsw.Nsquared``), and the mixed-layer ratio (a de Boyer Montegut et al. 2004 [DeBoyerMontegut2004]_ fixed 0.03 kg/m\ :sup:`3` sigma-t-anomaly criterion) are all computed here. 2. **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. 3. **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 ------------- .. list-table:: :header-rows: 1 :widths: 25 15 60 * - Key - Default - Description * - ``forcing.baroclinic.enabled`` - ``false`` - Master switch. There is no scalar-constant fallback: the nodal file is either present and read, or the feature is off entirely. * - ``forcing.baroclinic.filename`` - *(required if enabled)* - Nodal forcing NetCDF path (see `Nodal forcing file`_). * - ``forcing.baroclinic.ramp`` - enabled, ``1d`` - One-sided cold-start spinup ramp (normalized tanh, 0 at ``simulation.start_time`` rising to 1 over ``duration``), shared by the BPG/free-surface correction, the dynamic internal-tide drag, and dispersion, so ``ramp_factor == 0`` reproduces the pre-baroclinic friction tensor exactly. Independent of the global ``forcing.ramp`` (disabling one does not disable the other). A static ``internal_tide_friction`` attribute is not a baroclinic contribution (it is legal with no baroclinic file at all), so it is left unramped. * - ``forcing.baroclinic.internal_tide.enabled`` - ``false`` - Activates the dynamic (:math:`N_b`/:math:`N_m`-driven) internal-tide drag. Requires ``physics.internal_tide_friction: mesh`` naming a 2/3/ 5-column ``internal_tide_friction`` nodal attribute. Gated separately from the BPG, but still requires the parent ``enabled: true`` (the shared file supplies :math:`N_b`/:math:`N_m`). * - ``forcing.baroclinic.dispersion.enabled`` - ``false`` - Activates dispersion-as-friction. Requires ``internal_tide.enabled: true`` (dispersion rides on the same de-tided ``adju``/``adjv`` machinery internal-tide drag needs). * - ``forcing.baroclinic.dispersion.mode`` - ``mld`` - ``mld`` or ``cdisp``; see `Dispersion as friction`_. * - ``forcing.baroclinic.dispersion.deep_water_depth`` - ``1000.0`` [m] - Deep-water gate on local bathymetric depth. * - ``forcing.baroclinic.dispersion.mld_coefficients.a``/``b`` - *(required if mode: mld)* - Power-law coefficients applied to the dimensionless ``mixed_layer_ratio``; no default exists, and appropriate values are case-specific. Any positive pair produces :math:`c_d \le 0` (see `Dispersion as friction`_). .. code-block:: yaml 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). .. list-table:: :header-rows: 1 :widths: 25 15 60 * - Variable - Units - Required when * - ``time`` - CF numeric (seconds since an epoch the file itself declares) - always * - ``bpg_x``, ``bpg_y`` - m s\ :sup:`-2` - always * - ``sigma_t_surface`` - kg m\ :sup:`-3` - always * - ``n_bottom``, ``n_mean`` - s\ :sup:`-1` - ``internal_tide.enabled`` * - ``mixed_layer_ratio`` - 1 (dimensionless) - ``dispersion.enabled`` and ``mode: mld`` * - ``dispersion_cd`` - 1 (dimensionless) - ``dispersion.enabled`` and ``mode: cdisp`` 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 (:doc:`checkpoint`), 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 :doc:`../references`.