!! Configure-time numerical-stability audit for the ocean dyn-core.
module rdb_ocean_stability_audit
   !! Motivating failure (`tmp_local_artifacts/global_run/FINDINGS.md`,
   !! 2026-09-11): a global tripolar aquaplanet NaN'd at outer step 7. The
   !! ONLY diagnostic on offer was a bare non-finite-face count — "producer
   !! 0/0 upstream, investigate". A human needed several runs + a
   !! bisection to find the actual cause: `nu_h = 2.0e4` with `dt = 900 s`
   !! at the ~3 km polar cells gives a viscous-diffusion number of 1.65
   !! against an explicit-Laplacian bound of 0.125 — 13x over — and
   !! `bound_kh` (the per-cell runtime clamp that would have protected
   !! against exactly this) was never enabled.
   !!
   !! Nothing at configure time said any of that. This module is the fix:
   !! a small set of checks run once, AFTER the real per-cell metric
   !! arrays exist (`ocean_metrics_t`, filled by `configure_ocean_metrics`
   !! + land-masked by `configure_ocean_land_mask`) — never off the
   !! nominal `&grid_nml dx`/`dy`, which are DEGREES on spherical/tripolar
   !! grids and are in any case the NOMINAL spacing, not the smallest
   !! actual cell (a spherical/tripolar grid's smallest cell can be an
   !! order of magnitude below nominal near a pole).
   !!
   !! Each check reports the computed number, the limit, the knob(s)
   !! responsible, and a concrete fix — never a bare "X exceeded".
   !!
   !! Severity: a VIOLATED HARD STABILITY BOUND (viscous CFL, the tracer
   !! diffusive number) is an ERROR — returned via the P0 `ierr` status
   !! (`OCEAN_STATUS_ERR_SETUP`), never `error stop` (this module always
   !! has `ierr` to report through; `configure_ocean_metrics` et al. do
   !! the same). A MARGINAL/QUALITY issue (the Munk-layer resolution
   !! criterion, `ah_max` silently clamping `nu_h`) is a WARNING — logged,
   !! run proceeds. The viscous-CFL check is itself downgraded from ERROR
   !! to an informational WARNING when the run already carries automatic
   !! runtime protection (`bound_kh`, or the `stress_tensor` operator's
   !! own always-on per-cell CFL limiter) — the raw `nu_h*dt/dx^2` number
   !! is no longer what the kernel actually uses in that case, so a hard
   !! configure-time failure would be a false positive (see
   !! `docs/CLOSURE_MATRIX.md` / `rdb_ocean_horizontal_viscosity.F90`
   !! module header for `bound_kh` / `stress_tensor` semantics).
   use rdb_constants, only: wp, PI, VCOORD_SIGMA, VCOORD_ZSIGMA, &
                            VCOORD_ZSTAR_SIGMA
   use rdb_config, only: config_t
   use rdb_grid, only: hgrid_t
   use rdb_vcoord, only: parse_vcoord_type
   use rdb_ocean_metrics, only: ocean_metrics_t, parse_grid_config, &
                                parse_coriolis_scheme, GRID_CONFIG_CARTESIAN, &
                                CORIOLIS_SCHEME_PLANETARY
   use pic_logger, only: logger => global_logger
   use pic_strings, only: to_string
   use rdb_error_ring, only: fail
   use rdb_ocean_status, only: OCEAN_STATUS_OK, OCEAN_STATUS_ERR_SETUP
   implicit none
   private

   public :: ocean_stability_audit
   public :: ocean_viscous_cfl_number
   public :: ocean_viscous_cfl_max_nu_h
   public :: ocean_viscous_cfl_max_dt
   public :: ocean_diffusive_number
   public :: ocean_munk_delta_m
   public :: ocean_munk_required_nu_h
   public :: ocean_viscous_cfl_limit
   public :: ocean_diffusive_number_limit
   public :: ocean_munk_min_cells
   public :: ocean_sigma_stiffness
   public :: ocean_sigma_stiffness_worst
   public :: ocean_sigma_stiffness_limit
   public :: ocean_vcoord_is_terrain_following

   real(wp), parameter :: VISCOUS_CFL_LIMIT = 0.125_wp
      !! Single-axis viscous-diffusion stability bound `nu_h*dt/dx_min^2`.
      !! Taken directly from the constant this codebase ALREADY uses at
      !! runtime for exactly this quantity: the `bound_kh` per-face clamp
      !! (`rdb_ocean_horizontal_viscosity.F90`) limits the harmonic
      !! viscosity to `bound_coef*0.125/(dt*(idx^2+idy^2))`, documented
      !! there as "~1/4 of the forward-Euler stability limit" of 0.5 (the
      !! same two-axis sum-form bound `rdb_ocean_hdiff_tracer.F90`'s
      !! `kappa_h` check already uses, see `DIFFUSIVE_NUMBER_LIMIT`
      !! below). This audit collapses that two-axis form to the single
      !! worst axis (`nu_h*dt/dx_min^2` rather than
      !! `nu_h*dt*(1/dx_min^2+1/dy_min^2)`) so the reported number matches
      !! the plain "viscous CFL" a user computes by hand, at `bound_coef=1`
      !! parity with the runtime clamp's own margin — i.e. this check trips
      !! at exactly the `nu_h` that would need `bound_kh`'s protection.
   real(wp), parameter :: DIFFUSIVE_NUMBER_LIMIT = 0.5_wp
      !! Two-axis explicit forward-Euler Laplacian stability bound
      !! `kappa_h*dt_therm*(1/dx^2+1/dy^2) <= 0.5` — unchanged from the
      !! existing `rdb_config.F90` check this module absorbs (only the
      !! length scale changes: the real per-cell metric minimum, not
      !! nominal `dx`/`dy`).
   real(wp), parameter :: SIGMA_STIFFNESS_LIMIT = 0.2_wp
      !! Terrain-following STIFFNESS (slope) parameter bound
      !! `rx0 = |H_a - H_b| / (H_a + H_b) <= 0.2` over every face joining
      !! two wet columns, where `H` is the COLUMN the sigma coordinate
      !! divides into `nz` layers — under an ice shelf that is the WATER
      !! column `b - z_draft`, not the bathymetry.
      !!
      !! This is the classical σ-coordinate criterion: Beckmann &
      !! Haidvogel (1993), J. Phys. Oceanogr. 23, 1736-1753, §2c, who
      !! introduce `r = |Δh|/(2h̄)` (algebraically the same number) and
      !! smooth their seamount to `r <= 0.2`; the "hydrostatic
      !! consistency" condition of Haney (1991), J. Phys. Oceanogr. 21,
      !! 610-619, is the same statement. It bounds the σ pressure-gradient
      !! truncation, whose amplitude goes as the CUBE of the interface
      !! offset `Δe` between neighbouring columns
      !! (`a_peak = N²·Δe³/(6·dx·H̄)`, derived in
      !! `validation_examples/ocean/ice_shelf_cavity/README.md`), so a
      !! factor 2 in `rx0` is a factor 8 in spurious acceleration.
      !!
      !! WARNING, never an error: a violated `rx0` is not an instability
      !! on its own — with `N² = 0` the truncation is identically zero at
      !! any `rx0` — and plenty of useful runs are forced hard enough,
      !! damped hard enough, or short enough not to care. What it says is
      !! that the run's spurious PGF force is NOT small, so a quiescent or
      !! long integration over that geometry will measure the truncation
      !! rather than the physics.
      !!
      !! **It fires on healthy shipped cases, by design.** Measured over
      !! `validation_examples/ocean/`: 9 of 72 namelists trip it, on four
      !! distinct geometries. The `double_gyre` `"spoon"` continental
      !! slope reads `0.348` and `neverworld2`'s shelf reads `0.893`
      !! (`seamount_obc_baroclinic` sits just over at `0.235`), and all of
      !! them run for hundreds of days — because they carry
      !! `nu_h = 10000 m² s⁻¹`, i.e. a constant lateral-viscosity floor
      !! big enough to arrest a steady spurious force at `a/r` instead of
      !! integrating it. That is the correct reading of the warning on a
      !! forced configuration, and it is worth saying once at configure.
      !! The three `ice_shelf_cavity/` files are QUIET (`rx0 ≈ 0.015`:
      !! flat bed, and the only tilted boundary is a 13.8 m lid step).
      !! Motivating failure:
      !! `validation_examples/ocean/isomip_plus/ocean0_idealised_draft.nml`
      !! carries `rx0 = 0.73` at the ISOMIP+ trough sidewall (a 23 m water
      !! column beside a 146 m one across one 2 km face) and goes
      !! non-finite at day 3.2 with nothing in the log at configure time.
   real(wp), parameter :: MUNK_MIN_CELLS = 2.0_wp
      !! Minimum number of grid cells the Munk sidewall boundary layer
      !! `delta_M = (nu_h/beta)^(1/3)` must span; below this the wall
      !! carries grid-scale (2-delta) noise instead of a resolved
      !! boundary-layer profile (see
      !! `validation_examples/ocean/acc_channel/acc_channel.nml`, where
      !! this exact criterion is documented and was hand-derived).

