!! Energetics-based planetary boundary layer (EPBL) for the ocean core.
module rdb_ocean_epbl
   !! Prognostic-energy surface boundary layer: each thermo step the
   !! wind supplies mechanical TKE (mstar·rho0·u*^3·dt) and surface
   !! buoyancy loss supplies convective PE (nstar-weighted); the
   !! scheme spends that energy interface by interface — the
   !! diffusivity at each interface is the largest value whose
   !! implicit-diffusion PE cost the remaining TKE can pay.  Mixing
   !! stops where the energy runs out, which IS the mixed-layer
   !! depth.  Energetically closed by construction; unconditionally
   !! stable (the sweep performs the forward elimination of the
   !! backward-Euler vertical-diffusion solve it feeds).
   !!
   !! References: Reichl & Hallberg (2018), Ocean Modelling 132
   !! ("ePBL"); the gravity-wave column-height correction follows the
   !! same paper's available-PE bookkeeping.  Knob table:
   !! `docs/generated_nml_knobs.md`.
   !!
   !! Scope (PR 1): surface boundary layer only — no bottom-boundary
   !! ePBL, no mean-KE→TKE conversion (so the closed-form direct
   !! energy solve applies; no inner Newton loop), no Langmuir
   !! enhancement (PR 2).  Boussinesq SI.
   !!
   !! Interface convention (same as `rdb_ocean_vmix`): `kd_int(:,:,k)`
   !! lives at the bottom interface of layer k; `kd_int(:,:,1)` is the
   !! bed and `kd_int(:,:,nz+1)` the free surface, both forced to 0.
   !! Layers are bottom-up: k=1 bed, k=nz surface.
   !!
   !! EPBL replaces the KPP overlay (mutually exclusive); the PP81
   !! interior closure + background diffusivity continue to run
   !! underneath, and `epbl_merge_into_kv_kt` folds `kd_int` into
   !! `vmix%kv` / `vmix%kt` additively (MOM6 EPBL_IS_ADDITIVE) or by
   !! max, every stage; `epbl_compute` itself runs at thermo cadence.
   use rdb_constants, only: wp, GRAVITY, H_DIV_EPS
   use rdb_grid, only: hgrid_t
   use rdb_multilayer_state, only: multilayer_state_t
   use rdb_ocean_surface_stress, only: ocean_surface_stress_t
   use rdb_ocean_surface_flux, only: ocean_surface_flux_t, &
                                     sw_transmission, sw_pe_cost_shape
   use rdb_eos, only: eos_t, eos_specvol_derivs
   use rdb_scratch_3d, only: scratch_3d_buffer_t, &
                             scratch_3d_buffer_enter_data_impl, &
                             scratch_3d_buffer_exit_data_impl
   use, intrinsic :: iso_fortran_env, only: int64
   use rdb_mem_report, only: arr_bytes
   implicit none
   private

   public :: ocean_epbl_t
   public :: epbl_compute
   public :: epbl_merge_into_kv_kt
   public :: epbl_find_mstar
   public :: epbl_mixlen_shape
   public :: epbl_lf17_wave_state
   public :: epbl_lf17_la
   public :: epbl_lt_enhance
   public :: parse_epbl_mstar_scheme
   public :: parse_epbl_vstar_scheme
   public :: parse_epbl_combine
   public :: parse_epbl_lt_scheme

   ! mstar (mechanical TKE / u*^3) scheme tags.
   integer, parameter, public :: EPBL_MSTAR_CONSTANT = 1
      !! Fixed mstar (the simplest credible configuration).
   integer, parameter, public :: EPBL_MSTAR_OM4 = 2
      !! Ekman/Obukhov-balance form used by MOM6's OM4 production
      !! config (Reichl & Hallberg 2018 Appendix).
   integer, parameter, public :: EPBL_MSTAR_RH18 = 3
      !! Reichl & Hallberg (2018) eq. fits (cN1..cS2 coefficients).

   ! Turbulent-velocity-scale scheme tags.
   integer, parameter, public :: EPBL_VSTAR_CUBE_ROOT = 1
      !! vstar = vstar_scale_fac * (TKE / (dt rho0))^(1/3).
   integer, parameter, public :: EPBL_VSTAR_RH18 = 2
      !! Separate mechanical (u*-proportional, surface-decaying) +
      !! convective cube-root contributions (RH18).

   ! Combination of kd_epbl with the interior closure's kv/kt.
   integer, parameter, public :: EPBL_COMBINE_ADD = 1
      !! kt += kd ; kv += prandtl*kd  (MOM6 EPBL_IS_ADDITIVE=.true.)
   integer, parameter, public :: EPBL_COMBINE_MAX = 2
      !! kt = max(kt, kd) ; kv = max(kv, prandtl*kd)

   ! Langmuir-enhancement scheme tags (Reichl & Li 2019 forms).
   integer, parameter, public :: EPBL_LT_NONE = 0
   integer, parameter, public :: EPBL_LT_RESCALE = 1
      !! mstar *= min(max_enh, 1 + coef * La^exp)  (multiplicative)
   integer, parameter, public :: EPBL_LT_ADDITIVE = 2
      !! mstar += coef * La^exp

   ! Thickness regulariser added to every layer thickness used inside
   ! the energy solve (MOM6 H_subroundoff role): keeps the pivot
   ! products bdt1 = hp_a*hp_b representable when a layer vanishes.
   ! Division-safety only (D4 role 2): aliases the constant of record
   ! `H_DIV_EPS` from rdb_constants — the local name is kept so the many
   ! use sites below read unchanged (bitwise no-op: 1e-20 == 1e-20).
   real(wp), parameter :: H_NEGLECT = H_DIV_EPS

   ! ---- LF17 / COARE 3.5 empirical constants ----
   ! Parts of the cited formulas, NOT namelist knobs.  Li & Fox-Kemper
   ! (2017) statistical wave state; Edson et al. (2013) COARE 3.5
   ! drag; Webb & Fox-Kemper (2015) / Breivik et al. (2016)
   ! surface-layer-averaged Stokes drift over a Phillips spectrum.
   real(wp), parameter :: LT_VONKAR_WAVES = 0.40_wp
      !! von Karman constant used by the COARE u*->U10 inversion.
   real(wp), parameter :: LT_NU_AIR = 1.0e-6_wp
      !! Kinematic viscosity of air (m^2/s) for the smooth-flow z0.
   real(wp), parameter :: LT_RHO_AIR = 1.225_wp
      !! Air density (kg/m^3) for the water->air u* conversion.
   real(wp), parameter :: LT_CHARNOCK_MIN = 0.028_wp
      !! Cap on the Charnock parameter.
   real(wp), parameter :: LT_CHARNOCK_SLOPE = 0.0017_wp
      !! d(alpha_Charnock)/dU10 (s/m).
   real(wp), parameter :: LT_CHARNOCK_ICPT = -0.005_wp
      !! Charnock intercept at U10 = 0.
   real(wp), parameter :: LT_US_TO_U10 = 0.0162_wp
      !! Surface Stokes drift / U10 (Webb 2011).
   real(wp), parameter :: LT_SWH_FROM_U10SQ = 0.0246_wp
      !! Significant wave height = c * U10^2 (s^2/m).
   real(wp), parameter :: LT_U19P5_TO_U10 = 1.075_wp
      !! Pierson-Moskowitz U19.5/U10 ratio.
   real(wp), parameter :: LT_FM_INTO_FP = 1.296_wp
      !! Mean / peak frequency ratio (Webb 2011).
   real(wp), parameter :: LT_R_LOSS = 0.667_wp
      !! Stokes-transport loss ratio.
   real(wp), parameter :: LT_PI = 4.0_wp*atan(1.0_wp)

   type :: ocean_epbl_t
      logical :: is_init = .false.
         !! True between `init` and `destroy`.

      ! ---- Scheme selection + master switch ----
      logical :: enable = .false.
         !! Master switch.  Default off — all existing namelists and
         !! tests stay bit-identical.  Requires `vmix%use_closure`;
         !! disables the KPP overlay (configure logs the override).
      integer :: mstar_scheme = EPBL_MSTAR_OM4
      integer :: vstar_scheme = EPBL_VSTAR_CUBE_ROOT
      integer :: combine_mode = EPBL_COMBINE_ADD

      ! ---- mstar knobs ----
      real(wp) :: mstar_const = 1.2_wp
         !! Constant-scheme mstar (MOM6 MSTAR).
      real(wp) :: mstar_cap = -1.0_wp
         !! Cap on mstar for the OM4/RH18 schemes; off when < 0.
      real(wp) :: mstar_coef1 = 0.3_wp
         !! OM4 stabilizing-balance coefficient (MOM6 MSTAR2_COEF1).
      real(wp) :: c_ek = 0.085_wp
         !! OM4 Ekman-limit coefficient (MOM6 MSTAR2_COEF2).
      real(wp) :: mstar_conv_adj = 0.0_wp
         !! Reduce mechanical mstar when convection dominates, in
         !! [0,1]; 0 = off (MOM6 MSTAR_CONV_ADJ).
      real(wp) :: rh18_cn1 = 0.275_wp
      real(wp) :: rh18_cn2 = 8.0_wp
      real(wp) :: rh18_cn3 = -5.0_wp
      real(wp) :: rh18_cs1 = 0.2_wp
      real(wp) :: rh18_cs2 = 0.4_wp
         !! RH18 mstar fit coefficients (paper values).

      ! ---- Energetics knobs ----
      real(wp) :: nstar = 0.2_wp
         !! Fraction of convectively released PE that becomes
         !! entrainment-driving TKE (MOM6 NSTAR).
      real(wp) :: tke_decay = 2.5_wp
         !! Ratio of the natural Ekman depth to the mechanical-TKE
         !! decay scale (MOM6 TKE_DECAY).
      real(wp) :: wstar_ustar_coef = 1.0_wp
         !! Weight of the convective reservoir in the velocity scale.
      real(wp) :: vstar_scale_fac = 1.0_wp
         !! Overall vstar multiplier (∝ diffusivity).
      real(wp) :: vstar_surf_fac = 1.2_wp
         !! RH18 vstar scheme: mechanical surface vstar ∝ u* factor.
      real(wp) :: von_karman = 0.41_wp
         !! kappa in Kd = vstar * kappa * mixing_length.
      real(wp) :: ekman_scale_coef = 1.0_wp
         !! Rotational inhibition of the mixing length.
      real(wp) :: min_mix_len = 0.0_wp
         !! Mixing-length floor (m).
      real(wp) :: mixlen_exponent = 2.0_wp
         !! Shape-function exponent (2 = KPP-like; fast path).
      real(wp) :: translay_scale = 0.1_wp
         !! Transition-layer floor of the shape function; must be in
         !! [0,1) when `mld_iteration` is on.

      ! ---- MLD iteration knobs ----
      logical :: mld_iteration = .true.
         !! Iterate the boundary-layer depth to self-consistency with
         !! the mixing-length shape function (MOM6 USE_MLD_ITERATION).
      real(wp) :: mld_tol = 1.0_wp
         !! Convergence tolerance on the MLD root-find (m).
      integer :: mld_max_its = 20
      logical :: mld_bisection = .false.
         !! Plain bisection instead of false-position.
      logical :: mld_use_prev_guess = .false.
         !! Seed the iteration from the previous step's MLD (faster,
         !! ~1-3 iterations; off = always start at half depth).

      ! ---- Environment ----
      real(wp) :: rho0 = 1035.0_wp
         !! Boussinesq reference density (kg/m^3).
      real(wp) :: omega = 7.2921e-5_wp
         !! Earth rotation rate (1/s), for the omega_frac blend.
      real(wp) :: omega_frac = 0.0_wp
         !! Blend |f| with 2*Omega: absf = sqrt((1-of) f^2 + of 4 Om^2).
      real(wp) :: ustar_min = 1.0e-8_wp
         !! Floor on u* (deliberate rdb floor — pure denominator
         !! guard in the TKE decay scale; MOM6 derives ~1e-10).
      real(wp) :: prandtl = 1.0_wp
         !! Kv = prandtl * Kd into the momentum solve.
      logical :: in_eos = .false.
         !! `&ocean_psurf_nml in_eos` (E3): start this scheme's column
         !! pressure stack at `multilayer_state_t%p_top` (the ice-shelf
         !! load + surface pressure, Pa) instead of at 0 Pa.
         !!
         !! The stack has TWO consumers here and the knob moves BOTH,
         !! deliberately: the IN-SITU pressure argument of
         !! `eos_specvol_derivs`, and the PE weight
         !! `dpe_* = dmass*p_mid*dsv_*`.  They are the same pressure --
         !! the weight is the hydrostatic load the layer's centre of mass
         !! has to lift, and under a floating shelf the ice is part of
         !! that load.  Splitting them would put two pressure conventions
         !! in one column, which is the failure the `p_top` seam contract
         !! exists to prevent.
         !!
         !! Host scalar, assigned by `configure_ocean_epbl` BEFORE
         !! `enter_data`, and passed BY VALUE into the column kernel --
         !! never read through the device-mapped handle.
         !!
         !! `.false.` (default) ⇒ the stack starts at 0 Pa, character for
         !! character the pre-E3 arithmetic.  It is NOT enough that
         !! `p_top` be the zero array: under a cavity `p_top` is the ice
         !! load whether or not `in_eos` is set, so this gate is what
         !! keeps an existing cavity + EPBL run bit-identical.
      logical :: tke_diags = .false.
         !! Compute + store the per-column TKE budget terms (W/m^2).
         !! The column ledger closes to round-off — see the design
         !! doc §7; `test_ocean_epbl` asserts it.

      ! ---- Langmuir turbulence (LF17 wind-only path) ----
      ! One scalar La per column per MLD iteration, computed from u*
      ! and the current MLD guess (Li & Fox-Kemper 2017 statistical
      ! waves; no wave model, no Stokes arrays).  Enhancement applies
      ! to mstar only (Reichl & Li 2019).  NOTE: the LF17 branch uses
      ! NEITHER MOM6's MIN_LANGMUIR nor LA_DEPTH_MIN floors — those
      ! belong to the profile-averaging wave paths only.
      logical :: use_lt = .false.
         !! Master Langmuir switch (default off — bit-identity).
      integer :: lt_scheme = EPBL_LT_RESCALE
         !! Enhancement form: rescale (multiplicative) or additive.
      real(wp) :: lt_enhance_coef = 0.447_wp
         !! Enhancement coefficient (MOM6 LT_ENHANCE_COEF).
      real(wp) :: lt_enhance_exp = -1.33_wp
         !! La exponent (MOM6 LT_ENHANCE_EXP).
      real(wp) :: lt_max_enhance = 5.0_wp
         !! Cap on the multiplicative enhancement factor.
      real(wp) :: la_frac_hbl = 0.04_wp
         !! Stokes surface-layer-average depth as a fraction of the
         !! BLD (MOM6 LA_DEPTH_RATIO).
      real(wp) :: lt_lac1 = -0.87_wp
      real(wp) :: lt_lac2 = 0.0_wp
      real(wp) :: lt_lac3 = 0.0_wp
      real(wp) :: lt_lac4 = 0.95_wp
      real(wp) :: lt_lac5 = 0.95_wp
         !! Stability-modified Langmuir number coefficients (MOM6
         !! LT_MOD_LAC1..5: MLD/Ekman, MLD/Obukhov stable, unstable,
         !! Ekman/Obukhov stable, unstable).  All-zero => La_mod = La.

      ! ---- EOS hookup (shared handle from the eos slot) ----
      ! The energy weights use the SAME EOS the dyn-core runs.  We
      ! carry a value copy of the flat-POD `eos_t` (no
      ! allocatable) set once at configure from `ocean_state%eos`;
      ! it maps onto the device with the parent `this` for free and
      ! the per-column `eos_specvol_derivs(this%eos, ...)` call reads
      ! it from registers.  One source of truth — no private scalar
      ! copies that can drift from the dyn-core EOS.
      type(eos_t) :: eos

      ! ---- Persistent fields ----
      real(wp), allocatable :: f_centre(:, :)
         !! |f| at cell centres (1/s); filled by `set_f_centre`.
      real(wp), allocatable :: mld(:, :)
         !! Converged active-mixing-layer depth (m) from the last
         !! call — the next step's first guess (when
         !! `mld_use_prev_guess`) and the primary diagnostic.
      real(wp), allocatable :: b0(:, :)
         !! Surface buoyancy flux (m^2/s^3, > 0 stabilizing) from the last
         !! solve — the same `B0 = g·rho0·(dSV/dT·q_T + dSV/dS·q_S)` the
         !! mstar scaling uses, persisted so the Bodner (2023) MLE
         !! restratification can form its convective velocity scale
         !! `w*^3 = max(0,-b0)·mld`.  Zero until the first EPBL step.
      real(wp), allocatable :: kd_int(:, :, :)
         !! EPBL diapycnal diffusivity at interfaces (m^2/s),
         !! (nx, ny, nz+1); zero at bed (k=1) and surface (k=nz+1).
      real(wp), allocatable :: la(:, :)
         !! Turbulent Langmuir number from the last call (diagnostic;
         !! 0 where `use_lt` is off or the column is dry).

      ! ---- Per-step TKE budget diagnostics (W/m^2) ----
      ! Overwritten each `epbl_compute` (final MLD iteration).  The
      ! ledger: wind + conv + forcing - mixing - mech_decay -
      ! conv_decay = 0 exactly (forcing <= 0, all others >= 0).
      real(wp), allocatable :: tke_wind(:, :)
      real(wp), allocatable :: tke_conv(:, :)
      real(wp), allocatable :: tke_forcing(:, :)
      real(wp), allocatable :: tke_mixing(:, :)
      real(wp), allocatable :: tke_mech_decay(:, :)
      real(wp), allocatable :: tke_conv_decay(:, :)

      ! ---- Iteration-invariant column workspaces ----
      ! Filled once per call by the column prep sweep; read by every
      ! MLD iteration.  All (nx, ny, nz).  Everything else the sweep
      ! needs is carried as scalars (see the design doc D6) — no
      ! per-column work arrays, no NZ_STACK_MAX locals.
      type(scratch_3d_buffer_t) :: t0
         !! Pre-mixing layer temperature (degC).
      type(scratch_3d_buffer_t) :: s0
         !! Pre-mixing layer salinity (PSU).
      type(scratch_3d_buffer_t) :: dpe_t
         !! d(column PE)/d(T_k): rho0*h_k * p_mid * dSV_dT (J/m^2/degC).
      type(scratch_3d_buffer_t) :: dpe_s
         !! d(column PE)/d(S_k) (J/m^2/PSU).
      type(scratch_3d_buffer_t) :: dcolht_t
         !! d(column height)/d(T_k): rho0*h_k * dSV_dT (m/degC) —
         !! steric sensitivity for the gravity-wave radiation term.
      type(scratch_3d_buffer_t) :: dcolht_s
         !! d(column height)/d(S_k) (m/PSU).
      type(scratch_3d_buffer_t) :: ctke_sw
         !! (PR-21) Per-layer TKE cost (J/m^2, <= 0 for solar heating) of
         !! homogenising the penetrating shortwave absorbed IN that layer.
         !! Filled once per call by the prep sweep from the shared two-band
         !! transmission + the in-layer PE-cost shape `Phi(h/zeta)`; the
         !! surface layer's share folds into `ctke_sfc`, the sub-surface
         !! layers drain the sweep's TKE reservoirs.  Zero (never filled)
         !! unless `epbl_sw_ctke .and. sf%has_sw`.  Same shape / lifetime /
         !! device contract as `dcolht_s`.
      logical :: epbl_sw_ctke = .true.
         !! (PR-21) Charge the EPBL TKE ledger for penetrating shortwave
         !! (`&ocean_thermo_nml epbl_sw_ctke`).  Default `.true.`; inert at
         !! `sw_pen_frac = 0` (⇒ `sf%has_sw = .false.`) ⇒ bit-identical.
   contains
      procedure, non_overridable :: init => ocean_epbl_init
      procedure, non_overridable :: destroy => ocean_epbl_destroy
      procedure, non_overridable :: enter_data => ocean_epbl_enter_data
      procedure, non_overridable :: exit_data => ocean_epbl_exit_data
      procedure, non_overridable :: set_f_centre => ocean_epbl_set_f_centre
      procedure, non_overridable :: bytes => ocean_epbl_bytes
   end type ocean_epbl_t

