ocean_vmix_t Derived Type

type, public :: ocean_vmix_t


Inherits

type~~ocean_vmix_t~~InheritsGraph type~ocean_vmix_t ocean_vmix_t type~eos_t eos_t type~ocean_vmix_t->type~eos_t eos

Inherited by

type~~ocean_vmix_t~~InheritedByGraph type~ocean_vmix_t ocean_vmix_t type~ocean_state_t ocean_state_t type~ocean_state_t->type~ocean_vmix_t vmix type~ocean_engine_t ocean_engine_t type~ocean_engine_t->type~ocean_state_t state type~ocean_handle_t ocean_handle_t type~ocean_handle_t->type~ocean_state_t state type~ocean_handle_t->type~ocean_engine_t engine

Components

Type Visibility Attributes Name Initial
real(kind=wp), public, allocatable :: b0(:,:)

Surface buoyancy flux B_0 (m^2/s^3) the KPP overlay’s convective scale was built from, persisted per column on the SECOND pass (the one that uses the freshly-diagnosed bl_depth). Sign convention matches EPBL’s epbl%b0: > 0 stabilizing (heating / freshening), < 0 destabilizing (cooling / salting), and w_*^3 = max(0, -b0)*bl_depth. Diagnostic only — nothing reads it back into the closure; it exists so the two boundary-layer schemes’ surface forcing can be compared directly (test_ocean_buoyancy_flux). Zero until the first KPP overlay call.

real(kind=wp), public :: bkgnd_delta = 222.0_wp

Transition half-width (m): atan argument is (|z|-z0)/Delta.

logical, public :: bkgnd_henyey = .false.

Henyey, Wright & Flatte (1986) JGR 91:8487 latitude-dependent internal-wave factor, scaling the SCALAR background tracer diffusivities kt_bg/ks_bg by a horizontal-only factor L(phi) computed from geolatT (see henyey_lat_factor_impl for the exact form), with the result floored at bkgnd_kd_min:

kt_floor(i,j) = max(bkgnd_kd_min, kt_bg * L(phi))

The implemented variant is the SIMPLIFIED one of Harrison & Hallberg (2008) JPO 38:1894, which assumes the in-situ stratification equals a constant reference N0 rather than the evolving column N — so the factor depends only on latitude + bkgnd_henyey_n0_2omega / bkgnd_henyey_max_lat and needs no per-step recompute.

MUTUALLY EXCLUSIVE with bkgnd_profile (Bryan-Lewis), matching the reference formulation, which selects ONE background scheme and FATALs when a second is requested. Enabling both fails loud at configure. This is also the cheaper arrangement: no per-interface kd_bg field is filled at all, the latitude factor is a per-column scalar folded straight into the existing floor/ceiling clip.

Also requires a non-cartesian grid_config (validated fail-loud at configure): geolatT is identically zero on a cartesian grid, so every column would take the equatorial factor L(0 deg) = 0 and the background would collapse to a uniform bkgnd_kd_min everywhere — a latitude parameterisation on a grid with no meaningful latitude. Default .false. ⇒ the scalar background floor path is used verbatim (bit-identical).

SCOPE: the factor scales the two TRACER background floors only; the momentum floor kv_bg is left alone. Roundabout’s scalar background path deliberately carries kv_bg as an INDEPENDENT momentum floor rather than prandtl * kt_bg (the shipped defaults 1e-4 / 1e-5 imply Pr = 10), so there is no single background Kd for the reference code’s Kv_bkgnd = PRANDTL_BKGND * Kd tie to reproduce here. Henyey scales the diapycnal DIFFUSIVITY, which is what kt_bg/ks_bg are.

real(kind=wp), public :: bkgnd_henyey_max_lat = 95.0_wp

Latitude (degN) poleward of which the factor is reset to its equatorial (near-zero) floor. Deliberately > 90 by default so the clamp is INERT for any real latitude out of the box; lower it to activate the optional poleward cutoff.

real(kind=wp), public :: bkgnd_henyey_n0_2omega = 20.0_wp

Ratio of the assumed reference buoyancy frequency N0 to twice the planetary rotation rate (nondim). Physically N0 >> 2*Omega always, so this stays well above 1 (configure-time min=1 guard keeps the internal acosh argument in-domain even under a misconfigured value).

real(kind=wp), public :: bkgnd_kd_deep = 1.3e-4_wp

Deep-asymptote background tracer diffusivity (m^2/s).

real(kind=wp), public :: bkgnd_kd_min = -1.0_wp

Minimum background tracer diffusivity (m^2/s) under the Henyey latitude scaling — MOM6 KD_MIN, applied as max(Kd_min, Kd * L(phi)). Without it kd_bg would collapse toward zero at the equator (L(0 deg) = 0 exactly) and at the poleward max_lat clamp, instead of the documented behaviour “the Henyey profile is returned to the MINIMUM diffusivity”.

NEGATIVE = unset sentinel ⇒ resolved to HENYEY_KD_MIN_FRAC * kt_bg (MOM6’s 0.01*KD default) by vmix_resolve_kd_min, which vmix_assemble calls on every entry so a directly-constructed slot (unit tests) gets the same default as the configure path. Read only when bkgnd_henyey is on.

real(kind=wp), public :: bkgnd_kd_sfc = 1.0e-5_wp

Surface-asymptote background tracer diffusivity (m^2/s). MOM6 BRYAN_LEWIS_C2-side default scale.

real(kind=wp), public :: bkgnd_prandtl = 1.0_wp

Background Prandtl number — Kv_bg = bkgnd_prandtl * Kd_bg (MOM6 ties the background viscosity to the background tracer diffusivity through a Prandtl factor). Default 1.0.

logical, public :: bkgnd_profile = .false.

Master switch for the depth-varying Bryan-Lewis background. Default .false. ⇒ vmix_assemble uses the scalar kv_bg/kt_bg/ks_bg floor exactly as before (bit-identical).