contains

   pure function ocean_viscous_cfl_limit() result(lim)
      !! Accessor for `VISCOUS_CFL_LIMIT` — tests reference this instead
      !! of duplicating the literal.
      real(wp) :: lim
      lim = VISCOUS_CFL_LIMIT
   end function ocean_viscous_cfl_limit

   pure function ocean_diffusive_number_limit() result(lim)
      !! Accessor for `DIFFUSIVE_NUMBER_LIMIT`.
      real(wp) :: lim
      lim = DIFFUSIVE_NUMBER_LIMIT
   end function ocean_diffusive_number_limit

   pure function ocean_munk_min_cells() result(n)
      !! Accessor for `MUNK_MIN_CELLS`.
      real(wp) :: n
      n = MUNK_MIN_CELLS
   end function ocean_munk_min_cells

   pure function ocean_sigma_stiffness_limit() result(lim)
      !! Accessor for `SIGMA_STIFFNESS_LIMIT`.
      real(wp) :: lim
      lim = SIGMA_STIFFNESS_LIMIT
   end function ocean_sigma_stiffness_limit

   pure function ocean_sigma_stiffness(h_a, h_b) result(rx0)
      !! One face's terrain-following stiffness `|h_a-h_b|/(h_a+h_b)`.
      !!
      !! Both columns must be POSITIVE for the number to mean anything
      !! (a land column carries `H_VANISHED`, not a water column, and the
      !! caller masks it out); a non-positive sum returns `0` — "no
      !! constraint expressible", the same stance
      !! `ocean_viscous_cfl_number` takes for a degenerate `dx_min`.
      !! Range `[0, 1)`: `0` = two equal columns, `-> 1` = one column
      !! vanishing against its neighbour.
      real(wp), intent(in) :: h_a
         !! Column thickness on one side of the face (m).
      real(wp), intent(in) :: h_b
         !! Column thickness on the other side (m).
      real(wp) :: rx0
      if (h_a > 0.0_wp .and. h_b > 0.0_wp) then
         rx0 = abs(h_a - h_b)/(h_a + h_b)
      else
         rx0 = 0.0_wp
      end if
   end function ocean_sigma_stiffness

   pure function ocean_vcoord_is_terrain_following(code) result(tf)
      !! Does this `VCOORD_*` code put the layer interfaces on surfaces
      !! that follow the bottom (and, under an ice shelf, the ice base)?
      !!
      !! `VCOORD_ZSTAR` is NOT in the set: it is MOM6 z*
      !! (`ocean_vcoord_zstar_target`), a fixed nominal z profile dilated
      !! by `(H + eta)/H` over bed fillers, so its interfaces are
      !! geopotential to `O(eta/H)` and do not tilt with the topography.
      !! (Until the z* slice it shared the `VCOORD_SIGMA` branch and was
      !! in the set.) `VCOORD_ZSIGMA` and
      !! `VCOORD_ZSTAR_SIGMA` blend TO sigma in shallow water, which is
      !! where the stiff faces are, so they are in too. The fixed-z,
      !! Lagrangian and density families are not: their interfaces do not
      !! tilt with the topography.
      integer, intent(in) :: code
         !! A `VCOORD_*` code from `parse_vcoord_type`.
      logical :: tf
      tf = (code == VCOORD_SIGMA .or. &
            code == VCOORD_ZSIGMA .or. code == VCOORD_ZSTAR_SIGMA)
   end function ocean_vcoord_is_terrain_following

   pure function ocean_viscous_cfl_number(nu_h, dt, dx_min) result(cfl)
      !! `nu_h*dt/dx_min^2` — the single-axis viscous-diffusion stability
      !! number checked against `VISCOUS_CFL_LIMIT`. `dx_min` MUST be the
      !! smallest actual cell edge in the domain (e.g.
      !! `metrics_dx_min`/`ocean_stability_min_cell`), never a nominal
      !! `&grid_nml dx`/`dy` (degrees on non-Cartesian grids). `dx_min<=0`
      !! (degenerate/unset grid) returns 0 (no constraint expressible).
      real(wp), intent(in) :: nu_h, dt, dx_min
      real(wp) :: cfl
      if (dx_min > 0.0_wp) then
         cfl = nu_h*dt/dx_min**2
      else
         cfl = 0.0_wp
      end if
   end function ocean_viscous_cfl_number

   pure function ocean_viscous_cfl_max_nu_h(dt, dx_min, limit) result(nu_h_max)
      !! Largest `nu_h` (m^2/s) that keeps `ocean_viscous_cfl_number` at
      !! or below `limit`, at fixed `dt`/`dx_min` — the "reduce nu_h
      !! below ..." half of the audit's suggested fix.
      real(wp), intent(in) :: dt, dx_min, limit
      real(wp) :: nu_h_max
      if (dt > 0.0_wp) then
         nu_h_max = limit*dx_min**2/dt
      else
         nu_h_max = 0.0_wp
      end if
   end function ocean_viscous_cfl_max_nu_h

   pure function ocean_viscous_cfl_max_dt(nu_h, dx_min, limit) result(dt_max)
      !! Largest `dt` (s) that keeps `ocean_viscous_cfl_number` at or
      !! below `limit`, at fixed `nu_h`/`dx_min` — the "reduce dt below
      !! ..." half of the audit's suggested fix.
      real(wp), intent(in) :: nu_h, dx_min, limit
      real(wp) :: dt_max
      if (nu_h > 0.0_wp) then
         dt_max = limit*dx_min**2/nu_h
      else
         dt_max = huge(1.0_wp)
      end if
   end function ocean_viscous_cfl_max_dt

   pure function ocean_diffusive_number(kappa_h, dt_therm, dx_min) result(dnum)
      !! Two-axis explicit forward-Euler diffusive number
      !! `kappa_h*dt_therm*(1/dx_min^2+1/dy_min^2)`, conservatively
      !! evaluated at the SAME worst-case `dx_min` on both axes (matches
      !! the "use the minimum cell" instruction; exact on an isotropic
      !! worst cell, strictly more conservative than using the true
      !! per-axis pair). `dx_min<=0` returns 0.
      real(wp), intent(in) :: kappa_h, dt_therm, dx_min
      real(wp) :: dnum
      if (dx_min > 0.0_wp) then
         dnum = kappa_h*dt_therm*(2.0_wp/dx_min**2)
      else
         dnum = 0.0_wp
      end if
   end function ocean_diffusive_number

   pure function ocean_munk_delta_m(nu_h, beta) result(delta_m)
      !! Munk boundary-layer width `delta_M = (nu_h/beta)^(1/3)`.
      !! `beta<=0` (f-plane — no meridional PV gradient, no Munk
      !! boundary layer) or `nu_h<=0` returns `huge(1.0_wp)` (no
      !! constraint — never trips the >= 2-cell criterion).
      real(wp), intent(in) :: nu_h, beta
      real(wp) :: delta_m
      if (beta > 0.0_wp .and. nu_h > 0.0_wp) then
         delta_m = (nu_h/beta)**(1.0_wp/3.0_wp)
      else
         delta_m = huge(1.0_wp)
      end if
   end function ocean_munk_delta_m

   pure function ocean_munk_required_nu_h(beta, dx, n_cells) result(nu_h_req)
      !! `nu_h` (m^2/s) needed for `delta_M` to span exactly `n_cells` of
      !! width `dx` — the audit's suggested fix for a Munk-layer warning.
      real(wp), intent(in) :: beta, dx, n_cells
      real(wp) :: nu_h_req
      nu_h_req = beta*(n_cells*dx)**3
   end function ocean_munk_required_nu_h

   pure subroutine ocean_stability_min_cell(metrics, grid, dx_min, i_at, j_at, is_x)
      !! Smallest actual cell edge over the WET PHYSICAL domain (excludes
      !! ghosts and land), taken over BOTH `dxT` and `dyT`, with its (i,j)
      !! location and which axis (`is_x`) it came from — for actionable
      !! messages ("near j=110"). Host-side, configure time; the grid
      !! sizes here are at most a few 10^5 cells (a global tripolar
      !! config), trivial to scan once.
      !!
      !! **Wet cells only** (`metrics%wet_T > 0.5`): no viscous or
      !! diffusive operator acts on a land cell, and on the 1° tripolar
      !! grid the smallest cell anywhere is a 362 m LAND cell at a
      !! land-locked bipole, which made the viscous-CFL check abort a
      !! configuration whose smallest OCEAN cell was comfortably inside
      !! the bound.  Where the smallest cell is wet (every all-wet grid)
      !! the result is unchanged, value and location.  No wet cell ⇒
      !! `dx_min = huge`, which the caller already treats as "no
      !! constraint expressible".
      type(ocean_metrics_t), intent(in) :: metrics
      type(hgrid_t), intent(in) :: grid
      real(wp), intent(out) :: dx_min
      integer, intent(out) :: i_at, j_at
      logical, intent(out) :: is_x
      integer :: i, j, ng, i0, i1, j0, j1

      ng = grid%nghost
      i0 = ng + 1
      i1 = ng + grid%nx_phys
      j0 = ng + 1
      j1 = ng + grid%ny_phys
      dx_min = huge(1.0_wp)
      i_at = i0
      j_at = j0
      is_x = .true.
      do j = j0, j1
         do i = i0, i1
            if (metrics%wet_T(i, j) <= 0.5_wp) cycle
            if (metrics%dxT(i, j) < dx_min) then
               dx_min = metrics%dxT(i, j)
               i_at = i
               j_at = j
               is_x = .true.
            end if
            if (metrics%dyT(i, j) < dx_min) then
               dx_min = metrics%dyT(i, j)
               i_at = i
               j_at = j
               is_x = .false.
            end if
         end do
      end do
   end subroutine ocean_stability_min_cell

   pure subroutine ocean_munk_worst_case(cfg, metrics, grid, nu_h, ratio_min, beta_at, dx_at, j_at)
      !! Worst-case (smallest) `delta_M/dx` ratio over the physical
      !! domain, honouring a latitude-varying `beta` under
      !! `coriolis_scheme='planetary'` on a non-Cartesian grid (`beta =
      !! 2*omega*cos(lat)/R`, maximal — hence `delta_M` MINIMAL, the
      !! worst case — at the most equatorward row) and a constant `beta`
      !! (`&ocean_topo_nml coriolis_beta`) everywhere else. `ratio_min` is
      !! `huge(1.0_wp)` (no constraint) when `beta<=0` everywhere (an
      !! f-plane run has no Munk boundary layer to resolve).
      type(config_t), intent(in) :: cfg
      type(ocean_metrics_t), intent(in) :: metrics
      type(hgrid_t), intent(in) :: grid
      real(wp), intent(in) :: nu_h
      real(wp), intent(out) :: ratio_min, beta_at, dx_at
      integer, intent(out) :: j_at

      real(wp), parameter :: DEG2RAD = PI/180.0_wp
      logical :: planetary
      integer :: i, j, ng, i0, i1, j0, j1
      real(wp) :: beta_ij, dx_ij, delta_ij, ratio_ij

      planetary = (parse_grid_config(cfg%ocean%grid%grid_config) /= GRID_CONFIG_CARTESIAN &
                   .and. parse_coriolis_scheme(cfg%ocean%grid%coriolis_scheme) == CORIOLIS_SCHEME_PLANETARY)

      ratio_min = huge(1.0_wp)
      beta_at = 0.0_wp
      dx_at = 0.0_wp
      j_at = 0

      if (nu_h <= 0.0_wp) return

      ng = grid%nghost
      i0 = ng + 1
      i1 = ng + grid%nx_phys
      j0 = ng + 1
      j1 = ng + grid%ny_phys

      if (.not. planetary) then
         ! Uniform beta (beta_plane, or the f-plane beta<=0 no-op).
         beta_ij = cfg%ocean%topo%coriolis_beta
         if (beta_ij <= 0.0_wp) return
         do j = j0, j1
            do i = i0, i1
               dx_ij = min(metrics%dxT(i, j), metrics%dyT(i, j))
               delta_ij = ocean_munk_delta_m(nu_h, beta_ij)
               ratio_ij = delta_ij/dx_ij
               if (ratio_ij < ratio_min) then
                  ratio_min = ratio_ij
                  beta_at = beta_ij
                  dx_at = dx_ij
                  j_at = j
               end if
            end do
         end do
      else
         do j = j0, j1
            do i = i0, i1
               beta_ij = 2.0_wp*cfg%ocean%grid%omega*cos(metrics%geolatT(i, j)*DEG2RAD)/ &
                         cfg%ocean%grid%rad_earth
               if (beta_ij <= 0.0_wp) cycle
               dx_ij = min(metrics%dxT(i, j), metrics%dyT(i, j))
               delta_ij = ocean_munk_delta_m(nu_h, beta_ij)
               ratio_ij = delta_ij/dx_ij
               if (ratio_ij < ratio_min) then
                  ratio_min = ratio_ij
                  beta_at = beta_ij
                  dx_at = dx_ij
                  j_at = j
               end if
            end do
         end do
      end if
   end subroutine ocean_munk_worst_case

   pure subroutine ocean_sigma_stiffness_worst(nx, ny, i0, i1, j0, j1, column, wet, &
                                               rx0_max, i_at, j_at, is_x, &
                                               h_thin, h_thick, n_over, n_face)
      !! Worst (largest) `ocean_sigma_stiffness` over every face joining
      !! two WET columns inside `[i0,i1] x [j0,j1]`, with its location,
      !! its two column thicknesses, and how many faces are over
      !! `SIGMA_STIFFNESS_LIMIT`.
      !!
      !! Only INTERIOR-to-INTERIOR faces are scanned (the loops stop one
      !! short of `i1`/`j1`), so the number never depends on what a ghost
      !! ring happens to hold — which is what makes it the same under any
      !! decomposition and safe to quote in a configure message.
      !!
      !! A land column is excluded by `wet`, not by a thickness test: a
      !! land T-cell holds `h_layer = H_VANISHED` per the land-state
      !! contract, so its "column" is `nz*H_VANISHED` and would otherwise
      !! read as a near-vanishing neighbour at every coastline and make
      !! `rx0 -> 1` everywhere. A coastline is a WALL, not a stiff face:
      !! the metrics are zeroed there and no pressure gradient is taken.
      integer, intent(in) :: nx
         !! First dimension of `column`/`wet` (ghosts included).
      integer, intent(in) :: ny
         !! Second dimension.
      integer, intent(in) :: i0
         !! First physical index in x.
      integer, intent(in) :: i1
         !! Last physical index in x.
      integer, intent(in) :: j0
         !! First physical index in y.
      integer, intent(in) :: j1
         !! Last physical index in y.
      real(wp), intent(in) :: column(nx, ny)
         !! Column thickness (m) the vertical coordinate divides — the
         !! WATER column `b - z_draft` under an ice shelf.
      real(wp), intent(in) :: wet(nx, ny)
         !! Static wet (1) / land (0) T-cell mask.
      real(wp), intent(out) :: rx0_max
         !! Largest stiffness found; `0` if no wet-wet face exists.
      integer, intent(out) :: i_at
         !! `i` of the thin side of the worst face.
      integer, intent(out) :: j_at
         !! `j` of the thin side of the worst face.
      logical, intent(out) :: is_x
         !! `.true.` if the worst face is an x (east) face.
      real(wp), intent(out) :: h_thin
         !! Thinner column of the worst face (m).
      real(wp), intent(out) :: h_thick
         !! Thicker column of the worst face (m).
      integer, intent(out) :: n_over
         !! Wet-wet faces with `rx0 > SIGMA_STIFFNESS_LIMIT`.
      integer, intent(out) :: n_face
         !! Wet-wet faces scanned (the denominator for `n_over`).
      integer :: i, j
      real(wp) :: rx0, ha, hb

      rx0_max = 0.0_wp
      i_at = i0
      j_at = j0
      is_x = .true.
      h_thin = 0.0_wp
      h_thick = 0.0_wp
      n_over = 0
      n_face = 0

      do j = j0, j1
         do i = i0, i1
            if (wet(i, j) <= 0.5_wp) cycle
            ha = column(i, j)
            if (ha <= 0.0_wp) cycle
            if (i < i1) then
               if (wet(i + 1, j) > 0.5_wp) then
                  hb = column(i + 1, j)
                  if (hb > 0.0_wp) then
                     rx0 = ocean_sigma_stiffness(ha, hb)
                     n_face = n_face + 1
                     if (rx0 > SIGMA_STIFFNESS_LIMIT) n_over = n_over + 1
                     if (rx0 > rx0_max) then
                        rx0_max = rx0
                        i_at = i
                        j_at = j
                        is_x = .true.
                        h_thin = min(ha, hb)
                        h_thick = max(ha, hb)
                     end if
                  end if
               end if
            end if
            if (j < j1) then
               if (wet(i, j + 1) > 0.5_wp) then
                  hb = column(i, j + 1)
                  if (hb > 0.0_wp) then
                     rx0 = ocean_sigma_stiffness(ha, hb)
                     n_face = n_face + 1
                     if (rx0 > SIGMA_STIFFNESS_LIMIT) n_over = n_over + 1
                     if (rx0 > rx0_max) then
                        rx0_max = rx0
                        i_at = i
                        j_at = j
                        is_x = .false.
                        h_thin = min(ha, hb)
                        h_thick = max(ha, hb)
                     end if
                  end if
               end if
            end if
         end do
      end do
   end subroutine ocean_sigma_stiffness_worst

   subroutine ocean_stability_audit(cfg, metrics, grid, rank, ierr, column)
      !! Run all configure-time stability checks. Must run AFTER
      !! `configure_ocean_metrics` + `configure_ocean_land_mask` (needs
      !! the real filled `ocean_metrics_t`), before `ocean_state_enter_data`.
      !! `ierr` present -> `OCEAN_STATUS_ERR_SETUP` on any hard-bound
      !! violation (never `error stop`); warnings always just log,
      !! whatever `ierr` does. Rank-0-only logging (mirrors every other
      !! `configure_ocean_*` info/warning line); the ERROR path itself
      !! always fires (every rank must agree the config is broken).
      type(config_t), intent(in) :: cfg
      type(ocean_metrics_t), intent(in) :: metrics
      type(hgrid_t), intent(in) :: grid
      integer, intent(in) :: rank
      integer, intent(out), optional :: ierr
      real(wp), intent(in), optional :: column(:, :)
         !! Reference column thickness (m) at T cells, ghosts included —
         !! `bt_work%bt_H_ref`, which is `b - z_draft` afloat and `0`
         !! where grounded. Present ⇒ the terrain-following stiffness
         !! check (Check 5) runs; absent ⇒ it is skipped, which is what a
         !! caller with no barotropic datum yet should do.

      real(wp) :: dx_min, dt, dt_therm, nu_h, kappa_h, ah_max
      integer :: i_at, j_at
      logical :: is_x
      logical :: auto_protected

      if (present(ierr)) ierr = OCEAN_STATUS_OK

      dt = cfg%dt_fixed
      nu_h = cfg%ocean%hvisc%nu_h
      kappa_h = cfg%ocean%hdiff%kappa_h
      ah_max = cfg%ocean%hvisc%ah_max

      ! dt_fixed <= 0 => adaptive-CFL dt, not known at configure time
      ! (matches the pre-existing kappa_h check's own gate).
      if (dt <= 0.0_wp) return

      call ocean_stability_min_cell(metrics, grid, dx_min, i_at, j_at, is_x)
      if (dx_min <= 0.0_wp .or. dx_min == huge(1.0_wp)) return

      ! ---- Check 1: viscous CFL (nu_h*dt/dx_min^2) ----
      auto_protected = cfg%ocean%hvisc%bound_kh .or. cfg%ocean%hvisc%stress_tensor
      if (nu_h > 0.0_wp) then
         block
            real(wp) :: cfl, nu_h_max, dt_max
            cfl = ocean_viscous_cfl_number(nu_h, dt, dx_min)
            if (cfl > VISCOUS_CFL_LIMIT) then
               nu_h_max = ocean_viscous_cfl_max_nu_h(dt, dx_min, VISCOUS_CFL_LIMIT)
               dt_max = ocean_viscous_cfl_max_dt(nu_h, dx_min, VISCOUS_CFL_LIMIT)
               if (auto_protected) then
                  if (rank == 0) then
                     call logger%warning("&ocean_hvisc_nml nu_h = "//to_string(nu_h)// &
                                         " with dt = "//to_string(dt)//"s gives viscous CFL "// &
                                         to_string(cfl)//" at the smallest cell (dx = "// &
                                         to_string(dx_min/1000.0_wp)//" km, near i="//to_string(i_at)// &
                                         " j="//to_string(j_at)//"); limit is "// &
                                         to_string(VISCOUS_CFL_LIMIT)//" — but bound_kh/stress_tensor "// &
                                         "is enabled, so the runtime per-cell clamp will keep the "// &
                                         "EFFECTIVE viscosity within bound. Informational only.")
                  end if
               else
                  call fail("&ocean_hvisc_nml nu_h = "//to_string(nu_h)// &
                            " with dt = "//to_string(dt)//"s gives viscous CFL "// &
                            to_string(cfl)//" at the smallest cell (dx = "// &
                            to_string(dx_min/1000.0_wp)//" km, near i="//to_string(i_at)// &
                            " j="//to_string(j_at)//"); limit is "//to_string(VISCOUS_CFL_LIMIT)// &
                            ". Set &ocean_hvisc_nml bound_kh = .true. for a per-cell clamp, "// &
                            "or reduce nu_h below "//to_string(nu_h_max)// &
                            ", or reduce dt below "//to_string(dt_max)//"s.", ierr, OCEAN_STATUS_ERR_SETUP)
                  return
               end if
            end if
         end block
      end if

      ! ---- Check 2: tracer diffusive number (kappa_h) ----
      ! Absorbed + fixed from the old rdb_config.F90 check (Cartesian-only,
      ! nominal dx/dy): now uses the REAL minimum cell, on ANY grid type.
      if (kappa_h > 0.0_wp) then
         block
            real(wp) :: dnum
            dt_therm = dt*real(cfg%ocean%vmix%dt_therm_ratio, wp)
            dnum = ocean_diffusive_number(kappa_h, dt_therm, dx_min)
            if (dnum > DIFFUSIVE_NUMBER_LIMIT) then
               call fail("&ocean_hdiff_nml kappa_h = "//to_string(kappa_h)// &
                         " violates the explicit forward-Euler diffusive stability bound "// &
                         "kappa_h*dt_therm*(1/dx_min^2+1/dy_min^2) <= "// &
                         to_string(DIFFUSIVE_NUMBER_LIMIT)//" (computed "//to_string(dnum)// &
                         " using dt_therm = "//to_string(dt_therm)//"s, dx_min = "// &
                         to_string(dx_min)//"m near i="//to_string(i_at)//" j="//to_string(j_at)// &
                         " — the ACTUAL smallest cell, not nominal dx/dy). Reduce kappa_h "// &
                         "below "//to_string(DIFFUSIVE_NUMBER_LIMIT*dx_min**2/(2.0_wp*dt_therm))// &
                         ", or reduce dt/dt_therm_ratio.", ierr, OCEAN_STATUS_ERR_SETUP)
               return
            end if
         end block
      end if

      ! ---- Check 3: Munk-layer resolution (WARNING) ----
      if (nu_h > 0.0_wp) then
         block
            real(wp) :: ratio_min, beta_at, dx_at, nu_h_req
            integer :: j_munk
            call ocean_munk_worst_case(cfg, metrics, grid, nu_h, ratio_min, beta_at, dx_at, j_munk)
            if (ratio_min < MUNK_MIN_CELLS .and. ratio_min < huge(1.0_wp)) then
               nu_h_req = ocean_munk_required_nu_h(beta_at, dx_at, MUNK_MIN_CELLS)
               if (rank == 0) then
                  call logger%warning("Munk sidewall boundary layer under-resolved: "// &
                                      "delta_M = (nu_h/beta)^(1/3) = "//to_string(ocean_munk_delta_m(nu_h, beta_at))// &
                                      "m spans only "//to_string(ratio_min)//" cells (dx = "// &
                                      to_string(dx_at)//"m, beta = "//to_string(beta_at)//" 1/(m*s) near j="// &
                                      to_string(j_munk)//"); need >= "//to_string(MUNK_MIN_CELLS)// &
                                      " cells or the wall carries grid-scale (2-delta) noise. Raise "// &
                                      "&ocean_hvisc_nml nu_h to at least "//to_string(nu_h_req)// &
                                      " m2/s (and raise ah_max to match — see the next check).")
               end if
            end if
         end block
      end if

      ! ---- Check 4: ah_max clamping nu_h inert (WARNING) ----
      if (nu_h > 0.0_wp .and. ah_max > 0.0_wp .and. ah_max < nu_h) then
         if (rank == 0) then
            call logger%warning("&ocean_hvisc_nml ah_max = "//to_string(ah_max)// &
                                " is BELOW nu_h = "//to_string(nu_h)//" — ah_max is a hard CAP on the "// &
                                "per-face viscosity, so the configured nu_h is silently clamped down to "// &
                                "ah_max and never actually applied. Set ah_max >= nu_h (e.g. ah_max = "// &
                                to_string(nu_h)//" or higher).")
         end if
      end if

      ! ---- Check 5: terrain-following stiffness rx0 (WARNING) ----
      ! Geometry only — no dt, no nz, no viscosity. A sigma-family
      ! coordinate divides the COLUMN into nz layers, so a big column
      ! contrast across one face offsets the two columns' K-th interfaces
      ! by Delta_e ~ rx0*(H_a+H_b), and the pressure-gradient truncation
      ! goes as Delta_e^3. See SIGMA_STIFFNESS_LIMIT for the citations and
      ! for the ISOMIP+ Ocean0 failure that motivated this.
      if (present(column)) then
         if (ocean_vcoord_is_terrain_following( &
             parse_vcoord_type(cfg%vcoord_type, VCOORD_SIGMA))) then
            block
               real(wp) :: rx0_max, h_thin, h_thick
               integer :: i_rx, j_rx, n_over, n_face
               logical :: rx_is_x
               call ocean_sigma_stiffness_worst( &
                  size(column, 1), size(column, 2), &
                  grid%nghost + 1, grid%nghost + grid%nx_phys, &
                  grid%nghost + 1, grid%nghost + grid%ny_phys, &
                  column, metrics%wet_T, rx0_max, i_rx, j_rx, rx_is_x, &
                  h_thin, h_thick, n_over, n_face)
               if (rx0_max > SIGMA_STIFFNESS_LIMIT .and. rank == 0) then
                  call logger%warning("Terrain-following stiffness rx0 = "// &
                                      to_string(rx0_max)//" exceeds "// &
                                      to_string(SIGMA_STIFFNESS_LIMIT)//" on the '"// &
                                      trim(adjustl(cfg%vcoord_type))// &
                                      "' vertical coordinate: a "//to_string(h_thin)// &
                                      " m column sits beside a "//to_string(h_thick)// &
                                      " m one across a single "// &
                                      merge("x", "y", rx_is_x)//" face near i="// &
                                      to_string(i_rx)//" j="//to_string(j_rx)//" ("// &
                                      to_string(n_over)//" of "//to_string(n_face)// &
                                      " wet-wet faces are over the bound). The sigma "// &
                                      "pressure-gradient truncation scales as the CUBE of the "// &
                                      "interface offset between neighbouring columns, so this "// &
                                      "is a LARGE spurious force, and a quiescent or long run "// &
                                      "over this geometry will measure it rather than the "// &
                                      "physics. A FORCED, viscous run is normally fine here — a "// &
                                      "constant lateral-viscosity floor arrests a steady spurious "// &
                                      "force rather than integrating it, which is what the shipped "// &
                                      "nu_h = 10000 double-gyre and neverworld2 cases rely on. A "// &
                                      "quiescent, weakly-damped or long spin-up run is NOT: smooth "// &
                                      "the topography to rx0 <= "//to_string(SIGMA_STIFFNESS_LIMIT)// &
                                      " (under an ice shelf, raising &ocean_cavity_dyn_nml "// &
                                      "h_min_cavity removes the thinnest columns), or use a "// &
                                      "coordinate whose interfaces do not follow the topography. "// &
                                      "Viscosity and a smaller dt DELAY a runaway, they do not "// &
                                      "remove the error.")
               end if
            end block
         end if
      end if

      if (present(ierr)) ierr = OCEAN_STATUS_OK
   end subroutine ocean_stability_audit

end module rdb_ocean_stability_audit