contains

   ! =================================================================
   ! Lifecycle
   ! =================================================================

   subroutine ocean_epbl_init(this, grid, nz_ml)
      !! Allocate the persistent fields + column workspaces.  Always
      !! allocates (configure runs after init, so `enable` isn't
      !! known yet); the memory cost when off is the same 7-field
      !! footprint the vmix slot already pays.
      class(ocean_epbl_t), intent(inout) :: this
      type(hgrid_t), intent(in) :: grid
      integer, intent(in), optional :: nz_ml
      integer :: nx, ny, nz

      nx = grid%nx_total
      ny = grid%ny_total
      nz = 1
      if (present(nz_ml)) nz = nz_ml

      allocate (this%f_centre(nx, ny), source=0.0_wp)
      allocate (this%mld(nx, ny), source=0.0_wp)
      allocate (this%b0(nx, ny), source=0.0_wp)
      allocate (this%kd_int(nx, ny, nz + 1), source=0.0_wp)
      allocate (this%la(nx, ny), source=0.0_wp)
      allocate (this%tke_wind(nx, ny), source=0.0_wp)
      allocate (this%tke_conv(nx, ny), source=0.0_wp)
      allocate (this%tke_forcing(nx, ny), source=0.0_wp)
      allocate (this%tke_mixing(nx, ny), source=0.0_wp)
      allocate (this%tke_mech_decay(nx, ny), source=0.0_wp)
      allocate (this%tke_conv_decay(nx, ny), source=0.0_wp)
      call this%t0%init(nx, ny, nz, "epbl_t0")
      call this%s0%init(nx, ny, nz, "epbl_s0")
      call this%dpe_t%init(nx, ny, nz, "epbl_dpe_t")
      call this%dpe_s%init(nx, ny, nz, "epbl_dpe_s")
      call this%dcolht_t%init(nx, ny, nz, "epbl_dcolht_t")
      call this%dcolht_s%init(nx, ny, nz, "epbl_dcolht_s")
      call this%ctke_sw%init(nx, ny, nz, "epbl_ctke_sw")

      this%is_init = .true.
   end subroutine ocean_epbl_init

   subroutine ocean_epbl_destroy(this)
      class(ocean_epbl_t), intent(inout) :: this
      this%is_init = .false.
      if (allocated(this%f_centre)) deallocate (this%f_centre)
      if (allocated(this%mld)) deallocate (this%mld)
      if (allocated(this%b0)) deallocate (this%b0)
      if (allocated(this%kd_int)) deallocate (this%kd_int)
      if (allocated(this%la)) deallocate (this%la)
      if (allocated(this%tke_wind)) deallocate (this%tke_wind)
      if (allocated(this%tke_conv)) deallocate (this%tke_conv)
      if (allocated(this%tke_forcing)) deallocate (this%tke_forcing)
      if (allocated(this%tke_mixing)) deallocate (this%tke_mixing)
      if (allocated(this%tke_mech_decay)) deallocate (this%tke_mech_decay)
      if (allocated(this%tke_conv_decay)) deallocate (this%tke_conv_decay)
      call this%t0%destroy()
      call this%s0%destroy()
      call this%dpe_t%destroy()
      call this%dpe_s%destroy()
      call this%dcolht_t%destroy()
      call this%dcolht_s%destroy()
      call this%ctke_sw%destroy()
   end subroutine ocean_epbl_destroy

   subroutine ocean_epbl_enter_data(this)
      class(ocean_epbl_t), intent(inout) :: this
      select type (this)
      type is (ocean_epbl_t)
         call ocean_epbl_enter_data_impl(this)
      end select
   end subroutine ocean_epbl_enter_data

   subroutine ocean_epbl_enter_data_impl(this)
      type(ocean_epbl_t), intent(inout) :: this
      if (allocated(this%f_centre)) then
         !$acc enter data copyin(this%f_centre)
      end if
      if (allocated(this%mld)) then
         !$acc enter data copyin(this%mld)
      end if
      if (allocated(this%b0)) then
         !$acc enter data copyin(this%b0)
      end if
      if (allocated(this%kd_int)) then
         !$acc enter data copyin(this%kd_int)
      end if
      if (allocated(this%la)) then
         !$acc enter data copyin(this%la)
      end if
      if (allocated(this%tke_wind)) then
         !$acc enter data copyin(this%tke_wind)
      end if
      if (allocated(this%tke_conv)) then
         !$acc enter data copyin(this%tke_conv)
      end if
      if (allocated(this%tke_forcing)) then
         !$acc enter data copyin(this%tke_forcing)
      end if
      if (allocated(this%tke_mixing)) then
         !$acc enter data copyin(this%tke_mixing)
      end if
      if (allocated(this%tke_mech_decay)) then
         !$acc enter data copyin(this%tke_mech_decay)
      end if
      if (allocated(this%tke_conv_decay)) then
         !$acc enter data copyin(this%tke_conv_decay)
      end if
      call scratch_3d_buffer_enter_data_impl(this%t0)
      call scratch_3d_buffer_enter_data_impl(this%s0)
      call scratch_3d_buffer_enter_data_impl(this%dpe_t)
      call scratch_3d_buffer_enter_data_impl(this%dpe_s)
      call scratch_3d_buffer_enter_data_impl(this%dcolht_t)
      call scratch_3d_buffer_enter_data_impl(this%dcolht_s)
      call scratch_3d_buffer_enter_data_impl(this%ctke_sw)
   end subroutine ocean_epbl_enter_data_impl

   subroutine ocean_epbl_exit_data(this)
      class(ocean_epbl_t), intent(inout) :: this
      select type (this)
      type is (ocean_epbl_t)
         call ocean_epbl_exit_data_impl(this)
      end select
   end subroutine ocean_epbl_exit_data

   subroutine ocean_epbl_exit_data_impl(this)
      type(ocean_epbl_t), intent(inout) :: this
      call scratch_3d_buffer_exit_data_impl(this%ctke_sw)
      call scratch_3d_buffer_exit_data_impl(this%dcolht_s)
      call scratch_3d_buffer_exit_data_impl(this%dcolht_t)
      call scratch_3d_buffer_exit_data_impl(this%dpe_s)
      call scratch_3d_buffer_exit_data_impl(this%dpe_t)
      call scratch_3d_buffer_exit_data_impl(this%s0)
      call scratch_3d_buffer_exit_data_impl(this%t0)
      if (allocated(this%tke_conv_decay)) then
         !$acc exit data delete(this%tke_conv_decay)
      end if
      if (allocated(this%tke_mech_decay)) then
         !$acc exit data delete(this%tke_mech_decay)
      end if
      if (allocated(this%tke_mixing)) then
         !$acc exit data delete(this%tke_mixing)
      end if
      if (allocated(this%tke_forcing)) then
         !$acc exit data delete(this%tke_forcing)
      end if
      if (allocated(this%tke_conv)) then
         !$acc exit data delete(this%tke_conv)
      end if
      if (allocated(this%tke_wind)) then
         !$acc exit data delete(this%tke_wind)
      end if
      if (allocated(this%la)) then
         !$acc exit data delete(this%la)
      end if
      if (allocated(this%kd_int)) then
         !$acc exit data delete(this%kd_int)
      end if
      if (allocated(this%mld)) then
         !$acc exit data delete(this%mld)
      end if
      if (allocated(this%b0)) then
         !$acc exit data delete(this%b0)
      end if
      if (allocated(this%f_centre)) then
         !$acc exit data delete(this%f_centre)
      end if
   end subroutine ocean_epbl_exit_data_impl

   subroutine ocean_epbl_set_f_centre(this, grid, f_0, beta, y_ref)
      !! Fill `f_centre` with the beta-plane Coriolis magnitude at
      !! cell centres: |f_0 + beta*(y - y_ref)|.  Mirror of
      !! `coriolis_adv_set_beta_plane` (which fills corners).  Call
      !! after `init`, before `enter_data`.
      class(ocean_epbl_t), intent(inout) :: this
      type(hgrid_t), intent(in) :: grid
      real(wp), intent(in) :: f_0, beta, y_ref
      integer :: i, j, ng
      real(wp) :: y

      ng = grid%nghost
      do j = 1, size(this%f_centre, 2)
         y = (real(j + grid%j_offset_global - ng, wp) - 0.5_wp)*grid%dy
         do i = 1, size(this%f_centre, 1)
            this%f_centre(i, j) = abs(f_0 + beta*(y - y_ref))
         end do
      end do
   end subroutine ocean_epbl_set_f_centre

   ! =================================================================
   ! Parsers (host-side, configure time)
   ! =================================================================

   pure function parse_epbl_mstar_scheme(name) result(tag)
      character(len=*), intent(in) :: name
      integer :: tag
      select case (trim(name))
      case ("constant")
         tag = EPBL_MSTAR_CONSTANT
      case ("om4", "OM4")
         tag = EPBL_MSTAR_OM4
      case ("rh18", "RH18", "reichl_h18")
         tag = EPBL_MSTAR_RH18
      case default
         tag = -1
      end select
   end function parse_epbl_mstar_scheme

   pure function parse_epbl_vstar_scheme(name) result(tag)
      character(len=*), intent(in) :: name
      integer :: tag
      select case (trim(name))
      case ("cube_root", "cube_root_tke")
         tag = EPBL_VSTAR_CUBE_ROOT
      case ("rh18", "RH18", "reichl_h18")
         tag = EPBL_VSTAR_RH18
      case default
         tag = -1
      end select
   end function parse_epbl_vstar_scheme

   pure function parse_epbl_combine(name) result(tag)
      character(len=*), intent(in) :: name
      integer :: tag
      select case (trim(name))
      case ("add", "additive")
         tag = EPBL_COMBINE_ADD
      case ("max")
         tag = EPBL_COMBINE_MAX
      case default
         tag = -1
      end select
   end function parse_epbl_combine

   pure function parse_epbl_lt_scheme(name) result(tag)
      character(len=*), intent(in) :: name
      integer :: tag
      select case (trim(name))
      case ("rescale")
         tag = EPBL_LT_RESCALE
      case ("additive", "add")
         tag = EPBL_LT_ADDITIVE
      case default
         tag = -1
      end select
   end function parse_epbl_lt_scheme

   ! =================================================================
   ! Pure helpers (device-callable; unit-tested directly)
   ! =================================================================

   pure subroutine epbl_find_mstar(scheme, mstar_const, mstar_cap, &
                                   mstar_coef1, c_ek, mstar_conv_adj, &
                                   cn1, cn2, cn3, cs1, cs2, &
                                   b0, ustar, bld, absf, mstar)
      !! mstar = (mechanical TKE available for entrainment) / u*^3.
      !! Schemes: constant; OM4 Ekman/Obukhov balance; RH18 fits.
      !! All followed by the optional convective reduction
      !! (mstar_conv_adj in [0,1]; the u*=0 corner multiplies by
      !! (1 - adj), matching the reference behaviour).
      !$acc routine seq
      integer, intent(in) :: scheme
      real(wp), intent(in) :: mstar_const, mstar_cap
      real(wp), intent(in) :: mstar_coef1, c_ek, mstar_conv_adj
      real(wp), intent(in) :: cn1, cn2, cn3, cs1, cs2
      real(wp), intent(in) :: b0
         !! Surface buoyancy flux (m^2/s^3); > 0 stabilizing.
      real(wp), intent(in) :: ustar
         !! Surface friction velocity (m/s), already floored.
      real(wp), intent(in) :: bld
         !! Boundary-layer depth guess (m).
      real(wp), intent(in) :: absf
         !! |Coriolis| (1/s), possibly omega-blended.
      real(wp), intent(out) :: mstar

      real(wp) :: mstar_n, mstar_s, msn_term, absf_floor
      real(wp) :: mscr_t1, mscr_t2

      absf_floor = max(absf, 1.0e-20_wp)
      select case (scheme)
      case (EPBL_MSTAR_OM4)
         mstar_s = mstar_coef1*sqrt(max(0.0_wp, b0)/(ustar*ustar*absf_floor))
         mstar_n = 0.0_wp
         if (ustar > absf_floor*bld) then
            mstar_n = c_ek*log(ustar/(absf_floor*max(bld, 1.0e-10_wp)))
         end if
         mstar = max(mstar_s, min(1.25_wp, mstar_n))
         if (mstar_cap > 0.0_wp) mstar = min(mstar_cap, mstar)
      case (EPBL_MSTAR_RH18)
         msn_term = cn2*exp(cn3*bld*absf/ustar)
         mstar_n = cn1*msn_term/(1.0_wp + msn_term)
         mstar_s = cs1*(max(0.0_wp, b0)**2*bld/(ustar**5*absf_floor))**cs2
         mstar = mstar_n + mstar_s
         if (mstar_cap > 0.0_wp) mstar = min(mstar_cap, mstar)
      case default  ! EPBL_MSTAR_CONSTANT
         mstar = mstar_const
      end select

      ! Convective reduction (no-op at the default adj = 0).
      if (mstar_conv_adj > 0.0_wp) then
         mscr_t1 = -bld*min(0.0_wp, b0)
         mscr_t2 = 2.0_wp*mstar*ustar**3
         if (mscr_t2 > 0.0_wp) then
            mstar = mstar*((1.0_wp - mstar_conv_adj)*mscr_t1 + mscr_t2)/ &
                    (mscr_t1 + mscr_t2)
         else
            mstar = mstar*(1.0_wp - mstar_conv_adj)
         end if
      end if
   end subroutine epbl_find_mstar

   pure function epbl_mixlen_shape(z_depth, mld_guess, translay_scale, &
                                   mixlen_exponent, shaped) result(shape_val)
      !! Mixing-length shape factor in [translay_scale, 1]: 1 at the
      !! surface, decaying to the transition-layer floor at the MLD.
      !! `shaped = .false.` (no MLD iteration) returns 1.
      !$acc routine seq
      real(wp), intent(in) :: z_depth
         !! Unconditional interface depth below the surface (m).
      real(wp), intent(in) :: mld_guess, translay_scale, mixlen_exponent
      logical, intent(in) :: shaped
      real(wp) :: shape_val
      real(wp) :: fr

      if ((.not. shaped) .or. translay_scale >= 1.0_wp .or. &
          translay_scale < 0.0_wp .or. mld_guess <= 0.0_wp) then
         shape_val = 1.0_wp
      else
         fr = max(0.0_wp, (mld_guess - z_depth)/mld_guess)
         if (mixlen_exponent == 2.0_wp) then
            shape_val = translay_scale + (1.0_wp - translay_scale)*fr*fr
         else
            shape_val = translay_scale + (1.0_wp - translay_scale)*fr**mixlen_exponent
         end if
      end if
   end function epbl_mixlen_shape

   pure subroutine epbl_lf17_wave_state(ustar_w, rho_ocn, u10, ustokes, kphil)
      !! LF17 statistical wave state from the water-side friction
      !! velocity alone: COARE 3.5 fixed-point inversion u* -> U10
      !! (Edson et al. 2013), then the Pierson-Moskowitz-based
      !! surface Stokes drift and Phillips peak wavenumber (Li &
      !! Fox-Kemper 2017; Breivik et al. 2016).  BLD-independent —
      !! call once per column, outside the MLD iteration.
      !$acc routine seq
      real(wp), intent(in) :: ustar_w
         !! Water-side u* (m/s), > 0 (caller floors it).
      real(wp), intent(in) :: rho_ocn
         !! Seawater reference density (kg/m^3).
      real(wp), intent(out) :: u10
         !! 10-m wind speed (m/s).
      real(wp), intent(out) :: ustokes
         !! Surface Stokes drift (m/s).
      real(wp), intent(out) :: kphil
         !! Phillips-spectrum peak wavenumber (1/m).

      real(wp) :: ustar_air, z0_smooth, z0w, alpha_ch, u10_new
      real(wp) :: hm0, f_mean, vstokes
      integer :: itt
      logical :: converged

      ustar_air = ustar_w*sqrt(rho_ocn/LT_RHO_AIR)
      z0_smooth = 0.11_wp*LT_NU_AIR/ustar_air
      u10 = ustar_air*sqrt(1000.0_wp)
      converged = .false.
      do itt = 1, 20
         alpha_ch = min(LT_CHARNOCK_MIN, LT_CHARNOCK_SLOPE*u10 + LT_CHARNOCK_ICPT)
         ! alpha can go negative below U10 ~ 3 m/s; clamp z0 positive
         ! before the log.
         z0w = max(z0_smooth + alpha_ch*ustar_air**2/GRAVITY, 1.0e-10_wp)
         u10_new = ustar_air*log(10.0_wp/z0w)/LT_VONKAR_WAVES
         if (abs(u10_new - u10) <= 1.0e-3_wp*u10) then
            u10 = u10_new
            converged = .true.
            exit
         end if
         u10 = u10_new
      end do
      if (.not. converged) u10 = 25.82_wp*ustar_air

      ustokes = LT_US_TO_U10*u10
      hm0 = LT_SWH_FROM_U10SQ*u10*u10
      f_mean = LT_FM_INTO_FP*0.877_wp*GRAVITY/ &
               (2.0_wp*LT_PI*LT_U19P5_TO_U10*u10)
      vstokes = 0.125_wp*LT_PI*LT_R_LOSS*f_mean*hm0*hm0
      kphil = 0.176_wp*ustokes/max(vstokes, 1.0e-30_wp)
   end subroutine epbl_lf17_wave_state

   pure function epbl_lf17_la(ustar_w, zsl, ustokes, kphil) result(la)
      !! Turbulent Langmuir number La = sqrt(u*/u_s_SL): the Stokes
      !! drift averaged over the surface layer of thickness `zsl`
      !! under the Phillips spectrum, in the singularity-safe form
      !! (Breivik et al. 2016 with Webb & Fox-Kemper 2015 directional
      !! spreading).  No MIN_LANGMUIR / LA_DEPTH_MIN floors — those
      !! belong to MOM6's profile-averaging wave paths, not LF17.
      !$acc routine seq
      real(wp), intent(in) :: ustar_w
         !! Water-side u* (m/s).
      real(wp), intent(in) :: zsl
         !! Surface-layer average depth (m) = la_frac_hbl * BLD.
      real(wp), intent(in) :: ustokes, kphil
         !! From `epbl_lf17_wave_state`.
      real(wp) :: la

      real(wp) :: xkz, root2kz, r1, r3, r5, ustokes_sl

      xkz = kphil*zsl
      root2kz = sqrt(2.0_wp*xkz)
      r1 = (0.302_wp - 1.68_wp*xkz)*one_m_exp_x(2.0_wp*xkz)
      r3 = (0.1264_wp + 0.64_wp*xkz)*one_m_exp_x(5.12_wp*xkz)
      if (root2kz > 1.0e-3_wp) then
         r5 = sqrt(LT_PI)*(root2kz*(-0.84_wp*erfc(root2kz) + &
                                    0.2_wp*erfc(1.6_wp*root2kz)) + &
                           0.1182_wp*(erfc(1.6_wp*root2kz) - erfc(root2kz))/root2kz)
      else
         r5 = -0.64_wp*sqrt(LT_PI)*root2kz + &
              (-0.14184_wp + 1.0839648_wp*root2kz*root2kz)
      end if
      ! Floor is a pure numerical guard (UStokes_sl -> 0+ for very
      ! deep surface layers); La then goes huge and the enhancement
      ! vanishes, which is the correct limit.
      ustokes_sl = max(ustokes*(0.715_wp + r1 + r3 + r5), 1.0e-10_wp)
      la = sqrt(ustar_w/ustokes_sl)
   end function epbl_lf17_la

   pure function one_m_exp_x(x) result(f)
      !! (1 - exp(-x)) / x, Taylor-safe at small x.
      !$acc routine seq
      real(wp), intent(in) :: x
      real(wp) :: f
      if (x < 1.0e-4_wp) then
         f = 1.0_wp - x*(0.5_wp - x/6.0_wp)
      else
         f = (1.0_wp - exp(-x))/x
      end if
   end function one_m_exp_x

   pure subroutine epbl_lt_enhance(scheme, coef, expo, max_enh, vonkar, &
                                   lac1, lac2, lac3, lac4, lac5, &
                                   la, b0, ustar, bld, absf, mstar)
      !! Apply the Langmuir enhancement to mstar (Reichl & Li 2019;
      !! Li et al. 2016 stability modification).  The modified
      !! Langmuir number folds the boundary-layer stability regime in
      !! via Ekman / Obukhov / MLD length-scale ratios, split by the
      !! sign of the surface buoyancy flux; all-zero lac coefficients
      !! give La_mod = La exactly.
      !$acc routine seq
      integer, intent(in) :: scheme
      real(wp), intent(in) :: coef, expo, max_enh, vonkar
      real(wp), intent(in) :: lac1, lac2, lac3, lac4, lac5
      real(wp), intent(in) :: la
         !! Raw turbulent Langmuir number (> 0).
      real(wp), intent(in) :: b0
         !! Surface buoyancy flux (m^2/s^3); > 0 stabilizing.
      real(wp), intent(in) :: ustar, bld, absf
      real(wp), intent(inout) :: mstar

      real(wp) :: mld_ek, ek_ob, mld_ob, la_mod, enh

      mld_ek = bld*absf/ustar
      ek_ob = abs(b0*vonkar)/(max(absf, 1.0e-20_wp)*ustar*ustar)
      mld_ob = abs(bld*b0*vonkar)/ustar**3
      if (b0 >= 0.0_wp) then
         la_mod = la*((1.0_wp + max(-0.5_wp, lac1*mld_ek)) + &
                      lac4*ek_ob + lac2*mld_ob)
      else
         la_mod = la*((1.0_wp + max(-0.5_wp, lac1*mld_ek)) + &
                      lac5*ek_ob + lac3*mld_ob)
      end if
      la_mod = max(la_mod, 1.0e-10_wp)

      if (scheme == EPBL_LT_ADDITIVE) then
         mstar = mstar + coef*la_mod**expo
      else
         enh = min(max_enh, 1.0_wp + coef*la_mod**expo)
         mstar = mstar*enh
      end if
   end subroutine epbl_lt_enhance

   ! =================================================================
   ! Main compute
   ! =================================================================

   pure subroutine epbl_compute(grid, this, ms, ss, dt, sf)
      !! Run EPBL over the domain: fill `this%kd_int` (interface
      !! diffusivity) and `this%mld`.  Call at thermo cadence with
      !! the thermo dt.  Outer shim: dereferences the tracer-registry
      !! hTr arrays + the 2D Q_heat/Q_salt forcing fields on the host
      !! (array-of-DT and allocatable indirection blocks NVHPC device
      !! codegen), then forwards to the column kernel which reads
      !! q_T_kin(i,j) / q_S_kin(i,j) per column inside the DC.
      !!
      !! `sf` (surface-flux slot) is REQUIRED — EPBL computes B_0 from
      !! the per-column kinematic fluxes.  The dispatch in
      !! `vmix_apply_in_stage` already errors before reaching here when
      !! `sf` is absent and EPBL is active.
      type(hgrid_t), intent(in) :: grid
      type(ocean_epbl_t), intent(inout) :: this
      type(multilayer_state_t), intent(in) :: ms
      type(ocean_surface_stress_t), intent(in) :: ss
      real(wp), intent(in) :: dt
      type(ocean_surface_flux_t), intent(in) :: sf

      integer :: nx, ny
      logical :: sw_ctke_active

      if (ms%idx_temperature <= 0 .or. ms%idx_salinity <= 0) return

      nx = grid%nx_total
      ny = grid%ny_total

      ! Kinematic scale factors (column-invariant multipliers):
      !   q_T_kin(i,j) = Q_heat(i,j) / (rho0 · cp)  [degC·m/s]
      !   q_S_kin(i,j) = Q_salt(i,j) / rho0           [PSU·m/s]
      ! The column kernel reads these at (i,j) inside the DC so that
      ! spatially-varying forcing (Area-A3/A4) is handled correctly.
      !
      ! (PR-21) Penetrating-SW TKE ledger: active only when the ledger is
      ! enabled AND penetrating SW is on.  The irradiance source is
      ! selected HOST-SIDE (`sf%sw_from_qsw`); the conditionally-allocated
      ! `q_sw` is passed only on the guarded branch (validate_config
      ! forces enable_components when sw_source="q_sw", making it total).
      ! When inactive, the source array is irrelevant (kernel never reads
      ! it), so the legacy `Q_heat` argument is passed on both branches.
      sw_ctke_active = this%epbl_sw_ctke .and. sf%has_sw
      if (sw_ctke_active .and. sf%sw_from_qsw) then
         call epbl_column_kernel(grid, this, ms, &
                                 ms%tracers(ms%idx_temperature)%hTr, &
                                 ms%tracers(ms%idx_salinity)%hTr, &
                                 ss, dt, &
                                 sf%Q_heat, 1.0_wp/(this%rho0*sf%cp), &
                                 sf%Q_salt, 1.0_wp/this%rho0, &
                                 sf%q_sw, sw_ctke_active, sf%sw_pen_frac, &
                                 sf%sw_band_ratio, sf%sw_zeta1, sf%sw_zeta2, &
                                 ms%wet_mask, this%in_eos, nx, ny)
      else
         call epbl_column_kernel(grid, this, ms, &
                                 ms%tracers(ms%idx_temperature)%hTr, &
                                 ms%tracers(ms%idx_salinity)%hTr, &
                                 ss, dt, &
                                 sf%Q_heat, 1.0_wp/(this%rho0*sf%cp), &
                                 sf%Q_salt, 1.0_wp/this%rho0, &
                                 sf%Q_heat, sw_ctke_active, sf%sw_pen_frac, &
                                 sf%sw_band_ratio, sf%sw_zeta1, sf%sw_zeta2, &
                                 ms%wet_mask, this%in_eos, nx, ny)
      end if
   end subroutine epbl_compute

   pure subroutine epbl_column_kernel(grid, this, ms, hT, hS, ss, dt, &
                                      Q_heat_field, inv_rho0_cp, &
                                      Q_salt_field, inv_rho0, &
                                      sw_src_field, sw_ctke_active, sw_pen_frac, &
                                      sw_R, sw_zeta1, sw_zeta2, wet_mask_field, &
                                      p_top_in_eos, nx_arg, ny_arg)
      !! Per-column EPBL solve.  One `do concurrent (j, i)` with the
      !! serial work in k inside (j -> i -> k ordering); ALL sweep
      !! state is carried in scalars (design doc D6) — the only
      !! column arrays are the six iteration-invariant workspaces
      !! filled by the prep sweep.
      !!
      !! Kinematic surface fluxes are read per-column inside the DC:
      !!   q_t_kin = Q_heat_field(i,j) / (rho0 · cp)  [degC·m/s]
      !!   q_s_kin = Q_salt_field(i,j) / rho0           [PSU·m/s]
      !! For the constant-fill default both fields are uniform, giving
      !! arithmetic identical to the former scalar-broadcast path.
      !!
      !! Algorithm:
      !!   prep:  T0, S0 and the pressure-weighted PE / steric
      !!          sensitivities per layer (downward pressure sum).
      !!   outer: MLD root-find (false position / bisection) —
      !!          mstar and the mixing-length shape depend on MLD.
      !!   sweep: interfaces Ki = nz..2 downward.  Decay mech TKE
      !!          across the layer above; rotation-reduce the
      !!          convective reservoir; closed-form energy solve for
      !!          the largest affordable Kd (the gravity-wave
      !!          column-height correction folds into PEc_core);
      !!          advance the embedded tridiagonal forward
      !!          elimination (hp_a / dX_to_dPE_a / Th_a recursions).
      type(hgrid_t), intent(in) :: grid
      type(ocean_epbl_t), intent(inout) :: this
      type(multilayer_state_t), intent(in) :: ms
      ! assumed-shape-ok: tracer registry outer-shim — caller host-dereferences
      ! ms%tracers(idx)%hTr before passing; size varies per tracer slot
      ! (see CLAUDE.md "outer-shim + flat-impl" pattern); thermo cadence.
      real(wp), intent(in) :: hT(:, :, :)
         !! Temperature tracer hTr (degC*m), host-dereferenced.
      real(wp), intent(in) :: hS(:, :, :)  ! assumed-shape-ok: tracer registry outer-shim; thermo cadence
         !! Salinity tracer hTr (PSU*m), host-dereferenced.
      type(ocean_surface_stress_t), intent(in) :: ss
      real(wp), intent(in) :: dt
      real(wp), intent(in) :: Q_heat_field(:, :)   ! assumed-shape-ok: outer-shim pass; thermo cadence
         !! 2D heat-flux field (W/m²), host-dereferenced from sf%Q_heat.
      real(wp), intent(in) :: inv_rho0_cp
         !! Precomputed 1/(rho0·cp) multiplier.
      real(wp), intent(in) :: Q_salt_field(:, :)   ! assumed-shape-ok: outer-shim pass; thermo cadence
         !! 2D salt-flux field (kg/m²/s), host-dereferenced from sf%Q_salt.
      real(wp), intent(in) :: inv_rho0
         !! Precomputed 1/rho0 multiplier.
      real(wp), intent(in) :: sw_src_field(:, :)   ! assumed-shape-ok: outer-shim pass; thermo cadence
         !! (PR-21) 2D irradiance source (W/m², >= 0 on the q_sw path),
         !! host-selected from sf%Q_heat or sf%q_sw.  Read only when
         !! `sw_ctke_active`; otherwise the legacy sf%Q_heat is passed and
         !! ignored.
      logical, intent(in) :: sw_ctke_active
         !! (PR-21) Charge the TKE ledger for penetrating SW (host-side
         !! `epbl_sw_ctke .and. sf%has_sw`).  False ⇒ the surface
         !! energetics + sweep run the unmodified legacy lines.
      real(wp), intent(in) :: sw_pen_frac, sw_R, sw_zeta1, sw_zeta2
         !! (PR-21) Two-band SW parameters (from sf), by value.
      real(wp), intent(in) :: wet_mask_field(:, :)   ! assumed-shape-ok: outer-shim pass; thermo cadence
         !! (PR-21) Wet mask — mirrors the deposition kernel's I0 gate so
         !! the ledger and the tracer field account the same heat.
      logical, intent(in) :: p_top_in_eos
         !! (E3) Seed the column pressure stack at `ms%p_top(i,j)` rather
         !! than at 0 Pa — `&ocean_psurf_nml in_eos`, by value.  `.false.`
         !! ⇒ the pre-E3 arithmetic, character for character.
      integer, intent(in) :: nx_arg, ny_arg
         !! Grid extents — used to bounds-check Q_* indexing.

      integer :: i, j, nx, ny, nz
      ! prep locals
      integer :: k
      real(wp) :: q_t_kin, q_s_kin
      real(wp) :: hk, hk_eff, inv_h, t0k, s0k, dmass, dpres, p_mid, pres
      real(wp) :: dsv_dt_k, dsv_ds_k, dsv_dt_sfc, dsv_ds_sfc, h_sum
      ! forcing / environment locals
      real(wp) :: ustar, absf, idecay, mech_in
      real(wp) :: b0, ctke_sfc
      ! MLD iteration locals
      integer :: obl_it, n_its
      real(wp) :: min_mld, max_mld, mld_guess, mld_found
      real(wp) :: dmld_min, dmld_max
      logical :: have_min, have_max
      real(wp) :: mstar_val, mech_tke, conv_perel, forcing_clip
      ! sweep carried scalars
      integer :: ki, ka, kb
      real(wp) :: htot, z_int, pres_int, mld_output
      logical :: sfc_connected, sfc_disconnect
      real(wp) :: hp_a, dpe_t_a, dpe_s_a, dch_t_a, dch_s_a
      real(wp) :: th_a, sh_a, te_lag, se_lag, kddt_prev, kddt_cur
      real(wp) :: exp_kh, nstar_fc, tot_tke, dt_h, tke_here, vstar
      real(wp) :: h_ka, h_kb, hb_hs, shape_fn, hbs, mixlen, kd_g0
      real(wp) :: hp_b, th_b, sh_b, hps, bdt1, dt_c, ds_c
      real(wp) :: pec_core, colht_core, dkddt, pe_max
      real(wp) :: kd_val, tke_used, frac_bl, pe_g0, dpe_conv
      real(wp) :: b1, c1, r_reduc, te_new, se_new, surf_scale
      ! Langmuir (LF17) wave state + Langmuir number
      real(wp) :: lt_u10, lt_ustokes, lt_kphil, la_val
      ! TKE budget ledger (final iteration's values survive)
      real(wp) :: d_wind, d_conv, d_forcing, d_mixing, d_mdecay, d_cdecay
      ! (PR-21) penetrating-SW TKE ledger locals
      real(wp) :: i0_col, d_top_sw, d_bot_sw, heat_sw1, heat_sw2, q_nonpen_kin
      real(wp) :: ctke_sw_kb, r_sw

      nx = grid%nx_total
      ny = grid%ny_total
      nz = ms%nz_ml

      do concurrent(j=1:ny, i=1:nx) &
         local(k, hk, hk_eff, inv_h, t0k, s0k, dmass, dpres, p_mid, pres, &
               dsv_dt_k, dsv_ds_k, dsv_dt_sfc, dsv_ds_sfc, h_sum, &
               ustar, absf, idecay, mech_in, b0, ctke_sfc, &
               obl_it, n_its, min_mld, max_mld, mld_guess, mld_found, &
               dmld_min, dmld_max, have_min, have_max, &
               mstar_val, mech_tke, conv_perel, forcing_clip, &
               ki, ka, kb, htot, z_int, pres_int, mld_output, &
               sfc_connected, sfc_disconnect, &
               hp_a, dpe_t_a, dpe_s_a, dch_t_a, dch_s_a, &
               th_a, sh_a, te_lag, se_lag, kddt_prev, kddt_cur, &
               exp_kh, nstar_fc, tot_tke, dt_h, tke_here, vstar, &
               h_ka, h_kb, hb_hs, shape_fn, hbs, mixlen, kd_g0, &
               hp_b, th_b, sh_b, hps, bdt1, dt_c, ds_c, &
               pec_core, colht_core, dkddt, pe_max, &
               kd_val, tke_used, frac_bl, pe_g0, dpe_conv, &
               b1, c1, r_reduc, te_new, se_new, surf_scale, &
               lt_u10, lt_ustokes, lt_kphil, la_val, &
               d_wind, d_conv, d_forcing, d_mixing, d_mdecay, d_cdecay, &
               q_t_kin, q_s_kin, &
               i0_col, d_top_sw, d_bot_sw, heat_sw1, heat_sw2, q_nonpen_kin, &
               ctke_sw_kb, r_sw)

         ! ---- Column prep: T0/S0 + PE/steric weights (downward) ----
         ! (E3) The top of this column.  0 Pa at a free surface; the
         ! ice-shelf load + surface pressure `ms%p_top(i,j)` under a lid,
         ! when `&ocean_psurf_nml in_eos` is set.  Everything below
         ! accumulates `g*rho_0*h` downward from here, so `p_mid` is a
         ! true per-layer hydrostatic pressure either way — which is the
         ! test the `p_top` seam contract applies to a joining builder.
         ! It reaches BOTH consumers of the stack (the in-situ EOS
         ! argument and the PE weight `dmass*p_mid*dsv`), because they
         ! are the same pressure: the load a layer's centre of mass has
         ! to lift, ice included.
         pres = 0.0_wp
         if (p_top_in_eos) pres = ms%p_top(i, j)
         h_sum = 0.0_wp
         dsv_dt_sfc = 0.0_wp
         dsv_ds_sfc = 0.0_wp
         ! (PR-21) penetrating-SW column irradiance + running depth of the
         ! layer TOP below the free surface (0 at k=nz, grows downward to
         ! the opaque bed at k=1).  `i0_col` mirrors the deposition
         ! kernel's `I0 = sw_pen_frac·sw_src·wet_mask` exactly.
         if (sw_ctke_active) then
            i0_col = sw_pen_frac*sw_src_field(i, j)*wet_mask_field(i, j)
         else
            i0_col = 0.0_wp
         end if
         d_top_sw = 0.0_wp
         do k = nz, 1, -1
            hk = ms%h_layer(i, j, k)
            if (hk > 0.0_wp) then
               inv_h = 1.0_wp/(hk + H_NEGLECT)
               t0k = hT(i, j, k)*inv_h
               s0k = hS(i, j, k)*inv_h
            else
               t0k = 0.0_wp
               s0k = 0.0_wp
            end if
            dmass = this%rho0*hk
            dpres = GRAVITY*dmass
            p_mid = pres + 0.5_wp*dpres
            call eos_specvol_derivs(this%eos, t0k, s0k, p_mid, dsv_dt_k, dsv_ds_k)
            this%t0%data(i, j, k) = t0k
            this%s0%data(i, j, k) = s0k
            this%dpe_t%data(i, j, k) = dmass*p_mid*dsv_dt_k
            this%dpe_s%data(i, j, k) = dmass*p_mid*dsv_ds_k
            this%dcolht_t%data(i, j, k) = dmass*dsv_dt_k
            this%dcolht_s%data(i, j, k) = dmass*dsv_ds_k
            ! (PR-21) Per-layer penetrating-SW TKE cost.  Absorbed heat
            ! per band (degC·m) is the two-band difference form with an
            ! opaque bed at k = k_bot — the first LIVE layer counting up,
            ! `1` off z_fixed; the inert bed fillers below it absorb (and
            ! cost) nothing — (identical to the deposition kernel, so
            ! Σ_k Σ_n heat = I0·dt/(rho0·cp) exactly).  The in-layer PE
            ! cost of homogenising that exponentially-distributed heating
            ! is `Phi(h/zeta) <= 1` of the skin cost; the skin identity
            ! `rho0²·h·dsv_dt ≡ rho0·dcolht_t` makes this division-free.
            if (sw_ctke_active) then
               d_bot_sw = d_top_sw + hk
               if (k > ms%k_bot(i, j)) then
                  heat_sw1 = i0_col*sw_R*inv_rho0_cp*dt* &
                             (exp(-d_top_sw/sw_zeta1) - exp(-d_bot_sw/sw_zeta1))
                  heat_sw2 = i0_col*(1.0_wp - sw_R)*inv_rho0_cp*dt* &
                             (exp(-d_top_sw/sw_zeta2) - exp(-d_bot_sw/sw_zeta2))
               else
                  ! opaque bed: absorb the whole remaining irradiance.
                  heat_sw1 = i0_col*sw_R*inv_rho0_cp*dt*exp(-d_top_sw/sw_zeta1)
                  heat_sw2 = i0_col*(1.0_wp - sw_R)*inv_rho0_cp*dt*exp(-d_top_sw/sw_zeta2)
               end if
               this%ctke_sw%data(i, j, k) = &
                  -0.5_wp*GRAVITY*this%rho0*this%dcolht_t%data(i, j, k)* &
                  (heat_sw1*sw_pe_cost_shape(hk/sw_zeta1) + &
                   heat_sw2*sw_pe_cost_shape(hk/sw_zeta2))
               ! Below the opaque bed row: nothing arrives.  `k_bot ≡ 1`
               ! off z_fixed, so this never fires there (bit-identical).
               if (k < ms%k_bot(i, j)) this%ctke_sw%data(i, j, k) = 0.0_wp
               d_top_sw = d_bot_sw
            end if
            if (k == nz) then
               dsv_dt_sfc = dsv_dt_k
               dsv_ds_sfc = dsv_ds_k
            end if
            pres = pres + dpres
            h_sum = h_sum + hk
         end do
         h_sum = h_sum + H_NEGLECT

         ! ---- Surface forcing energetics ----
         ! Kinematic fluxes at this column:
         !   q_T_kin = Q_heat(i,j) / (rho0·cp)  [degC·m/s]
         !   q_S_kin = Q_salt(i,j) / rho0         [PSU·m/s]
         q_t_kin = inv_rho0_cp*Q_heat_field(i, j)
         q_s_kin = inv_rho0*Q_salt_field(i, j)
         ! B0 = g rho0 (dSV_dT q_T + dSV_dS q_S); > 0 stabilizing.
         ! B0 drives mstar / Langmuir (surface-buoyancy scaling); it uses
         ! the FULL net heat flux, unchanged by this PR (MOM6 does the
         ! same — penetrating SW enters ONLY through the per-layer TKE
         ! ledger, not the mstar buoyancy scale).
         b0 = GRAVITY*this%rho0*(dsv_dt_sfc*q_t_kin + dsv_ds_sfc*q_s_kin)
         this%b0(i, j) = b0   ! persist for the Bodner MLE convective velocity scale
         ! cTKE_sfc: PE released (> 0) / required (< 0) to homogenize
         ! the freshly applied SKIN fluxes through the surface layer.
         ! (PR-21) When penetrating SW is charged to the ledger, the skin
         ! carries only the NON-penetrating heat `q_nonpen = q_heat - I0`;
         ! the SW absorbed within the surface layer is the separately
         ! computed `ctke_sw(nz)` (Phi-weighted, <= the all-skin charge).
         ! I0 = 0 ⇒ q_nonpen = q_heat and ctke_sw(nz) = 0 ⇒ the else
         ! branch is character-for-character the legacy line.
         if (sw_ctke_active) then
            q_nonpen_kin = inv_rho0_cp*(Q_heat_field(i, j) - i0_col)
            ctke_sfc = -0.5_wp*GRAVITY*this%rho0**2*ms%h_layer(i, j, nz)* &
                       (q_nonpen_kin*dt*dsv_dt_sfc + q_s_kin*dt*dsv_ds_sfc) &
                       + this%ctke_sw%data(i, j, nz)
         else
            ctke_sfc = -0.5_wp*GRAVITY*this%rho0**2*ms%h_layer(i, j, nz)* &
                       (q_t_kin*dt*dsv_dt_sfc + q_s_kin*dt*dsv_ds_sfc)
         end if

         ! PR-12 dedup: |tau| at cell centres is a shared field
         ! (ocean_surface_stress_set_derived) — the inner sqrt(tau_xc^2 +
         ! tau_yc^2) below IS stress_mag, computed with the identical FP
         ! op order, so this substitution is bit-identical (§7.5).
         ! Phase 4b: `stress_shelf` adds the ICE-SHELF base stress, which
         ! is not in `tau` (the cover mask zeroes the wind there).  The
         ! supports are disjoint, the field is always allocated, and it
         ! is the zero array without a cavity — `x + 0.0` is `x`.  See
         ! the contract in `rdb_ocean_surface_stress`.
         ustar = max(sqrt((ss%stress_mag(i, j) + ss%stress_shelf(i, j))/ &
                          this%rho0), this%ustar_min)
         absf = sqrt((1.0_wp - this%omega_frac)*this%f_centre(i, j)**2 + &
                     this%omega_frac*4.0_wp*this%omega**2)
         idecay = this%tke_decay*absf/ustar
         mech_in = dt*this%rho0*ustar**3

         ! LF17 wave state is BLD-independent: compute once per
         ! column; only the surface-layer average inside the MLD
         ! iteration depends on the guess.
         la_val = 0.0_wp
         lt_u10 = 0.0_wp
         lt_ustokes = 0.0_wp
         lt_kphil = 0.0_wp
         if (this%use_lt) then
            call epbl_lf17_wave_state(ustar, this%rho0, lt_u10, lt_ustokes, lt_kphil)
         end if

         d_wind = 0.0_wp
         d_conv = 0.0_wp
         d_forcing = 0.0_wp
         d_mixing = 0.0_wp
         d_mdecay = 0.0_wp
         d_cdecay = 0.0_wp
         mld_found = 0.0_wp

         if (ms%wet_mask(i, j) <= 0.0_wp .or. h_sum <= 2.0_wp*H_NEGLECT) then
            ! Dry / land column: no mixing.
            do k = 1, nz + 1
               this%kd_int(i, j, k) = 0.0_wp
            end do
         else

            ! ---- Outer MLD iteration ----
            min_mld = 0.0_wp
            max_mld = h_sum
            dmld_min = 0.0_wp
            dmld_max = 0.0_wp
            have_min = .false.
            have_max = .false.
            mld_guess = 0.5_wp*(min_mld + max_mld)
            if (this%mld_use_prev_guess .and. this%mld(i, j) > 0.0_wp) then
               mld_guess = min(this%mld(i, j), max_mld)
            end if
            n_its = 1
            if (this%mld_iteration) n_its = this%mld_max_its

            do obl_it = 1, n_its
               ! Budget ledger restarts each iteration; the last
               ! iteration's values are the ones reported.
               d_wind = 0.0_wp
               d_conv = 0.0_wp
               d_forcing = 0.0_wp
               d_mixing = 0.0_wp
               d_mdecay = 0.0_wp
               d_cdecay = 0.0_wp

               ! (A) mstar at the current MLD guess.
               call epbl_find_mstar(this%mstar_scheme, this%mstar_const, &
                                    this%mstar_cap, this%mstar_coef1, &
                                    this%c_ek, this%mstar_conv_adj, &
                                    this%rh18_cn1, this%rh18_cn2, this%rh18_cn3, &
                                    this%rh18_cs1, this%rh18_cs2, &
                                    b0, ustar, mld_guess, absf, mstar_val)
               if (this%use_lt) then
                  la_val = epbl_lf17_la(ustar, this%la_frac_hbl*mld_guess, &
                                        lt_ustokes, lt_kphil)
                  call epbl_lt_enhance(this%lt_scheme, this%lt_enhance_coef, &
                                       this%lt_enhance_exp, this%lt_max_enhance, &
                                       this%von_karman, &
                                       this%lt_lac1, this%lt_lac2, this%lt_lac3, &
                                       this%lt_lac4, this%lt_lac5, &
                                       la_val, b0, ustar, mld_guess, absf, mstar_val)
               end if
               mech_tke = mstar_val*mech_in
               d_wind = mech_tke

               ! (B) seed the reservoirs from the surface forcing.
               if (ctke_sfc <= 0.0_wp) then
                  forcing_clip = max(ctke_sfc, -mech_tke)
                  mech_tke = mech_tke + forcing_clip
                  conv_perel = 0.0_wp
                  d_forcing = forcing_clip
               else
                  conv_perel = ctke_sfc
                  d_conv = this%nstar*ctke_sfc
               end if

               ! (C) sweep initialization at the surface layer.
               h_ka = ms%h_layer(i, j, nz) + H_NEGLECT
               hp_a = h_ka
               dpe_t_a = this%dpe_t%data(i, j, nz)
               dpe_s_a = this%dpe_s%data(i, j, nz)
               dch_t_a = this%dcolht_t%data(i, j, nz)
               dch_s_a = this%dcolht_s%data(i, j, nz)
               th_a = h_ka*this%t0%data(i, j, nz)
               sh_a = h_ka*this%s0%data(i, j, nz)
               te_lag = 0.0_wp
               se_lag = 0.0_wp
               kddt_prev = 0.0_wp
               htot = ms%h_layer(i, j, nz)
               z_int = ms%h_layer(i, j, nz)
               pres_int = GRAVITY*this%rho0*ms%h_layer(i, j, nz)
               mld_output = ms%h_layer(i, j, nz)
               sfc_connected = .true.
               this%kd_int(i, j, nz + 1) = 0.0_wp

               ! (D) downward sweep over interfaces Ki = nz .. 2.
               ! Layer above the interface: ka = Ki; below: kb = Ki-1.
               do ki = nz, 2, -1
                  ka = ki
                  kb = ki - 1
                  h_ka = ms%h_layer(i, j, ka) + H_NEGLECT
                  h_kb = ms%h_layer(i, j, kb) + H_NEGLECT
                  sfc_disconnect = .false.

                  ! (1) mechanical TKE decays across the layer above
                  !     (Ekman-scale e-folding; no decay at f = 0).
                  exp_kh = exp(-h_ka*idecay)
                  d_mdecay = d_mdecay + (1.0_wp - exp_kh)*mech_tke
                  mech_tke = mech_tke*exp_kh

                  ! (2) per-layer convective forcing accrual (PR-21).
                  !     The surface layer's cTKE seeds the reservoirs in
                  !     (B); here the sub-surface layer `kb` accrues its
                  !     penetrating-SW cost.  A POSITIVE ctke_sw(kb) (SW
                  !     cooling: only reachable on the legacy net-heat
                  !     path at night) releases convective PE — into
                  !     conv_perel, posted to the convective ledger like
                  !     the surface seed.  A NEGATIVE ctke_sw(kb) (solar
                  !     heating: the physical case) is a TKE SINK and
                  !     drains the reservoirs in (3b), after tot_tke is
                  !     formed.
                  if (sw_ctke_active) then
                     ctke_sw_kb = this%ctke_sw%data(i, j, kb)
                     if (ctke_sw_kb > 0.0_wp) then
                        conv_perel = conv_perel + ctke_sw_kb
                        d_conv = d_conv + this%nstar*ctke_sw_kb
                     end if
                  else
                     ctke_sw_kb = 0.0_wp
                  end if

                  ! (3) rotation-reduced convective efficiency.
                  nstar_fc = this%nstar
                  if (conv_perel > 0.0_wp .and. absf > 0.0_wp) then
                     nstar_fc = this%nstar*conv_perel/ &
                                (conv_perel + 0.2_wp* &
                                 sqrt(0.5_wp*dt*this%rho0*(absf*htot)**3*conv_perel))
                  end if
                  tot_tke = mech_tke + nstar_fc*conv_perel

                  ! (3b) penetrating-SW TKE drain (PR-21).  A negative
                  !      ctke_sw(kb) (solar heating stratifies below the
                  !      interface) must be paid before any mixing here —
                  !      discard that TKE to homogenise the SW through the
                  !      next denser cell.  Mechanical + convective
                  !      reservoirs drain PROPORTIONATELY (Reichl &
                  !      Hallberg 2018).  The consumed energy is booked to
                  !      d_mixing (the PE-raising work the SW stratification
                  !      demands), and the nstar-vs-nstar_fc slop of the
                  !      drained convective reservoir to d_cdecay — mirroring
                  !      the proven (8b) closed-form drain below, so the
                  !      column TKE budget still closes to round-off.  (Note:
                  !      posting to d_mixing OR d_forcing balances the ledger;
                  !      posting to BOTH double-counts.)
                  if (sw_ctke_active) then
                     if (ctke_sw_kb < 0.0_wp) then
                        if (ctke_sw_kb + tot_tke < 0.0_wp) then
                           ! SW cost exhausts all the TKE at this interface.
                           d_mixing = d_mixing + tot_tke
                           d_cdecay = d_cdecay + (this%nstar - nstar_fc)*conv_perel
                           tot_tke = 0.0_wp
                           mech_tke = 0.0_wp
                           conv_perel = 0.0_wp
                        else
                           r_sw = (tot_tke + ctke_sw_kb)/tot_tke
                           d_mixing = d_mixing - ctke_sw_kb
                           d_cdecay = d_cdecay + &
                                      (1.0_wp - r_sw)*(this%nstar - nstar_fc)*conv_perel
                           tot_tke = r_sw*tot_tke
                           mech_tke = r_sw*mech_tke
                           conv_perel = r_sw*conv_perel
                        end if
                     end if
                  end if

                  ! (5) static-stability short-circuit: no energy and
                  !     a stable interface => no mixing here.
                  if (tot_tke <= 0.0_wp .and. &
                      0.0_wp <= (this%dcolht_t%data(i, j, kb) + this%dcolht_t%data(i, j, ka))* &
                      (this%t0%data(i, j, ka) - this%t0%data(i, j, kb)) + &
                      (this%dcolht_s%data(i, j, kb) + this%dcolht_s%data(i, j, ka))* &
                      (this%s0%data(i, j, ka) - this%s0%data(i, j, kb))) then
                     kd_val = 0.0_wp
                     sfc_disconnect = .true.
                  else
                     ! (6) velocity scale, mixing length, first-guess Kd.
                     dt_h = dt/max(0.5_wp*(h_ka + h_kb), 1.0e-15_wp*h_sum)
                     tke_here = mech_tke + this%wstar_ustar_coef*conv_perel
                     hb_hs = (h_sum - z_int)/h_sum
                     shape_fn = epbl_mixlen_shape(z_int, mld_guess, &
                                                  this%translay_scale, &
                                                  this%mixlen_exponent, &
                                                  this%mld_iteration)
                     hbs = min(hb_hs, shape_fn)
                     if (tke_here > 0.0_wp) then
                        if (this%vstar_scheme == EPBL_VSTAR_RH18) then
                           surf_scale = max(0.05_wp, 1.0_wp - htot/mld_guess)
                           vstar = this%vstar_scale_fac*surf_scale* &
                                   (this%vstar_surf_fac*ustar + &
                                    (this%wstar_ustar_coef*conv_perel/ &
                                     (dt*this%rho0))**(1.0_wp/3.0_wp))
                        else
                           vstar = this%vstar_scale_fac* &
                                   (tke_here/(dt*this%rho0))**(1.0_wp/3.0_wp)
                        end if
                        if (this%mld_iteration) then
                           mixlen = max(this%min_mix_len, &
                                        (htot*hbs*vstar)/ &
                                        (this%ekman_scale_coef*absf*htot*hbs + vstar))
                           kd_g0 = vstar*this%von_karman*mixlen
                        else
                           kd_g0 = vstar*this%von_karman*(htot*hbs*vstar)/ &
                                   (this%ekman_scale_coef*absf*htot*hbs + vstar)
                        end if
                     else
                        vstar = 0.0_wp
                        kd_g0 = 0.0_wp
                     end if

                     ! (7) pivot quantities for the layer below.
                     hp_b = h_kb
                     th_b = h_kb*this%t0%data(i, j, kb)
                     sh_b = h_kb*this%s0%data(i, j, kb)

                     ! (8) closed-form energy solve (direct path).
                     hps = hp_a + hp_b
                     bdt1 = hp_a*hp_b
                     dt_c = hp_a*th_b - hp_b*th_a
                     ds_c = hp_a*sh_b - hp_b*sh_a
                     pec_core = hp_b*(dpe_t_a*dt_c + dpe_s_a*ds_c) - &
                                hp_a*(this%dpe_t%data(i, j, kb)*dt_c + &
                                      this%dpe_s%data(i, j, kb)*ds_c)
                     colht_core = hp_b*(dch_t_a*dt_c + dch_s_a*ds_c) - &
                                  hp_a*(this%dcolht_t%data(i, j, kb)*dt_c + &
                                        this%dcolht_s%data(i, j, kb)*ds_c)
                     ! Gravity-wave radiation correction: a shrinking
                     ! column radiates energy that cannot drive mixing.
                     if (colht_core < 0.0_wp) then
                        pec_core = pec_core - pres_int*colht_core
                     end if
                     dkddt = kd_g0*dt_h
                     pe_max = pec_core/(bdt1*hps)

                     if (pe_max < 0.0_wp) then
                        ! (8a) convectively unstable: mixing RELEASES
                        ! PE.  Recompute vstar with the released
                        ! energy included; Kd from the mixing length
                        ! (not energy-limited); bank the release.
                        tke_here = mech_tke + &
                                   this%wstar_ustar_coef*(conv_perel - pe_max)
                        if (tke_here > 0.0_wp) then
                           if (this%vstar_scheme == EPBL_VSTAR_RH18) then
                              surf_scale = max(0.05_wp, 1.0_wp - htot/mld_guess)
                              vstar = this%vstar_scale_fac*surf_scale* &
                                      (this%vstar_surf_fac*ustar + &
                                       (this%wstar_ustar_coef*conv_perel/ &
                                        (dt*this%rho0))**(1.0_wp/3.0_wp))
                           else
                              vstar = this%vstar_scale_fac* &
                                      (tke_here/(dt*this%rho0))**(1.0_wp/3.0_wp)
                           end if
                           if (this%mld_iteration) then
                              mixlen = max(this%min_mix_len, &
                                           (htot*hbs*vstar)/ &
                                           (this%ekman_scale_coef*absf*htot*hbs + vstar))
                              kd_val = vstar*this%von_karman*mixlen
                           else
                              kd_val = vstar*this%von_karman*(htot*hbs*vstar)/ &
                                       (this%ekman_scale_coef*absf*htot*hbs + vstar)
                           end if
                        else
                           vstar = 0.0_wp
                           kd_val = 0.0_wp
                        end if
                        pe_g0 = pec_core*dkddt/(bdt1*(bdt1 + dkddt*hps))
                        dpe_conv = pec_core*(kd_val*dt_h)/ &
                                   (bdt1*(bdt1 + (kd_val*dt_h)*hps))
                        if (dpe_conv > 0.0_wp) then
                           kd_val = kd_g0
                           dpe_conv = pe_g0
                        end if
                        ! dpe_conv < 0 => the reservoir grows; the
                        ! reservoirs are NOT proportionally drained on
                        ! this branch.
                        conv_perel = conv_perel - dpe_conv
                        d_conv = d_conv - this%nstar*dpe_conv
                        if (sfc_connected) then
                           mld_output = mld_output + ms%h_layer(i, j, kb)
                        end if
                     else
                        ! (8b) stable: direct closed-form Kd from the
                        ! energy budget; drain the reservoirs.
                        if ((pec_core*dkddt <= &
                             tot_tke*(bdt1*(bdt1 + dkddt*hps))) .or. &
                            (pec_core <= 0.0_wp)) then
                           kd_val = kd_g0
                           tke_used = pec_core*dkddt/(bdt1*(bdt1 + dkddt*hps))
                           frac_bl = 1.0_wp
                        else
                           kd_val = (bdt1**2*tot_tke)/ &
                                    (dt_h*(pec_core - bdt1*hps*tot_tke))
                           tke_used = tot_tke
                           frac_bl = tot_tke*(bdt1*(bdt1 + dkddt*hps))/ &
                                     (pec_core*dkddt)
                        end if
                        if (sfc_connected) then
                           mld_output = mld_output + frac_bl*ms%h_layer(i, j, kb)
                        end if
                        if (frac_bl < 1.0_wp) sfc_disconnect = .true.
                        r_reduc = 0.0_wp
                        if (tot_tke > 0.0_wp .and. tot_tke > tke_used) then
                           r_reduc = (tot_tke - tke_used)/tot_tke
                        end if
                        d_mixing = d_mixing + tke_used
                        d_cdecay = d_cdecay + &
                                   (1.0_wp - r_reduc)*(this%nstar - nstar_fc)*conv_perel
                        mech_tke = r_reduc*mech_tke
                        conv_perel = r_reduc*conv_perel
                     end if
                  end if

                  this%kd_int(i, j, ki) = kd_val
                  kddt_cur = kd_val*dt_h

                  ! (9) advance the embedded forward elimination.
                  b1 = 1.0_wp/(hp_a + kddt_cur)
                  c1 = kddt_cur*b1
                  if (ki == nz) then
                     te_lag = b1*(h_ka*this%t0%data(i, j, ka))
                     se_lag = b1*(h_ka*this%s0%data(i, j, ka))
                  else
                     te_new = b1*(h_ka*this%t0%data(i, j, ka) + kddt_prev*te_lag)
                     se_new = b1*(h_ka*this%s0%data(i, j, ka) + kddt_prev*se_lag)
                     te_lag = te_new
                     se_lag = se_new
                  end if
                  hp_a = h_kb + (hp_a*b1)*kddt_cur
                  dpe_t_a = this%dpe_t%data(i, j, kb) + c1*dpe_t_a
                  dpe_s_a = this%dpe_s%data(i, j, kb) + c1*dpe_s_a
                  dch_t_a = this%dcolht_t%data(i, j, kb) + c1*dch_t_a
                  dch_s_a = this%dcolht_s%data(i, j, kb) + c1*dch_s_a
                  th_a = h_kb*this%t0%data(i, j, kb) + kddt_cur*te_lag
                  sh_a = h_kb*this%s0%data(i, j, kb) + kddt_cur*se_lag
                  kddt_prev = kddt_cur
                  if (sfc_disconnect) then
                     htot = ms%h_layer(i, j, kb)
                     sfc_connected = .false.
                  else
                     htot = htot + ms%h_layer(i, j, kb)
                  end if
                  z_int = z_int + ms%h_layer(i, j, kb)
                  pres_int = pres_int + GRAVITY*this%rho0*ms%h_layer(i, j, kb)
               end do
               this%kd_int(i, j, 1) = 0.0_wp

               ! Leftover stocks at the bed count as dissipated.
               d_mdecay = d_mdecay + mech_tke
               d_cdecay = d_cdecay + this%nstar*conv_perel

               mld_found = mld_output
               if (.not. this%mld_iteration) exit
               if (abs(mld_found - mld_guess) < this%mld_tol) exit
               if (obl_it == n_its) exit

               ! Bracket update + next guess (false position with a
               ! bisection fallback; bisection-only when configured).
               if (mld_found > mld_guess) then
                  min_mld = mld_guess
                  dmld_min = mld_found - mld_guess
                  have_min = .true.
               else
                  max_mld = mld_guess
                  dmld_max = mld_found - mld_guess
                  have_max = .true.
               end if
               if (this%mld_bisection) then
                  mld_guess = 0.5_wp*(min_mld + max_mld)
               else if (have_min .and. have_max .and. obl_it > 2 .and. &
                        mod(obl_it - 1, 4) > 0) then
                  mld_guess = min_mld + dmld_min*(max_mld - min_mld)/ &
                              (dmld_min - dmld_max)
               else if (mld_found > min_mld .and. mld_found < max_mld) then
                  mld_guess = mld_found
               else
                  mld_guess = 0.5_wp*(min_mld + max_mld)
               end if
            end do

         end if

         this%mld(i, j) = mld_found
         this%la(i, j) = la_val
         if (this%tke_diags) then
            this%tke_wind(i, j) = d_wind/dt
            this%tke_conv(i, j) = d_conv/dt
            this%tke_forcing(i, j) = d_forcing/dt
            this%tke_mixing(i, j) = d_mixing/dt
            this%tke_mech_decay(i, j) = d_mdecay/dt
            this%tke_conv_decay(i, j) = d_cdecay/dt
         end if
      end do
   end subroutine epbl_column_kernel

   pure subroutine epbl_merge_into_kv_kt(this, nx, ny, nzp1, kv, kt)
      !! Fold the EPBL diffusivity into the vmix interface fields.
      !! Called EVERY stage (the interior closure rewrites kv/kt each
      !! stage; `kd_int` itself refreshes at thermo cadence).
      !! Interior interfaces only — k=1 (bed) and k=nz+1 (surface)
      !! stay at the closed-boundary zero in both source and target.
      type(ocean_epbl_t), intent(in) :: this
      integer, intent(in) :: nx, ny, nzp1
         !! Interface-field extents (explicit shape: assumed-shape
         !! dummies in a `do concurrent` kernel make NVHPC walk the
         !! descriptor with per-launch memcpys — this runs every stage).
      real(wp), intent(inout) :: kv(nx, ny, nzp1)
         !! Momentum viscosity at interfaces; gets prandtl*kd.
      real(wp), intent(inout) :: kt(nx, ny, nzp1)
         !! Tracer diffusivity at interfaces; gets kd.

      integer :: i, j, k

      do concurrent(k=2:nzp1 - 1, j=1:ny, i=1:nx)
         if (this%combine_mode == EPBL_COMBINE_MAX) then
            kt(i, j, k) = max(kt(i, j, k), this%kd_int(i, j, k))
            kv(i, j, k) = max(kv(i, j, k), this%prandtl*this%kd_int(i, j, k))
         else
            kt(i, j, k) = kt(i, j, k) + this%kd_int(i, j, k)
            kv(i, j, k) = kv(i, j, k) + this%prandtl*this%kd_int(i, j, k)
         end if
      end do
   end subroutine epbl_merge_into_kv_kt

   pure function ocean_epbl_bytes(this) result(nbytes)
      !! Counted allocatable footprint of the EPBL slot
      !! (0 when unallocated). One arr_bytes term per array — add a
      !! term here when a new allocatable joins the type.
      class(ocean_epbl_t), intent(in) :: this
      integer(int64) :: nbytes
      nbytes = arr_bytes(this%f_centre) &
               + arr_bytes(this%mld) &
               + arr_bytes(this%b0) &
               + arr_bytes(this%kd_int) &
               + arr_bytes(this%la) &
               + arr_bytes(this%tke_wind) &
               + arr_bytes(this%tke_conv) &
               + arr_bytes(this%tke_forcing) &
               + arr_bytes(this%tke_mixing) &
               + arr_bytes(this%tke_mech_decay) &
               + arr_bytes(this%tke_conv_decay) &
               + this%t0%bytes() &
               + this%s0%bytes() &
               + this%dpe_t%bytes() &
               + this%dpe_s%bytes() &
               + this%dcolht_t%bytes() &
               + this%dcolht_s%bytes() &
               + this%ctke_sw%bytes()
   end function ocean_epbl_bytes

end module rdb_ocean_epbl