real(kind=wp), public :: bkgnd_z0 = 2500.0_wp

Transition-centre depth (m, positive down) where the profile reaches the (sfc+deep)/2 midpoint.

real(kind=wp), public, allocatable :: bl_depth(:,:)

KPP boundary-layer depth (m, positive down).

integer, public :: buoyancy_coeffs = BUOY_COEFFS_CONSTANT

BUOY_COEFFS_CONSTANT | BUOY_COEFFS_EOS. Read ON-DEVICE (inside vmix_kpp_overlay_impl’s do concurrent bodies), so it rides the same configure-precedes-enter_data contract as rho0 and the pp81_* scalars beside it.

real(kind=wp), public :: c_vt2 = 1.8_wp

Unresolved-turbulence coefficient for the V_t² term in the bulk-Ri denominator (LMD94 eq 23). Folded form of (C_v · √(-β_T) · √(c_s · ε)) / κ. Default 1.8 matches the standard LMD94 tuning. Set to 0 to disable V_t² — recovers the shear-only bulk-Ri sweep bit-identically; useful as a discriminator in unit tests. bl_depth(:, :) from the previous step seeds w_s for V_t², so on the very first call (h_b_lagged = 0) the w_* contribution evaluates to zero and the algorithm self-bootstraps.

logical, public :: conv_enable = .false.

Master switch. Requires use_closure + thermodynamics (validated at configure). Default off => bit-identical.

real(kind=wp), public :: conv_kd = 1.0_wp

Convective tracer diffusivity (m^2/s). MOM6 KD_CONV default 1.0 – ~1e5x the background, admissible only because vdiff is an unconditionally-stable backward-Euler solve.

real(kind=wp), public :: conv_n2_thresh = 0.0_wp

Trigger threshold on N^2 (s^-2). MOM6 BV_SQR_CONV default 0.0. Strict < so an exactly-neutral interface (N^2 == 0) does not trigger.

real(kind=wp), public :: conv_prandtl = 1.0_wp

Kv_conv = conv_prandtl * kd_conv. MOM6 PRANDTL_CONV default 1.0.

real(kind=wp), public :: cs_nonlocal = 6.3_wp

Non-local transport coefficient C_s (LMD94 eq 20). The full form 6.32·(1 - 0.5·exp(-σ/0.1)) is asymptotic; at σ > 0.1 it sits within 1% of the limit value 6.32, and the non-local flux is most important in that range. Using the limit value keeps the kernel branch-free and matches MOM6’s KPP_Cstar default.

logical, public :: ddiff_enable = .false.

Master switch (MOM6 USE_CVMIX_DDIFF). Requires thermodynamics (validated at configure). Default off.

real(kind=wp), public :: ddiff_exp1 = 1.0_wp

Inner (bracket) exponent of the fingering clamped form (CVMix DDIFF_EXP1).

real(kind=wp), public :: ddiff_exp2 = 3.0_wp

Outer exponent of the fingering clamped form (CVMix DDIFF_EXP2); exp1=1, exp2=3 is the Large et al. cubic.

real(kind=wp), public :: ddiff_kappa_s = 1.0e-4_wp

Leading salt-fingering salinity diffusivity K_f (m^2/s, CVMix KAPPA_DDIFF_S). K_T = 0.7*K_S (0.7 hard-wired).

real(kind=wp), public :: ddiff_mol_diff = 1.5e-6_wp

Molecular diffusivity scaling the convection branch (m^2/s, CVMix MOL_DIFF) – the molecular value, NOT a background eddy diffusivity.

real(kind=wp), public :: ddiff_param1 = 0.909_wp

MC76 diffusive-convection exterior coeff (CVMix KAPPA_DDIFF_PARAM1).

real(kind=wp), public :: ddiff_param2 = 4.6_wp

MC76 middle coeff (CVMix KAPPA_DDIFF_PARAM2).

real(kind=wp), public :: ddiff_param3 = -0.54_wp

MC76 interior coeff (CVMix KAPPA_DDIFF_PARAM3).

real(kind=wp), public :: ddiff_strat_param_max = 2.55_wp

R_rho salt-fingering cutoff (CVMix STRAT_PARAM_MAX). Above it fingering diffusivity is zero.

logical, public :: ddiff_use_k90 = .false.

Diffusive-convection form: .false. = Marmorino-Caldwell 1976 (MC76, default), .true. = Kelley 1990 (K90).

type(eos_t), public :: eos
real(kind=wp), public, allocatable :: gamma_s(:,:,:)

Non-local salinity flux (PSU·m/s) at interfaces.

real(kind=wp), public, allocatable :: gamma_t(:,:,:)

Non-local temperature flux (°C·m/s) at interfaces.

real(kind=wp), public :: hmix_fixed = 20.0_wp

Surface-band thickness (m) over which kv_ml_invz2 profile is active. MOM6 production default 20 m.

integer, public :: interior_closure = VMIX_INTERIOR_PP81

Interior mixing closure tag.

logical, public :: is_init = .false.

True between init and destroy. Prefer this to allocated(...) — tracks GPU device attachment too.

real(kind=wp), public, allocatable :: kd_bg(:,:,:)

Bryan-Lewis depth-profile tracer background (m^2/s) at interfaces. kv background floor = bkgnd_prandtl * kd_bg.

real(kind=wp), public :: kd_max = huge(1.0_wp)

Ceiling on tracer diffusivity (kt, ks) (m^2/s). Default huge = no clip. MOM6 Kd_max.

integer, public :: kd_smooth_iterations = 0

Number of 1-2-1 horizontal smoothing passes applied to kv/kt at each interior interface. Default 0 = off (no smoothing kernel runs, bit-identical). MOM6 Kd_smooth.

integer, public :: kpp_sw_method = KPP_SW_MXL

Shortwave-in-boundary-layer method for B_0 (MOM6 KPP_SHORTWAVE_METHOD). Only bites when penetrating SW is active (sf%has_sw); inert at sw_pen_frac = 0 ⇒ the default mxl_sw is bit-identical to the legacy path there.

real(kind=wp), public, allocatable :: ks(:,:,:)

Salinity eddy diffusivity.

real(kind=wp), public :: ks_bg = 1.0e-5_wp

Background floor on salinity diffusivity (m^2/s). Default matches pp81_kappa_bg.

real(kind=wp), public, allocatable :: kt(:,:,:)

Temperature eddy diffusivity.

real(kind=wp), public :: kt_bg = 1.0e-5_wp

Background floor on temperature diffusivity (m^2/s). Default matches pp81_kappa_bg.

real(kind=wp), public, allocatable :: kv(:,:,:)

Momentum (u, v) eddy viscosity at interfaces.

real(kind=wp), public :: kv_bg = 1.0e-4_wp

Background floor on momentum viscosity (m^2/s). Default matches pp81_nu_bg so the floor never raises a PP81 value (PP81 writes pp81_nu_bg + nu0·factor ≥ pp81_nu_bg).

logical, public :: kv_from_restart = .false.

PR-2 (bt-rem-from-av-rem review): set by ocean_state_restart_read (rdb_ocean_state.F90) immediately after a restart read, from the registry’s entry_found("vmix_kv") — .true. iff THIS read actually found vmix_kv in the checkpoint (an older checkpoint without the field, or a cold start, both leave it .false.). configure_ocean_lateral’s mandatory pp81_* config-copy (vmix_seed_backgrounds) reads it to decide whether to skip reseeding kv — see that routine’s docstring. Pure host bookkeeping: never device-mapped, never itself in the restart registry (it describes a read that already happened, not state to carry forward).

real(kind=wp), public :: kv_max = huge(1.0_wp)

Ceiling on momentum viscosity (m^2/s). Default huge = no clip (bit-identical). MOM6 Kd_max momentum analogue.

real(kind=wp), public :: kv_ml_invz2 = 0.0_wp

Extra kinematic viscosity (m²/s) inside the surface band of thickness hmix_fixed. Profile is (hmix_fixed / z)² where z is the distance from the surface to the interface. Mirrors MOM6’s KV_ML_INVZ2 + HMIX_FIXED combo — provides the dissipation that lets the wind drive thin surface layers without exciting a numerical eigenmode. Zero (default) means no addition; the kv field comes purely from PP81 / KPP.

logical, public :: p_top_in_eos = .false.

Mirror of &ocean_psurf_nml in_eos — whether multilayer_state_t%p_top carries a surface load that the in-situ EOS pressure must be measured down from. Seeded at configure from the same knob EPBL’s in_eos takes, so the two boundary-layer schemes build their pressure stacks the same way. Only read when buoyancy_coeffs == BUOY_COEFFS_EOS (p_top is the zero array when the knob is off anyway — this is the belt-and-braces gate EPBL already carries).

real(kind=wp), public :: pp81_alpha = 5.0_wp

Richardson-number multiplier in (1 + α*Ri). Original paper uses 5; some implementations use 4 or 10.

real(kind=wp), public :: pp81_kappa_bg = 1.0e-5_wp

Background tracer diffusivity (m^2/s).

real(kind=wp), public :: pp81_nu0 = 1.0e-2_wp

Numerator viscosity at Ri=0 (m^2/s).

real(kind=wp), public :: pp81_nu_bg = 1.0e-4_wp

Background interior viscosity (m^2/s).

real(kind=wp), public :: rho0 = 1035.0_wp

Boussinesq reference density (kg/m^3). Used in the N² = -g/ρ_0 * dρ/dz expression, in the friction velocity u_* = √(|τ|/ρ_0), and in the kinematic surface fluxes q_T = Q_heat/(ρ_0·cp) / q_S = Q_salt/ρ_0 that set the KPP surface buoyancy flux B_0.

ASSIGNED FROM CONFIG by configure_ocean_reference_density, which copies the single rho0 of record (&ocean_ic_nml rho_0 -> eos%rho0). The literal here is only the pre-configure type default. UNLIKE the other reference densities this one IS read on-device (this%rho0 inside the do concurrent bodies of vmix_compute_pp81 / vmix_kpp_overlay_impl / vmix_convective_impl), so under mem:separate it is only correct because the configure pass runs strictly BEFORE ocean_state_enter_data’s copyin — same as the pp81_* scalars beside it. A later write needs !$acc update device.

real(kind=wp), public :: ri_crit = 0.3_wp

Critical bulk Richardson number for KPP BL depth.

real(kind=wp), public :: shear2_floor = 1.0e-10_wp

Lower bound on |∂u/∂z|² to avoid Ri = N²/0 blow-up in quiescent water columns.

real(kind=wp), public, allocatable :: smooth_scratch(:,:,:)

Scratch (nx, ny, nz+1) for one smoothing pass.

logical, public :: use_closure = .false.

Master switch. When .false. the driver skips the compute step and rdb_ocean_vdiff falls back to its scalar K_v_* constants. Default off so all existing tests stay on the scalar path.

logical, public :: use_kpp = .false.

Enable KPP surface-boundary-layer scheme (future).

logical, public :: vmix_guard = .false.

Debug-gated negative/NaN guard. When .true. the assembly scans kv/kt/ks for a negative or NaN value and error stops (or returns a non-zero status via the testable path). Default off (cheap reduction skipped) so production runs pay nothing.


Type-Bound Procedures

procedure, public, non_overridable :: bytes => ocean_vmix_bytes

  • private pure function ocean_vmix_bytes(this) result(nbytes)

    Counted allocatable footprint of the vertical mixing slot (0 when unallocated). One arr_bytes term per array — add a term here when a new allocatable joins the type.

    Arguments

    Type IntentOptional Attributes Name
    class(ocean_vmix_t), intent(in) :: this

    Return Value integer(kind=int64)

procedure, public, non_overridable :: destroy => ocean_vmix_destroy

procedure, public, non_overridable :: enter_data => ocean_vmix_enter_data

procedure, public, non_overridable :: exit_data => ocean_vmix_exit_data

procedure, public, non_overridable :: init => ocean_vmix_init

  • private subroutine ocean_vmix_init(this, grid, nz_ml)

    Allocate the kv / kt / ks diffusivity fields at layer interfaces. Default values: kv = kv_bg (= pp81_nu_bg), kt = ks = kt_bg (= pp81_kappa_bg) at interior interfaces; boundary interfaces k=1 and k=nz+1 are zeroed (closed BC). BL fields (bl_depth, gamma_*) seed at zero. ks is allocated so the assembly stage (vmix_assemble) can floor/clip it; vmix_split_kd_heat_salt derives its live value from kt every stage, and vdiff_apply_tracers consumes it for salinity + every passive tracer.

    Read more…

    Arguments

    Type IntentOptional Attributes Name
    class(ocean_vmix_t), intent(inout) :: this
    type(hgrid_t), intent(in) :: grid
    integer, intent(in), optional :: nz_ml

procedure, public, non_overridable :: seed_backgrounds => vmix_seed_backgrounds

  • private pure subroutine vmix_seed_backgrounds(this, skip_kv)

    Seed kv_bg/kt_bg/ks_bg and the kv/kt/ks/kd_bg arrays from the current pp81_nu_bg/pp81_kappa_bg fields, then zero the closed-BC boundary interfaces on kv/ks. Extracted out of ocean_vmix_init (§2E structural invariant: kv_bg == pp81_nu_bg, kt_bg == ks_bg == pp81_kappa_bg, so vmix_assemble’s background floor is a no-op for the shipped PP81 closure) so BOTH the initial seed and the &ocean_vmix_nml pp81_* config-copy re-derive consistently — a bare field copy without this call would leave the assembly floor clamped against the OLD (type-default) background even after a user sets a new one. Requires kv/kt/ks/kd_bg already allocated (true after init; the config-copy call runs strictly after init_from_config).

    Arguments

    Type IntentOptional Attributes Name
    class(ocean_vmix_t), intent(inout) :: this
    logical, intent(in), optional :: skip_kv

    PR-2 (bt-rem-from-av-rem, fixed per review): default .false. — the FULL seed always runs (scalars + kv/kt/ks/kd_bg arrays + the kv/ks boundary zero), exactly the historical behaviour. The config-copy call site (configure_ocean_lateral, AFTER engine_setup’s restart read) passes skip_kv = state%vmix%kv_from_restart — .true. ONLY when THIS read actually found vmix_kv in the checkpoint (an older checkpoint without the field, or a cold start, both leave kv_from_restart = .false., so the array still reseeds normally and the run is never left with an uninitialised kv). When skipped, kv is left EXACTLY as the restart read wrote it — no reseed, no boundary re-zero — because a checkpointed kv is a CARRIED field (visc_rem_precompute reads the PREVIOUS stage’s kv before this stage recomputes it) and re-zeroing its boundary rows is not provably idempotent: nothing in this tree asserts every kv-writing closure (PP81/KPP/EPBL/kappa-shear/tidal-mixing/convective adjustment, vmix_assemble) keeps kv(:,:,1) / kv(:,:,nz+1) at exactly 0 throughout a run, so re-asserting it here could diverge a restored run from the continued one it must match bitwise. Restoring the checkpoint verbatim is the only choice that is unconditionally correct.

    Read more…

Source Code

   type :: ocean_vmix_t
      logical :: is_init = .false.
         !! True between `init` and `destroy`.  Prefer this to
         !! `allocated(...)` — tracks GPU device attachment too.
      logical :: kv_from_restart = .false.
         !! PR-2 (bt-rem-from-av-rem review): set by `ocean_state_restart_read`
         !! (`rdb_ocean_state.F90`) immediately after a restart read, from
         !! the registry's `entry_found("vmix_kv")` — `.true.` iff THIS
         !! read actually found `vmix_kv` in the checkpoint (an older
         !! checkpoint without the field, or a cold start, both leave it
         !! `.false.`).  `configure_ocean_lateral`'s mandatory `pp81_*`
         !! config-copy (`vmix_seed_backgrounds`) reads it to decide
         !! whether to skip reseeding `kv` — see that routine's docstring.
         !! Pure host bookkeeping: never device-mapped, never itself in
         !! the restart registry (it describes a read that already
         !! happened, not state to carry forward).

      ! ---- Scheme selection ----
      logical :: use_closure = .false.
         !! Master switch.  When `.false.` the driver skips the
         !! compute step and `rdb_ocean_vdiff` falls back to its
         !! scalar `K_v_*` constants.  Default off so all existing
         !! tests stay on the scalar path.
      logical :: use_kpp = .false.
         !! Enable KPP surface-boundary-layer scheme (future).
      integer :: interior_closure = VMIX_INTERIOR_PP81
         !! Interior mixing closure tag.

      ! ---- PP81 constants ----
      ! Default values from Pacanowski & Philander (1981) JPO 11:1443.
      real(wp) :: pp81_nu0 = 1.0e-2_wp
         !! Numerator viscosity at Ri=0 (m^2/s).
      real(wp) :: pp81_nu_bg = 1.0e-4_wp
         !! Background interior viscosity (m^2/s).
      real(wp) :: pp81_kappa_bg = 1.0e-5_wp
         !! Background tracer diffusivity (m^2/s).
      real(wp) :: pp81_alpha = 5.0_wp
         !! Richardson-number multiplier in (1 + α*Ri).  Original
         !! paper uses 5; some implementations use 4 or 10.
      real(wp) :: rho0 = 1035.0_wp
         !! Boussinesq reference density (kg/m^3).  Used in the
         !! N² = -g/ρ_0 * dρ/dz expression, in the friction velocity
         !! u_* = √(|τ|/ρ_0), and in the kinematic surface fluxes
         !! q_T = Q_heat/(ρ_0·cp) / q_S = Q_salt/ρ_0 that set the KPP
         !! surface buoyancy flux B_0.
         !!
         !! ASSIGNED FROM CONFIG by `configure_ocean_reference_density`,
         !! which copies the single rho0 of record (`&ocean_ic_nml rho_0`
         !! -> `eos%rho0`).  The literal here is only the pre-configure
         !! type default.  UNLIKE the other reference densities this one
         !! IS read on-device (`this%rho0` inside the `do concurrent`
         !! bodies of `vmix_compute_pp81` / `vmix_kpp_overlay_impl` /
         !! `vmix_convective_impl`), so under `mem:separate` it is only
         !! correct because the configure pass runs strictly BEFORE
         !! `ocean_state_enter_data`'s `copyin` — same as the `pp81_*`
         !! scalars beside it.  A later write needs `!$acc update device`.
      real(wp) :: shear2_floor = 1.0e-10_wp
         !! Lower bound on |∂u/∂z|² to avoid Ri = N²/0 blow-up in
         !! quiescent water columns.

      ! ---- EOS handle for the KPP buoyancy flux ----
      ! Shared flat-POD copy of `ocean_state%eos`, set once at
      ! configure.  The KPP convective-velocity scale B_0 needs the
      ! surface α (thermal expansion) and β (haline contraction);
      ! it reads `eos%alpha_T` / `eos%beta_S` so the closure tracks
      ! the dyn-core EOS coefficients (previously a private pair that
      ! was NEVER refreshed from the eos slot — the latent staleness
      ! bug this centralization fixes).  Maps onto the device with
      ! the parent `this`.  Both are DIMENSIONAL (kg/m^3 per degC /
      ! psu) — see `kpp_surface_buoyancy_flux` for the `1/rho_0` that
      ! turns them into a buoyancy flux.
      type(eos_t) :: eos

      ! ---- Source of the α/β pair (E4, `&ocean_vmix_nml buoyancy_coeffs`) ----
      ! The handle members above are the LINEAR EOS's true coefficients.
      ! Under a NONLINEAR EOS they are a constant stand-in for a strongly
      ! state-dependent pair, and `BUOY_COEFFS_EOS` replaces them with
      ! `eos_buoyancy_coeffs(eos, T, S, p)` evaluated where each consumer
      ! needs it.  Default `BUOY_COEFFS_CONSTANT` ⇒ bit-identical; under
      ! `eos = "linear"` the two settings are byte-identical by
      ! construction (that branch of `eos_buoyancy_coeffs` returns the
      ! handle members themselves, no round-trip through ρ²·dSV).
      integer :: buoyancy_coeffs = BUOY_COEFFS_CONSTANT
         !! `BUOY_COEFFS_CONSTANT` | `BUOY_COEFFS_EOS`.  Read ON-DEVICE
         !! (inside `vmix_kpp_overlay_impl`'s `do concurrent` bodies), so
         !! it rides the same configure-precedes-`enter_data` contract as
         !! `rho0` and the `pp81_*` scalars beside it.
      logical :: p_top_in_eos = .false.
         !! Mirror of `&ocean_psurf_nml in_eos` — whether
         !! `multilayer_state_t%p_top` carries a surface load that the
         !! in-situ EOS pressure must be measured down from.  Seeded at
         !! configure from the same knob EPBL's `in_eos` takes, so the two
         !! boundary-layer schemes build their pressure stacks the same
         !! way.  Only read when `buoyancy_coeffs == BUOY_COEFFS_EOS`
         !! (`p_top` is the zero array when the knob is off anyway — this
         !! is the belt-and-braces gate EPBL already carries).

      ! ---- KPP-specific ----
      real(wp) :: ri_crit = 0.3_wp
         !! Critical bulk Richardson number for KPP BL depth.

      ! ---- KV_ML_INVZ2 surface-band viscosity (MOM6) ----
      real(wp) :: kv_ml_invz2 = 0.0_wp
         !! Extra kinematic viscosity (m²/s) inside the surface band
         !! of thickness `hmix_fixed`.  Profile is `(hmix_fixed / z)²`
         !! where z is the distance from the surface to the interface.
         !! Mirrors MOM6's KV_ML_INVZ2 + HMIX_FIXED combo — provides
         !! the dissipation that lets the wind drive thin surface
         !! layers without exciting a numerical eigenmode.  Zero
         !! (default) means no addition; the kv field comes purely
         !! from PP81 / KPP.
      real(wp) :: hmix_fixed = 20.0_wp
         !! Surface-band thickness (m) over which `kv_ml_invz2`
         !! profile is active.  MOM6 production default 20 m.
      real(wp) :: cs_nonlocal = 6.3_wp
         !! Non-local transport coefficient C_s (LMD94 eq 20).
         !! The full form `6.32·(1 - 0.5·exp(-σ/0.1))` is asymptotic;
         !! at σ > 0.1 it sits within 1% of the limit value 6.32, and
         !! the non-local flux is most important in that range.  Using
         !! the limit value keeps the kernel branch-free and matches
         !! MOM6's `KPP_Cstar` default.
      real(wp) :: c_vt2 = 1.8_wp
         !! Unresolved-turbulence coefficient for the V_t² term in the
         !! bulk-Ri denominator (LMD94 eq 23).  Folded form of
         !! `(C_v · √(-β_T) · √(c_s · ε)) / κ`.  Default 1.8 matches
         !! the standard LMD94 tuning.  Set to 0 to disable V_t² —
         !! recovers the shear-only bulk-Ri sweep bit-identically;
         !! useful as a discriminator in unit tests.
         !! `bl_depth(:, :)` from the previous step seeds w_s for V_t²,
         !! so on the very first call (h_b_lagged = 0) the w_*
         !! contribution evaluates to zero and the algorithm
         !! self-bootstraps.
      integer :: kpp_sw_method = KPP_SW_MXL
         !! Shortwave-in-boundary-layer method for `B_0` (MOM6
         !! `KPP_SHORTWAVE_METHOD`).  Only bites when penetrating SW is
         !! active (`sf%has_sw`); inert at `sw_pen_frac = 0` ⇒ the
         !! default `mxl_sw` is bit-identical to the legacy path there.

      ! ---- Assembly stage: backgrounds / ceilings / smoothing / guard ----
      ! The single downstream gate (`vmix_assemble`) that every interior
      ! and overlay closure feeds into before vdiff consumes kv/kt/ks.
      ! Defaults reproduce the pre-assembly numerics bit-for-bit: the
      ! backgrounds match the constant `pp81_*_bg` that PP81 already adds
      ! (so the floor is a no-op for the closure path), the ceilings are
      ! `huge` (no clip), smoothing is off, and the guard is off.
      real(wp) :: kv_bg = 1.0e-4_wp
         !! Background floor on momentum viscosity (m^2/s).  Default
         !! matches `pp81_nu_bg` so the floor never raises a PP81 value
         !! (PP81 writes `pp81_nu_bg + nu0·factor ≥ pp81_nu_bg`).
      real(wp) :: kt_bg = 1.0e-5_wp
         !! Background floor on temperature diffusivity (m^2/s).
         !! Default matches `pp81_kappa_bg`.
      real(wp) :: ks_bg = 1.0e-5_wp
         !! Background floor on salinity diffusivity (m^2/s).
         !! Default matches `pp81_kappa_bg`.
      real(wp) :: kv_max = huge(1.0_wp)
         !! Ceiling on momentum viscosity (m^2/s).  Default `huge` =
         !! no clip (bit-identical).  MOM6 `Kd_max` momentum analogue.
      real(wp) :: kd_max = huge(1.0_wp)
         !! Ceiling on tracer diffusivity (kt, ks) (m^2/s).  Default
         !! `huge` = no clip.  MOM6 `Kd_max`.
      integer :: kd_smooth_iterations = 0
         !! Number of 1-2-1 horizontal smoothing passes applied to
         !! kv/kt at each interior interface.  Default 0 = off (no
         !! smoothing kernel runs, bit-identical).  MOM6 `Kd_smooth`.
      logical :: vmix_guard = .false.
         !! Debug-gated negative/NaN guard.  When `.true.` the assembly
         !! scans kv/kt/ks for a negative or NaN value and `error stop`s
         !! (or returns a non-zero status via the testable path).
         !! Default off (cheap reduction skipped) so production runs pay
         !! nothing.

      ! ---- C7 background mixing: Bryan-Lewis XOR Henyey ----
      ! Two MUTUALLY EXCLUSIVE alternatives to the scalar background floor,
      ! matching the reference code's one-background-scheme rule:
      !   * `bkgnd_profile` — Bryan & Lewis (1979) depth profile, replacing
      !     the scalar floor with a per-interface `kd_bg` field.
      !   * `bkgnd_henyey`  — Henyey (1986) latitude factor on the SCALAR
      !     `kt_bg`/`ks_bg`, floored at `bkgnd_kd_min`.
      ! Enabling both fails loud at configure.  Both default off ⇒ the
      ! scalar path is used verbatim (bit-identical).
      !
      ! When `bkgnd_profile` is on, the SCALAR `kt_bg`/`ks_bg` additive
      ! floor in `vmix_assemble` is replaced by a per-interface
      ! Bryan & Lewis (1979) JGR 84:2503 depth profile
      !   Kd_bg(z) = Kd_sfc + (Kd_deep - Kd_sfc)*(0.5 + atan((|z|-z0)/Delta)/PI)
      ! evaluated from the CURRENT column interface depths (cumulative
      ! h_layer from the surface down) so it is correct under any vcoord.
      ! Momentum follows MOM6: Kv_bg = bkgnd_prandtl * Kd_bg.  Default off
      ! ⇒ the scalar background path is used verbatim (bit-identical).
      logical :: bkgnd_profile = .false.
         !! Master switch for the depth-varying Bryan-Lewis background.
         !! Default `.false.` ⇒ `vmix_assemble` uses the scalar
         !! kv_bg/kt_bg/ks_bg floor exactly as before (bit-identical).
      real(wp) :: bkgnd_kd_sfc = 1.0e-5_wp
         !! Surface-asymptote background tracer diffusivity (m^2/s).
         !! MOM6 BRYAN_LEWIS_C2-side default scale.
      real(wp) :: bkgnd_kd_deep = 1.3e-4_wp
         !! Deep-asymptote background tracer diffusivity (m^2/s).
      real(wp) :: bkgnd_z0 = 2500.0_wp
         !! Transition-centre depth (m, positive down) where the profile
         !! reaches the (sfc+deep)/2 midpoint.
      real(wp) :: bkgnd_delta = 222.0_wp
         !! Transition half-width (m): atan argument is (|z|-z0)/Delta.
      real(wp) :: bkgnd_prandtl = 1.0_wp
         !! Background Prandtl number — Kv_bg = bkgnd_prandtl * Kd_bg
         !! (MOM6 ties the background viscosity to the background tracer
         !! diffusivity through a Prandtl factor).  Default 1.0.
      logical :: bkgnd_henyey = .false.
         !! Henyey, Wright & Flatte (1986) JGR 91:8487 latitude-dependent
         !! internal-wave factor, scaling the SCALAR background tracer
         !! diffusivities `kt_bg`/`ks_bg` by a horizontal-only factor
         !! `L(phi)` computed from `geolatT` (see `henyey_lat_factor_impl`
         !! for the exact form), with the result floored at `bkgnd_kd_min`:
         !!
         !!   kt_floor(i,j) = max(bkgnd_kd_min, kt_bg * L(phi))
         !!
         !! The implemented variant is the SIMPLIFIED one of Harrison &
         !! Hallberg (2008) JPO 38:1894, which assumes the in-situ
         !! stratification equals a constant reference `N0` rather than the
         !! evolving column N — so the factor depends only on latitude +
         !! `bkgnd_henyey_n0_2omega` / `bkgnd_henyey_max_lat` and needs no
         !! per-step recompute.
         !!
         !! MUTUALLY EXCLUSIVE with `bkgnd_profile` (Bryan-Lewis), matching
         !! the reference formulation, which selects ONE background scheme
         !! and FATALs when a second is requested.  Enabling both fails loud
         !! at configure.  This is also the cheaper arrangement: no
         !! per-interface `kd_bg` field is filled at all, the latitude
         !! factor is a per-column scalar folded straight into the existing
         !! floor/ceiling clip.
         !!
         !! Also requires a non-cartesian `grid_config` (validated fail-loud
         !! at configure): `geolatT` is identically zero on a cartesian grid,
         !! so every column would take the equatorial factor `L(0 deg) = 0`
         !! and the background would collapse to a uniform `bkgnd_kd_min`
         !! everywhere — a latitude parameterisation on a grid with no
         !! meaningful latitude.  Default `.false.` ⇒ the scalar background
         !! floor path is used verbatim (bit-identical).
         !!
         !! SCOPE: the factor scales the two TRACER background floors only;
         !! the momentum floor `kv_bg` is left alone.  Roundabout's scalar
         !! background path deliberately carries `kv_bg` as an INDEPENDENT
         !! momentum floor rather than `prandtl * kt_bg` (the shipped
         !! defaults 1e-4 / 1e-5 imply Pr = 10), so there is no single
         !! background `Kd` for the reference code's `Kv_bkgnd =
         !! PRANDTL_BKGND * Kd` tie to reproduce here.  Henyey scales the
         !! diapycnal DIFFUSIVITY, which is what `kt_bg`/`ks_bg` are.
      real(wp) :: bkgnd_kd_min = -1.0_wp
         !! Minimum background tracer diffusivity (m^2/s) under the Henyey
         !! latitude scaling — MOM6 `KD_MIN`, applied as
         !! `max(Kd_min, Kd * L(phi))`.  Without it `kd_bg` would collapse
         !! toward zero at the equator (`L(0 deg) = 0` exactly) and at the
         !! poleward `max_lat` clamp, instead of the documented behaviour
         !! "the Henyey profile is returned to the MINIMUM diffusivity".
         !!
         !! NEGATIVE = unset sentinel ⇒ resolved to
         !! `HENYEY_KD_MIN_FRAC * kt_bg` (MOM6's `0.01*KD` default) by
         !! `vmix_resolve_kd_min`, which `vmix_assemble` calls on every
         !! entry so a directly-constructed slot (unit tests) gets the same
         !! default as the configure path.  Read only when `bkgnd_henyey`
         !! is on.
      real(wp) :: bkgnd_henyey_n0_2omega = 20.0_wp
         !! Ratio of the assumed reference buoyancy frequency `N0` to twice
         !! the planetary rotation rate (nondim).  Physically
         !! `N0 >> 2*Omega` always, so this stays well above 1
         !! (configure-time `min=1` guard keeps the internal `acosh`
         !! argument in-domain even under a misconfigured value).
      real(wp) :: bkgnd_henyey_max_lat = 95.0_wp
         !! Latitude (degN) poleward of which the factor is reset to its
         !! equatorial (near-zero) floor.  Deliberately > 90 by default so
         !! the clamp is INERT for any real latitude out of the box; lower
         !! it to activate the optional poleward cutoff.

      ! ---- Convective adjustment (Brunt-Vaisala trigger, CVMix_conv-style) ----
      ! `&ocean_conv_nml`.  A CONTRIBUTOR that raises kv/kt via max() where
      ! the interior N^2 < n2_thresh, applied strictly below the active
      ! surface boundary layer.  Default off => bit-identical.  See the
      ! module docstring for the D1-D4 divergences from MOM6's
      ! MOM_CVMix_conv and the kd_max interaction.
      logical :: conv_enable = .false.
         !! Master switch.  Requires `use_closure` + thermodynamics
         !! (validated at configure).  Default off => bit-identical.
      real(wp) :: conv_kd = 1.0_wp
         !! Convective tracer diffusivity (m^2/s).  MOM6 `KD_CONV`
         !! default 1.0 -- ~1e5x the background, admissible only because
         !! vdiff is an unconditionally-stable backward-Euler solve.
      real(wp) :: conv_prandtl = 1.0_wp
         !! Kv_conv = conv_prandtl * kd_conv.  MOM6 `PRANDTL_CONV`
         !! default 1.0.
      real(wp) :: conv_n2_thresh = 0.0_wp
         !! Trigger threshold on N^2 (s^-2).  MOM6 `BV_SQR_CONV` default
         !! 0.0.  Strict `<` so an exactly-neutral interface (N^2 == 0)
         !! does not trigger.

      ! ---- Double diffusion (salt fingering + diffusive convection) ----
      ! `&ocean_ddiff_nml`.  NOT a kv/kt contributor -- it is folded INTO
      ! `vmix_split_kd_heat_salt` (a pre-split ks write would be clobbered
      ! by `ks := kt`), producing an ASYMMETRIC ks-vs-kt divergence:
      !   ks = kt_pre + kd_extra_s ;  kt = kt_pre + kd_extra_t
      ! Interior interfaces only, branched on the SIGNED alpha*dT / beta*dS
      ! (never a pre-divided R_rho -- dodges R_rho<=0, ->inf, 0/0).  The
      ! CVMix (Large et al. 1994 / Marmorino-Caldwell 1976 / Kelley 1990)
      ! closed forms; constants are the CVMix defaults.  Default off =>
      ! bit-identical.  v1 uses the constant linear-EOS alpha_T/beta_S off
      ! `this%eos` (bitwise-exact for the linear EOS); nonlinear
      ! per-interface derivatives are a deferred refinement.
      logical :: ddiff_enable = .false.
         !! Master switch (MOM6 `USE_CVMIX_DDIFF`).  Requires
         !! thermodynamics (validated at configure).  Default off.
      real(wp) :: ddiff_strat_param_max = 2.55_wp
         !! R_rho salt-fingering cutoff (CVMix `STRAT_PARAM_MAX`).  Above
         !! it fingering diffusivity is zero.
      real(wp) :: ddiff_kappa_s = 1.0e-4_wp
         !! Leading salt-fingering salinity diffusivity K_f (m^2/s,
         !! CVMix `KAPPA_DDIFF_S`).  K_T = 0.7*K_S (0.7 hard-wired).
      real(wp) :: ddiff_exp1 = 1.0_wp
         !! Inner (bracket) exponent of the fingering clamped form
         !! (CVMix `DDIFF_EXP1`).
      real(wp) :: ddiff_exp2 = 3.0_wp
         !! Outer exponent of the fingering clamped form (CVMix
         !! `DDIFF_EXP2`); `exp1=1, exp2=3` is the Large et al. cubic.
      real(wp) :: ddiff_param1 = 0.909_wp
         !! MC76 diffusive-convection exterior coeff (CVMix
         !! `KAPPA_DDIFF_PARAM1`).
      real(wp) :: ddiff_param2 = 4.6_wp
         !! MC76 middle coeff (CVMix `KAPPA_DDIFF_PARAM2`).
      real(wp) :: ddiff_param3 = -0.54_wp
         !! MC76 interior coeff (CVMix `KAPPA_DDIFF_PARAM3`).
      real(wp) :: ddiff_mol_diff = 1.5e-6_wp
         !! Molecular diffusivity scaling the convection branch (m^2/s,
         !! CVMix `MOL_DIFF`) -- the *molecular* value, NOT a background
         !! eddy diffusivity.
      logical :: ddiff_use_k90 = .false.
         !! Diffusive-convection form: `.false.` = Marmorino-Caldwell 1976
         !! (MC76, default), `.true.` = Kelley 1990 (K90).

      ! ---- Diagnostic / prognostic 2D fields (KPP) ----
      real(wp), allocatable :: bl_depth(:, :)
         !! KPP boundary-layer depth (m, positive down).
      real(wp), allocatable :: b0(:, :)
         !! Surface buoyancy flux `B_0` (m^2/s^3) the KPP overlay's
         !! convective scale was built from, persisted per column on the
         !! SECOND pass (the one that uses the freshly-diagnosed
         !! `bl_depth`).  Sign convention matches EPBL's `epbl%b0`:
         !! `> 0` stabilizing (heating / freshening), `< 0` destabilizing
         !! (cooling / salting), and `w_*^3 = max(0, -b0)*bl_depth`.
         !! Diagnostic only — nothing reads it back into the closure; it
         !! exists so the two boundary-layer schemes' surface forcing can
         !! be compared directly (`test_ocean_buoyancy_flux`).  Zero until
         !! the first KPP overlay call.

      ! ---- 3D mixing coefficients on layer interfaces ----
      ! Shape (nx, ny, nz_ml+1); k=1 at the bed, k=nz_ml+1 at the
      ! free surface.  Consumed by the vertical-diffusion solve.
      real(wp), allocatable :: kv(:, :, :)
         !! Momentum (u, v) eddy viscosity at interfaces.
      real(wp), allocatable :: kt(:, :, :)
         !! Temperature eddy diffusivity.
      real(wp), allocatable :: ks(:, :, :)
         !! Salinity eddy diffusivity.

      ! ---- KPP non-local (counter-gradient) transport ----
      ! Interface-located downward flux in tracer-units · m/s.  Only
      ! non-zero inside the BL under destabilizing surface forcing
      ! (B_0 < 0).  Divergence ∂γ/∂z appears as a source in the
      ! tracer-vdiff RHS.  Allocated alongside `kv` / `kt` when
      ! `nz_ml` is supplied; same shape `(nx, ny, nz_ml + 1)`.
      real(wp), allocatable :: gamma_t(:, :, :)
         !! Non-local temperature flux (°C·m/s) at interfaces.
      real(wp), allocatable :: gamma_s(:, :, :)
         !! Non-local salinity flux (PSU·m/s) at interfaces.

      ! ---- Assembly smoothing scratch ----
      ! Double-buffer for the 1-2-1 horizontal smoothing kernel — a
      ! 1-2-1 pass cannot run in place (neighbours must read pre-pass
      ! values).  Allocated in `init` and mapped in `enter_data`
      ! alongside kv/kt so the smoothing kernel never lazy-attaches a
      ! component to an already-mapped parent (that collides with the
      ! present table).  Only read when `kd_smooth_iterations > 0`.
      real(wp), allocatable :: smooth_scratch(:, :, :)
         !! Scratch (nx, ny, nz+1) for one smoothing pass.

      ! ---- C7 Bryan-Lewis background floor field ----
      ! Per-interface tracer-background diffusivity (m^2/s), shape
      ! (nx, ny, nz+1).  Filled by `vmix_bkgnd_fill` (called from
      ! `vmix_assemble`) each stage from the current column thicknesses
      ! when `bkgnd_profile` is on; otherwise never read.  Allocated in
      ! `init` so the enter_data orchestrator maps it unconditionally
      ! (avoids a lazy device attach on an already-mapped parent).
      real(wp), allocatable :: kd_bg(:, :, :)
         !! Bryan-Lewis depth-profile tracer background (m^2/s) at
         !! interfaces.  kv background floor = bkgnd_prandtl * kd_bg.
   contains
      procedure, non_overridable :: init => ocean_vmix_init
      procedure, non_overridable :: destroy => ocean_vmix_destroy
      procedure, non_overridable :: enter_data => ocean_vmix_enter_data
      procedure, non_overridable :: exit_data => ocean_vmix_exit_data
      procedure, non_overridable :: bytes => ocean_vmix_bytes
      procedure, non_overridable :: seed_backgrounds => vmix_seed_backgrounds
   end type ocean_vmix_t