rdb_ocean_diag.F90 Source File

Ocean diagnostics manager.


This file depends on

sourcefile~~rdb_ocean_diag.f90~~EfferentGraph sourcefile~rdb_ocean_diag.f90 rdb_ocean_diag.F90 sourcefile~rdb_constants.f90 rdb_constants.F90 sourcefile~rdb_ocean_diag.f90->sourcefile~rdb_constants.f90 sourcefile~rdb_error_ring.f90 rdb_error_ring.F90 sourcefile~rdb_ocean_diag.f90->sourcefile~rdb_error_ring.f90 sourcefile~rdb_grid.f90 rdb_grid.F90 sourcefile~rdb_ocean_diag.f90->sourcefile~rdb_grid.f90 sourcefile~rdb_mem_report.f90 rdb_mem_report.F90 sourcefile~rdb_ocean_diag.f90->sourcefile~rdb_mem_report.f90 sourcefile~rdb_ocean_diag_mask.f90 rdb_ocean_diag_mask.F90 sourcefile~rdb_ocean_diag.f90->sourcefile~rdb_ocean_diag_mask.f90 sourcefile~rdb_ocean_status.f90 rdb_ocean_status.F90 sourcefile~rdb_ocean_diag.f90->sourcefile~rdb_ocean_status.f90 sourcefile~rdb_grid.f90->sourcefile~rdb_constants.f90 sourcefile~rdb_mem_report.f90->sourcefile~rdb_constants.f90 sourcefile~rdb_ocean_diag_mask.f90->sourcefile~rdb_constants.f90 sourcefile~rdb_ocean_diag_mask.f90->sourcefile~rdb_grid.f90 sourcefile~rdb_ocean_diag_mask.f90->sourcefile~rdb_mem_report.f90

Files dependent on this one

sourcefile~~rdb_ocean_diag.f90~~AfferentGraph sourcefile~rdb_ocean_diag.f90 rdb_ocean_diag.F90 sourcefile~rdb_ocean_diag_derived.f90 rdb_ocean_diag_derived.F90 sourcefile~rdb_ocean_diag_derived.f90->sourcefile~rdb_ocean_diag.f90 sourcefile~rdb_ocean_diag_fills.f90 rdb_ocean_diag_fills.F90 sourcefile~rdb_ocean_diag_derived.f90->sourcefile~rdb_ocean_diag_fills.f90 sourcefile~rdb_ocean_state.f90 rdb_ocean_state.F90 sourcefile~rdb_ocean_diag_derived.f90->sourcefile~rdb_ocean_state.f90 sourcefile~rdb_ocean_diag_fills.f90->sourcefile~rdb_ocean_diag.f90 sourcefile~rdb_ocean_diag_fills.f90->sourcefile~rdb_ocean_state.f90 sourcefile~rdb_ocean_diag_netcdf.f90 rdb_ocean_diag_netcdf.F90 sourcefile~rdb_ocean_diag_netcdf.f90->sourcefile~rdb_ocean_diag.f90 sourcefile~rdb_ocean_engine.f90 rdb_ocean_engine.F90 sourcefile~rdb_ocean_engine.f90->sourcefile~rdb_ocean_diag.f90 sourcefile~rdb_ocean_engine.f90->sourcefile~rdb_ocean_diag_derived.f90 sourcefile~rdb_ocean_engine.f90->sourcefile~rdb_ocean_diag_fills.f90 sourcefile~rdb_ocean_engine.f90->sourcefile~rdb_ocean_diag_netcdf.f90 sourcefile~rdb_ocean_engine.f90->sourcefile~rdb_ocean_state.f90 sourcefile~rdb_ocean_setup.f90 rdb_ocean_setup.F90 sourcefile~rdb_ocean_engine.f90->sourcefile~rdb_ocean_setup.f90 sourcefile~rdb_ocean_state.f90->sourcefile~rdb_ocean_diag.f90 sourcefile~rdb_driver.f90 rdb_driver.F90 sourcefile~rdb_driver.f90->sourcefile~rdb_ocean_engine.f90 sourcefile~rdb_driver.f90->sourcefile~rdb_ocean_state.f90 sourcefile~rdb_handle.f90 rdb_handle.F90 sourcefile~rdb_handle.f90->sourcefile~rdb_ocean_engine.f90 sourcefile~rdb_handle.f90->sourcefile~rdb_ocean_state.f90 sourcefile~rdb_ocean_api.f90 rdb_ocean_api.F90 sourcefile~rdb_ocean_api.f90->sourcefile~rdb_ocean_diag_derived.f90 sourcefile~rdb_ocean_api.f90->sourcefile~rdb_ocean_diag_fills.f90 sourcefile~rdb_ocean_api.f90->sourcefile~rdb_ocean_engine.f90 sourcefile~rdb_ocean_api.f90->sourcefile~rdb_handle.f90 sourcefile~rdb_ocean_setup.f90->sourcefile~rdb_ocean_state.f90

Source Code

!! Ocean diagnostics manager.
module rdb_ocean_diag
   !! Owns the diagnostics pipeline for the ocean dynamical core.
   !! Built around the following design points (Phase 6 work):
   !!
   !!   1. **Pull model, not push.**  Kernels write to `state` and
   !!      stay diag-agnostic; this manager iterates a registry each
   !!      output step, fetches the source array (by pointer / by
   !!      enum tag), remaps onto the configured output vertical
   !!      grid, accumulates time means, and hands the ready buffer
   !!      to the I/O server.  Kernels never call `diag%send(...)`.
   !!
   !!   2. **Compute-side remap.**  Layer→z, layer→isopycnal, and
   !!      time-mean accumulation all happen on the compute rank
   !!      before hand-off.  The I/O server then only has to do
   !!      compression + netcdf write.  Keeps the I/O server lean
   !!      (no physics-aware code) at the cost of carrying the
   !!      remap target buffers on every compute rank — which is
   !!      cheap since they're shape `(nx, ny, nz_out)` not
   !!      `(nx_global, ny_global, nz_out)`.
   !!
   !!   3. **I/O server interaction.**  When a `diag_var_t`'s
   !!      cadence fires, this manager calls `io_server_send(buf,
   !!      meta)` on the per-rank send queue.  Backpressure (server
   !!      slower than compute) shows up as a hung send and is
   !!      logged.  Hand-off contract: the manager owns the buffer
   !!      lifetime; the I/O server gets a non-owning view.
   !!
   !! Phase 0e status: empty scaffold.  Components are declared so
   !! Phase 6 can fill in `register / step / send_ready` without
   !! restructuring the god state.  The bound procedures init/destroy
   !! are no-ops on the empty registry.
   !!
   !! Collaborator hand-off: this slot is independent of the
   !! dynamical core — once `ocean_diag_register` lands, every other
   !! kernel can be diagnosed without modification.  Good first task
   !! for a new contributor with FMS / diag_manager experience.
#ifdef LFORTRAN_PASSING
   use rdb_constants, only: wp
#else
   use rdb_constants, only: NZ_STACK_MAX, wp
#endif
   use rdb_grid, only: hgrid_t
   use rdb_ocean_diag_mask, only: diag_mask_t, diag_mask_destroy
   use pic_logger, only: logger => global_logger
   use, intrinsic :: iso_fortran_env, only: int64, real32
   use, intrinsic :: ieee_arithmetic, only: ieee_is_finite
   use rdb_mem_report, only: arr_bytes
   use rdb_ocean_status, only: OCEAN_STATUS_OK, OCEAN_STATUS_ERR_SETUP
   use rdb_error_ring, only: fail
   implicit none
   private
#ifdef LFORTRAN_PASSING
   integer, parameter :: NZ_STACK_MAX = 64
      !! LFortran 0.64 workaround: module-local copy of the rdb_constants value
      !! (an imported parameter used as an explicit-shape dummy bound inside a
      !! PURE call becomes an impure getter under LFortran). Keep in sync (=64).
#endif

   public :: ocean_diag_t, diag_var_t, diag_fill_proc, diag_remap_proc
   public :: diag_emit_proc, ocean_diag_nc_stream_t
   public :: diag_spec_t, parse_diag_spec
   public :: diag_reduce_stats, diag_field_stats
      !! Exposed for `test_ocean_diag_reduce`: the emit-path statistics
      !! reduction is the only device kernel in this module, so it is unit
      !! tested directly (numerics + `mem:separate` residency) rather than
      !! only through `step`.

   abstract interface
      subroutine diag_fill_proc(state_handle, buf)
         !! Fill routine signature.  `state_handle` is polymorphic so
         !! this module stays decoupled from `ocean_state_t` (which
         !! itself composes `ocean_diag_t`).  Implementations live in
         !! `rdb_ocean_diag_fills` and do a `select type` cast to the
         !! concrete ocean state inside the body.  `buf` is the
         !! pre-allocated output buffer the manager owns.
         import :: wp
         implicit none
         class(*), intent(in) :: state_handle
         real(wp), intent(inout) :: buf(:, :, :)
      end subroutine diag_fill_proc

      subroutine diag_emit_proc(diag, ivar, t)
         !! Optional post-fire emit hook.  Called by `step` once per
         !! cadence-fire AFTER the log line; used by the NetCDF
         !! writer to append a slice for the var that just fired.
         !! Kept abstract so the diag module stays free of NetCDF /
         !! I/O-server deps — `rdb_ocean_diag_netcdf` provides the
         !! concrete implementation.
         import :: wp
         implicit none
         class(*), intent(inout) :: diag
         integer, intent(in) :: ivar
         real(wp), intent(in) :: t
      end subroutine diag_emit_proc

      subroutine diag_remap_proc(state_handle, z_out, layer_buf, output_buf, is_extensive)
         !! Vertical-remap routine signature.  Called by the manager
         !! after `fill` when a var's `output_vgrid` is not LAYER.
         !! `layer_buf` is the manager-owned native-grid scratch the
         !! fill wrote into; `output_buf` is the remap target on the
         !! configured output vgrid (z-levels / isopycnals / 2D).
         !! State-side data needed for the remap (e.g. `h_layer`,
         !! bathy) is fetched via `select type` on `state_handle`.
         !!
         !! Conservation contract:  `is_extensive` (forwarded by the
         !! manager from `diag_var_t%is_extensive`) selects the operator.
         !! `.false.` — INTENSIVE: the target value is the
         !! thickness-weighted average of the overlapping source layers.
         !! `.true.` — EXTENSIVE: the column integral is conservatively
         !! REDISTRIBUTED (Σ output == Σ layer_buf), for thickness-
         !! integrated quantities (hTr, h·KE, transports).
         import :: wp
         implicit none
         class(*), intent(in) :: state_handle
         real(wp), intent(in) :: z_out(:)
         real(wp), intent(in) :: layer_buf(:, :, :)
         real(wp), intent(inout) :: output_buf(:, :, :)
         logical, intent(in) :: is_extensive
      end subroutine diag_remap_proc
   end interface

   ! ---- Source-array location tags ----
   ! Identifies where in `ocean_state` the source data lives, so the
   ! remap step knows which stagger / shape to consume.
   integer, parameter, public :: DIAG_LOC_CENTER = 1  !! cell centre
   integer, parameter, public :: DIAG_LOC_FACE_X = 2  !! east face
   integer, parameter, public :: DIAG_LOC_FACE_Y = 3  !! north face
   integer, parameter, public :: DIAG_LOC_CORNER = 4  !! south-west corner

   ! ---- Vertical-grid tags ----
   ! Source vgrid: what the model stores.  Output vgrid: what we
   ! remap to before handing the buffer to the I/O server.
   integer, parameter, public :: DIAG_VGRID_LAYER = 1
      !! Model layers (k=1 bed, k=nz surface).
   integer, parameter, public :: DIAG_VGRID_Z_FIXED = 2
      !! Fixed z-levels (e.g., 50 standard depths).
   integer, parameter, public :: DIAG_VGRID_DENSITY = 3
      !! Isopycnal bins for watermass analysis.
   integer, parameter, public :: DIAG_VGRID_SURFACE = 4
      !! 2D surface slice (no remap needed).
   integer, parameter, public :: DIAG_VGRID_BOTTOM = 5
      !! 2D bed slice.
   integer, parameter, public :: DIAG_VGRID_ZSTAR = 6
      !! Fixed z*-levels: SSH-tracking stretched-depth output grid
      !! (each column's target depths scale with col_h / resting depth).
   integer, parameter, public :: DIAG_VGRID_SIGMA = 7
      !! Fixed sigma-levels: terrain-following fractional-depth output
      !! grid (target interface depth = sigma fraction * col_h).

   real(wp), parameter, public :: DIAG_MISSING_VALUE = 1.0e20_wp
      !! Sentinel written into remapped output cells that overlap no water
      !! (below-bottom / pinched-out) when vanished-target masking is on,
      !! and advertised as the NetCDF `_FillValue` / `missing_value`.

   ! ---- Time-operator tags ----
   integer, parameter, public :: DIAG_OP_INSTANT = 1  !! snapshot at cadence
   integer, parameter, public :: DIAG_OP_MEAN = 2  !! dt-weighted time mean over cadence
   integer, parameter, public :: DIAG_OP_MAX = 3
   integer, parameter, public :: DIAG_OP_MIN = 4
   integer, parameter, public :: DIAG_OP_INTEGRAL = 5
      !! Cumulative time integral over the cadence window —
      !! emits Σ(sample · dt) without dividing.  Used for budget
      !! closure: time-integrated fluxes through a surface or
      !! through an OBC face are the conserved quantity, not
      !! their per-step rate.

   ! ---- Unified diagnostic-selection spec ----
   integer, parameter, public :: DIAG_OP_UNSET = -1
      !! `diag_spec_t%time_op` sentinel: attribute not given => keep the
      !! diagnostic's canonical default time-operator.
   integer, parameter, public :: DIAG_COORD_UNSET = 0
      !! `diag_spec_t%coord` sentinel: no `:coord` attribute given => keep
      !! the diagnostic's default output vgrid (the global `&ocean_diag_nml
      !! vgrid`).  Set values are `DIAG_VGRID_*` (layer / z / zstar / sigma).

   type :: diag_spec_t
      !! One parsed entry of the `&ocean_diag_nml diags` selection list.
      !! Produced by `parse_diag_spec`; consumed by `register_default_diags`
      !! (canonical-default overrides / skips) and the derived-diagnostic
      !! orchestrator (`apply_diag_selection`).  Unset attributes carry
      !! sentinels so the consumer falls back to the canonical default.
      character(len=64) :: name = ""        !! diagnostic name (catalog or canonical)
      logical  :: off = .false.             !! `:off` => skip this diagnostic
      integer  :: time_op = DIAG_OP_UNSET   !! `:instant/mean/max/min` override
      real(wp) :: dt_out = -1.0_wp          !! `:<n>s/m/h/d` cadence override (<0 = unset)
      integer  :: coord = DIAG_COORD_UNSET  !! `:layer/z/zstar/sigma` output-vgrid override
   end type diag_spec_t

   type :: ocean_diag_nc_stream_t
      !! NetCDF output stream state.  Carried inline on `ocean_diag_t`
      !! so the diag module stays NetCDF-free (no `use netcdf` here);
      !! the actual file operations live in `rdb_ocean_diag_netcdf`
      !! which manipulates these fields.
      logical :: is_open = .false.
      integer :: ncid = -1
      character(len=256) :: filename = ""
      integer :: x_dimid = -1
      integer :: y_dimid = -1
      integer :: xtype = -1
         !! Resolved NetCDF element type for the DATA variables of this
         !! stream (`NC_WP` / `NC_R4` — see `rdb_io_netcdf`).  `-1` = unset;
         !! `open_stream` resolves it from `&ocean_diag_nml
         !! output_precision` ("double", the default, => `NC_WP` =>
         !! byte-identical output).  Held as a bare integer so this module
         !! stays NetCDF-free; only `rdb_ocean_diag_netcdf` interprets it.
         !!
         !! Scope note: this governs the DIAGNOSTIC stream only.  Restarts,
         !! gauges, coastal output, console conservation totals and
         !! checksums are unconditionally `NC_WP`/`wp` — there is
         !! deliberately no knob that can make a restart lossy.
      real(real32), allocatable :: stage(:, :, :)
         !! Host staging buffer for the fp64 -> fp32 conversion done
         !! immediately before `nf90_put_var`.  Allocated by `open_stream`
         !! ONLY when `xtype == NC_R4`, sized to the largest registered
         !! variable's `output_buffer`; unallocated (zero cost) on the
         !! default double-precision path.
   end type ocean_diag_nc_stream_t

   type :: diag_var_t
      !! Per-variable diagnostic record.  Owned by ocean_diag_t.
      character(len=64)  :: name = ""
         !! Short netcdf variable name.
      character(len=128) :: long_name = ""
         !! CF-compliant long_name attribute.
      character(len=32)  :: units = ""
      character(len=64)  :: standard_name = ""

      ! ---- Source binding ----
      ! Procedure pointer to the per-var fill routine.  Set at
      ! `register` time by the slot that owns the source data; the
      ! manager invokes it on cadence-fire to populate the buffer.
      ! `nopass` because the registry holds the bind, not the var.
      procedure(diag_fill_proc), pointer, nopass :: fill => null()
      procedure(diag_remap_proc), pointer, nopass :: remap => null()
         !! Optional vertical-remap routine.  Set at register time
         !! when `output_vgrid /= DIAG_VGRID_LAYER`; null otherwise.
      integer :: source_loc = DIAG_LOC_CENTER
      integer :: source_vgrid = DIAG_VGRID_LAYER

      ! ---- Output binding ----
      integer  :: output_vgrid = DIAG_VGRID_LAYER
      integer  :: time_op = DIAG_OP_MEAN
      real(wp) :: dt_out = 3600.0_wp
         !! Output cadence (s).  Manager keeps a per-var counter and
         !! fires when the accumulated dt passes the threshold.

      ! ---- Buffers ----
      ! `output_buffer` is the post-remap buffer that gets shipped
      ! to the I/O server.  `layer_buffer` is the manager-owned
      ! native-grid scratch the `fill` routine writes into when a
      ! remap step is required; for `output_vgrid == LAYER` the
      ! fill writes directly to `output_buffer` and `layer_buffer`
      ! stays unallocated.  `accumulator` is the running-sum buffer
      ! used when `time_op /= DIAG_OP_INSTANT`.
      real(wp), allocatable :: output_buffer(:, :, :)
      real(wp), allocatable :: layer_buffer(:, :, :)
      real(wp), allocatable :: accumulator(:, :, :)
      integer :: n_accum = 0
         !! Number of contributions in the current accumulator window.
      real(wp) :: dt_accum = 0.0_wp
         !! Wall time accumulated into this var's window (s).

      ! ---- Region restriction ----
      type(diag_mask_t), allocatable :: mask
         !! Optional region mask.  When allocated, `fold_sample`
         !! multiplies each sample by `mask%weight(i, j)` before
         !! folding into the accumulator — cells outside the region
         !! contribute zero.  Output buffer shape is unchanged (full
         !! domain with zeros outside the mask); scalar-aggregating
         !! reductions land with the Phase D budget plumbing or as
         !! a Phase C v2 follow-on.

      ! ---- Vertical quantity kind ----
      logical :: is_extensive = .false.
         !! `.false.` (default): field is INTENSIVE — per-unit-thickness
         !! quantity like temperature, salinity, velocity, density.
         !! When remapped to a non-LAYER vgrid, the column value is
         !! weight-averaged across overlapping source layers.
         !!
         !! `.true.`: field is EXTENSIVE — already thickness-integrated,
         !! e.g. `hTr` (tracer · m), KE per layer (h · 0.5 · |u|²),
         !! transport per layer (h · u).  When remapped, the column
         !! values must be conservatively REDISTRIBUTED across the
         !! target layers (sum preserved), not averaged.
         !!
         !! Phase B v1 status: the existing `remap_layer_to_z` is
         !! intensive-only.  Extensive remap (and the corresponding
         !! split inside `diag_remap_proc`) lands with isopycnal /
         !! density-bin remap in Phase E.  Flag is here now so calling
         !! code can declare intent and the upgrade is non-breaking.

      ! ---- Enable gate ----
      logical :: enabled = .true.
         !! When `.false.` the dispatcher skips this var entirely — no
         !! `fill_*` kernel, no accumulator fold, no emit — so an
         !! unrequested diagnostic costs zero GPU work.  Lets a run turn
         !! off individual default diagnostics it does not want.  Default
         !! `.true.` ⇒ every registered var runs (bit-identical).

      ! ---- Vanished-target masking ----
      logical :: has_missing = .false.
         !! When `.true.` the remap fills target cells that overlap no water
         !! (below-bottom / pinched-out in a shallow column) with
         !! `DIAG_MISSING_VALUE` instead of 0, and the NetCDF writer tags the
         !! variable with a `_FillValue` / `missing_value` attribute.  Set at
         !! register time for non-LAYER diagnostics when masking is enabled
         !! (`&ocean_diag_nml mask_vanished_layers`).  Default `.false.` =>
         !! below-bottom cells read 0 (bit-identical to the legacy writer).

      ! ---- I/O server binding ----
      integer :: stream_id = 0
         !! ID of the output stream this var ships to (Phase 6 wires
         !! the I/O server stream table).

      ! ---- Per-var NetCDF state ----
      ! Populated by `rdb_ocean_diag_netcdf` when the stream is
      ! opened; consumed by the NetCDF emit hook on each fire.
      integer :: nc_varid = -1
      integer :: nc_time_dimid = -1
      integer :: nc_time_varid = -1
      integer :: nc_z_dimid = -1
         !! -1 for 2D vars; set for 3D vars when the stream is opened.
      integer :: nc_time_index = 0
         !! Number of slices already written for this var (1-indexed
         !! position of the NEXT write).

      ! ---- In-memory access generation (P7) ----
      integer :: fire_count = 0
         !! Number of times this var's `output_buffer` has been
         !! refreshed (filled/folded/finalised + pulled host-ward) since
         !! registration. Incremented unconditionally in `ocean_diag_step`
         !! at the SAME point as the per-fire `update self` — independent
         !! of whether a NetCDF stream is open (`nc_time_index` stays 0
         !! with diagnostics disabled or output suppressed, so it cannot
         !! serve this role). This is the `generation` the C ABI's
         !! `rdb_ocean_get_diagnostic_ptr` returns: a Python `Field`
         !! re-checks it on access and knows `output_buffer` is already
         !! host-current the instant it changes (no separate refresh call
         !! needed — the pull above already happened synchronously).
   contains
      procedure, non_overridable :: bytes => diag_var_bytes
   end type diag_var_t

   type :: ocean_diag_t
      logical :: is_init = .false.
         !! True between `init` and `destroy`.  Prefer this to
         !! `allocated(...)` — tracks GPU device attachment too.
      logical :: on_device = .false.
         !! `.true.` between `enter_data` and `exit_data`.  The emit-time
         !! statistics reduction reads `output_buffer` where it actually
         !! lives: on the device via `do concurrent ... reduce` when mapped,
         !! on the host otherwise (unit tests that skip `enter_data`).  A
         !! device read of an unmapped buffer under `-gpu=mem:separate`
         !! would return garbage silently, so this is not optional.

      ! ---- Registry ----
      integer :: nvars = 0
         !! Live count of registered diagnostics.
      integer :: nvars_max = 0
         !! Capacity of `vars(:)`.  Grown via reallocation on
         !! `register` overflow.
      type(diag_var_t), allocatable :: vars(:)

      ! ---- Output vertical grids ----
      ! Configured at init from the namelist.  The manager looks up
      ! `vars(i)%output_vgrid` and dispatches the remap onto the
      ! matching array.  Phase 6 wires the remap kernels.
      integer  :: nz_out = 0
      real(wp), allocatable :: z_out(:)
         !! Output z-levels (m, positive up; surface at index nz_out).
      integer  :: n_rho_out = 0
      real(wp), allocatable :: rho_out(:)
         !! Output isopycnal bin edges (kg/m^3).
      integer  :: n_sigma_out = 0
      real(wp), allocatable :: sigma_out(:)
         !! Output sigma levels: cumulative fractions (0..1, shallow->deep).
      integer  :: n_zstar_out = 0
      real(wp), allocatable :: zstar_out(:)
         !! Output z* reference interface depths (m, positive-down; deepest =
         !! reference total depth H_ref).  Per-column grid stretched by
         !! col_h / H_ref (SSH-tracking).

      ! ---- I/O-server send buffer ----
      ! Pre-allocated, reused across diag pushes.  Sized to the
      ! largest registered variable's `output_buffer`.  Sending uses
      ! a zero-copy `c_loc` view into this buffer.
      real(wp), allocatable :: send_buf(:, :, :)

      ! ---- Triggering scalars ----
      logical :: enabled = .true.
         !! Master switch.  Phase 6 reads from namelist.
      real(wp) :: dt_last_eval = 0.0_wp
         !! Wall time at last evaluation pass (s).

      ! ---- NetCDF stream ----
      ! Inline state (no `use netcdf` here); the writer module
      ! `rdb_ocean_diag_netcdf` opens / writes / closes against this.
      type(ocean_diag_nc_stream_t) :: nc_stream
      procedure(diag_emit_proc), pointer, nopass :: emit_post_fire => null()
         !! Optional post-fire hook the manager calls after the log
         !! line.  `rdb_ocean_diag_netcdf::open_stream` binds this to
         !! the NetCDF writer; unbound = log-only behaviour.

      type(hgrid_t) :: grid
         !! Cached grid (scalar-only struct, cheap to copy).  Derived
         !! diagnostic fills read `grid%nghost` from here (and the
         !! per-cell spacing from `state%metrics` directly — design D5);
         !! they only see `state_handle` so the grid has to be reachable
         !! through the state composition.
   contains
      procedure, non_overridable :: init => ocean_diag_init
      procedure, non_overridable :: destroy => ocean_diag_destroy
      procedure, non_overridable :: register => ocean_diag_register
      procedure, non_overridable :: disable => ocean_diag_disable
      procedure, non_overridable :: is_registered => ocean_diag_is_registered
      procedure, non_overridable :: step => ocean_diag_step
      procedure, non_overridable :: set_output_z_levels => ocean_diag_set_output_z_levels
      procedure, non_overridable :: set_output_density_levels => ocean_diag_set_output_density_levels
      procedure, non_overridable :: set_output_sigma_levels => ocean_diag_set_output_sigma_levels
      procedure, non_overridable :: set_output_zstar_levels => ocean_diag_set_output_zstar_levels
      procedure, non_overridable :: enter_data => ocean_diag_enter_data
      procedure, non_overridable :: exit_data => ocean_diag_exit_data
      procedure, non_overridable :: bytes => ocean_diag_bytes
   end type ocean_diag_t

   integer, parameter :: INITIAL_CAPACITY = 16

contains

   function parse_diag_spec(spec) result(specs)
      !! Parse the `&ocean_diag_nml diags` selection string into structured
      !! entries.  Entries are whitespace/comma-separated; within an entry,
      !! colon-separated attributes are self-identifying (order-free):
      !!
      !!   * `off`                        -> skip the diagnostic
      !!   * `instant`/`mean`/`max`/`min` -> time-operator override
      !!   * `<int><unit>` (s/m/h/d)      -> cadence override
      !!
      !! The first colon field is always the diagnostic name (case-sensitive,
      !! matched against registered / catalog names).  Attribute matching is
      !! case-insensitive.  An empty/blank string yields a zero-length array
      !! (the bit-identical default).  An unrecognised attribute fails loud.
      character(len=*), intent(in) :: spec
      type(diag_spec_t), allocatable :: specs(:)
      character(len=len(spec)) :: buf
      integer :: i, j, n, ntok, k

      ! Normalise commas to spaces so both separators work.
      buf = spec
      do i = 1, len(buf)
         if (buf(i:i) == ",") buf(i:i) = " "
      end do
      n = len_trim(buf)

      ! Pass 1: count whitespace-delimited tokens.
      ntok = 0
      i = 1
      do while (i <= n)
         if (buf(i:i) == " ") then
            i = i + 1
            cycle
         end if
         ntok = ntok + 1
         do while (i <= n)
            if (buf(i:i) == " ") exit
            i = i + 1
         end do
      end do

      allocate (specs(ntok))
      if (ntok == 0) return

      ! Pass 2: parse each token into name + attributes.
      k = 0
      i = 1
      do while (i <= n)
         if (buf(i:i) == " ") then
            i = i + 1
            cycle
         end if
         j = i
         do while (j <= n)
            if (buf(j:j) == " ") exit
            j = j + 1
         end do
         k = k + 1
         call parse_one_spec_token(buf(i:j - 1), specs(k))
         i = j + 1
      end do
   end function parse_diag_spec

   subroutine parse_one_spec_token(tok, s)
      !! Parse a single `name[:attr]...` token into a `diag_spec_t`.
      !! Fails loud on an unrecognised attribute.
      character(len=*), intent(in) :: tok
      type(diag_spec_t), intent(out) :: s
      integer :: p, q, m
      real(wp) :: secs
      logical :: ok
      character(len=:), allocatable :: attr

      m = len_trim(tok)
      ! First colon field is the name.
      p = index(tok(1:m), ":")
      if (p == 0) then
         s%name = tok(1:m)
         return
      end if
      s%name = tok(1:p - 1)

      ! Remaining colon-separated fields are attributes.
      p = p + 1
      do while (p <= m)
         q = index(tok(p:m), ":")
         if (q == 0) then
            attr = lower_ascii(tok(p:m))
            p = m + 1
         else
            attr = lower_ascii(tok(p:p + q - 2))
            p = p + q
         end if
         if (len_trim(attr) == 0) cycle

         select case (attr)
         case ("off")
            s%off = .true.
         case ("instant")
            s%time_op = DIAG_OP_INSTANT
         case ("mean")
            s%time_op = DIAG_OP_MEAN
         case ("max")
            s%time_op = DIAG_OP_MAX
         case ("min")
            s%time_op = DIAG_OP_MIN
         case ("integral")
            s%time_op = DIAG_OP_INTEGRAL
         case ("layer")
            s%coord = DIAG_VGRID_LAYER
         case ("z")
            s%coord = DIAG_VGRID_Z_FIXED
         case ("zstar", "z*")
            s%coord = DIAG_VGRID_ZSTAR
         case ("sigma")
            s%coord = DIAG_VGRID_SIGMA
         case ("density", "rho")
            s%coord = DIAG_VGRID_DENSITY
         case default
            call parse_cadence_attr(attr, secs, ok)
            if (ok) then
               s%dt_out = secs
            else
               call logger%error("unknown diag attribute '"//attr// &
                                 "' in spec token '"//trim(tok)// &
                                 "'; valid: off | instant|mean|max|min|integral | "// &
                                 "layer|z|zstar|sigma|density | <int>s/m/h/d")
               error stop "parse_diag_spec: unknown attribute"
            end if
         end select
      end do
   end subroutine parse_one_spec_token

   pure subroutine parse_cadence_attr(s, secs, ok)
      !! Parse a cadence attribute `<int><unit>` (unit s/m/h/d) to seconds.
      !! `ok=.false.` if `s` is not a well-formed positive cadence.
      character(len=*), intent(in) :: s
      real(wp), intent(out) :: secs
      logical, intent(out) :: ok
      integer :: n, ios, val
      character :: unit
      character(len=128) :: ferr

      secs = -1.0_wp
      ok = .false.
      n = len_trim(s)
      if (n < 2) return
      unit = s(n:n)
      read (s(1:n - 1), *, iostat=ios, iomsg=ferr) val
      if (ios /= 0 .or. val <= 0) return
      select case (unit)
      case ("s")
         secs = real(val, wp)
      case ("m")
         secs = real(val, wp)*60.0_wp
      case ("h")
         secs = real(val, wp)*3600.0_wp
      case ("d")
         secs = real(val, wp)*86400.0_wp
      case default
         return
      end select
      ok = .true.
   end subroutine parse_cadence_attr

   pure function lower_ascii(s) result(out)
      !! ASCII lowercase a string (attribute matching is case-insensitive).
      character(len=*), intent(in) :: s
      character(len=len(s)) :: out
      integer :: i, c
      do i = 1, len(s)
         c = iachar(s(i:i))
         if (c >= iachar("A") .and. c <= iachar("Z")) then
            out(i:i) = achar(c + 32)
         else
            out(i:i) = s(i:i)
         end if
      end do
   end function lower_ascii

   subroutine ocean_diag_init(this, grid)
      !! Allocate an empty registry sized at `INITIAL_CAPACITY` slots.
      !! Subsequent `register` calls grow the array via doubling.
      class(ocean_diag_t), intent(inout) :: this
      type(hgrid_t), intent(in) :: grid
      this%grid = grid
      this%nvars = 0
      this%nvars_max = INITIAL_CAPACITY
      allocate (this%vars(this%nvars_max))
      this%is_init = .true.
   end subroutine ocean_diag_init

   subroutine ocean_diag_destroy(this)
      class(ocean_diag_t), intent(inout) :: this
      integer :: i
      this%is_init = .false.
      if (allocated(this%vars)) then
         do i = 1, this%nvars
            if (allocated(this%vars(i)%output_buffer)) deallocate (this%vars(i)%output_buffer)
            if (allocated(this%vars(i)%layer_buffer)) deallocate (this%vars(i)%layer_buffer)
            if (allocated(this%vars(i)%accumulator)) deallocate (this%vars(i)%accumulator)
            if (allocated(this%vars(i)%mask)) then
               call diag_mask_destroy(this%vars(i)%mask)
               deallocate (this%vars(i)%mask)
            end if
            nullify (this%vars(i)%fill)
            nullify (this%vars(i)%remap)
         end do
         deallocate (this%vars)
      end if
      this%nvars = 0
      this%nvars_max = 0
      if (allocated(this%z_out)) deallocate (this%z_out)
      if (allocated(this%rho_out)) deallocate (this%rho_out)
      if (allocated(this%sigma_out)) deallocate (this%sigma_out)
      if (allocated(this%zstar_out)) deallocate (this%zstar_out)
      if (allocated(this%send_buf)) deallocate (this%send_buf)
      ! Host-only fp32 staging buffer for the single-precision diag stream
      ! (never device-mapped, so no exit_data pairing).  Normally released
      ! by `close_stream`; freed here too so a destroy without a close
      ! does not leak.
      if (allocated(this%nc_stream%stage)) deallocate (this%nc_stream%stage)
   end subroutine ocean_diag_destroy

   subroutine ocean_diag_enter_data(this)
      !! Attach each registered var's per-buffer allocatables to the device.
      !! Called by `ocean_state_enter_data` after `register_default_diags`
      !! has populated `vars(:)` — buffers are sized at register time, so
      !! the descriptors here are valid.
      !!
      !! Per-var bookkeeping (dt_accum, n_accum, time_op, ...) stays on
      !! host; the manager step loop is host code that dispatches device
      !! kernels via the outer-shim + flat-impl pattern.  Only the bulk
      !! buffers (output_buffer, accumulator, layer_buffer, mask weight)
      !! need device residency.
      !! Type-bound wrapper — delegates to the non-polymorphic impl so the
      !! map base is the heap object, not a polymorphic stack box (AMD
      !! libomptarget cross-slot-overlap fix).
      class(ocean_diag_t), intent(inout) :: this
      select type (this)
      type is (ocean_diag_t)
         call ocean_diag_enter_data_impl(this)
      end select
   end subroutine ocean_diag_enter_data

   subroutine ocean_diag_enter_data_impl(this)
      type(ocean_diag_t), intent(inout) :: this
      integer :: i
      if (.not. this%is_init) return
      if (allocated(this%vars)) then
         do i = 1, this%nvars
            associate (v => this%vars(i))
               if (allocated(v%output_buffer)) then
                  !$acc enter data copyin(v%output_buffer)
               end if
               if (allocated(v%accumulator)) then
                  !$acc enter data copyin(v%accumulator)
               end if
               if (allocated(v%layer_buffer)) then
                  !$acc enter data copyin(v%layer_buffer)
               end if
               if (allocated(v%mask)) then
                  !$acc enter data copyin(v%mask)
                  associate (m => v%mask)
                     if (allocated(m%weight)) then
                        !$acc enter data copyin(m%weight)
                     end if
                  end associate
               end if
            end associate
         end do
      end if
      if (allocated(this%z_out)) then
         !$acc enter data copyin(this%z_out)
      end if
      if (allocated(this%rho_out)) then
         !$acc enter data copyin(this%rho_out)
      end if
      if (allocated(this%sigma_out)) then
         !$acc enter data copyin(this%sigma_out)
      end if
      if (allocated(this%zstar_out)) then
         !$acc enter data copyin(this%zstar_out)
      end if
      if (allocated(this%send_buf)) then
         !$acc enter data copyin(this%send_buf)
      end if
      this%on_device = .true.
   end subroutine ocean_diag_enter_data_impl

   subroutine ocean_diag_exit_data(this)
      !! Detach in reverse order of enter_data.  Idempotency-safe via
      !! `is_init` gate — repeated calls without intervening enter_data
      !! become no-ops once the manager is destroyed.
      class(ocean_diag_t), intent(inout) :: this
      select type (this)
      type is (ocean_diag_t)
         call ocean_diag_exit_data_impl(this)
      end select
   end subroutine ocean_diag_exit_data

   subroutine ocean_diag_exit_data_impl(this)
      type(ocean_diag_t), intent(inout) :: this
      integer :: i
      if (.not. this%is_init) return
      if (allocated(this%send_buf)) then
         !$acc exit data delete(this%send_buf)
      end if
      if (allocated(this%zstar_out)) then
         !$acc exit data delete(this%zstar_out)
      end if
      if (allocated(this%sigma_out)) then
         !$acc exit data delete(this%sigma_out)
      end if
      if (allocated(this%rho_out)) then
         !$acc exit data delete(this%rho_out)
      end if
      if (allocated(this%z_out)) then
         !$acc exit data delete(this%z_out)
      end if
      if (allocated(this%vars)) then
         do i = this%nvars, 1, -1
            associate (v => this%vars(i))
               if (allocated(v%mask)) then
                  associate (m => v%mask)
                     if (allocated(m%weight)) then
                        !$acc exit data delete(m%weight)
                     end if
                  end associate
                  !$acc exit data delete(v%mask)
               end if
               if (allocated(v%layer_buffer)) then
                  !$acc exit data delete(v%layer_buffer)
               end if
               if (allocated(v%accumulator)) then
                  !$acc exit data delete(v%accumulator)
               end if
               if (allocated(v%output_buffer)) then
                  !$acc exit data delete(v%output_buffer)
               end if
            end associate
         end do
      end if
      this%on_device = .false.
   end subroutine ocean_diag_exit_data_impl

   subroutine ocean_diag_register(this, name, units, fill, n1, n2, n3, &
                                  long_name, standard_name, time_op, dt_out, &
                                  output_vgrid, remap, mask, is_extensive, has_missing)
      !! Register a new diagnostic variable.  Grows the registry via
      !! capacity doubling on overflow.  Buffer allocation depends on
      !! `output_vgrid`:
      !!   * `LAYER` (default): one `output_buffer(n1, n2, n3)` —
      !!     `fill` writes directly into it.
      !!   * `Z_FIXED` (or any non-LAYER target): two buffers —
      !!     `layer_buffer(n1, n2, n3)` for the fill, plus
      !!     `output_buffer(n1, n2, this%nz_out)` for the remapped
      !!     result.  Caller must have configured `nz_out` via
      !!     `set_output_z_levels` first, and bind a `remap` proc.
      !! Caller binds `fill` to a routine that knows how to populate
      !! the layer-native buffer from the state handle.
      !!
      !! `mask` (optional): restricts accumulation to the region
      !! where `mask%weight > 0`.  See `diag_mask_t` builders in
      !! `rdb_ocean_diag_mask`.
      class(ocean_diag_t), intent(inout) :: this
      character(len=*), intent(in) :: name
      character(len=*), intent(in) :: units
      procedure(diag_fill_proc) :: fill
      integer, intent(in) :: n1, n2, n3
      character(len=*), intent(in), optional :: long_name, standard_name
      integer, intent(in), optional :: time_op
      real(wp), intent(in), optional :: dt_out
      integer, intent(in), optional :: output_vgrid
      procedure(diag_remap_proc), optional :: remap
      type(diag_mask_t), intent(in), optional :: mask
      logical, intent(in), optional :: is_extensive
      logical, intent(in), optional :: has_missing
      type(diag_var_t), allocatable :: tmp(:)
      integer :: i, ovgrid, nzout

      if (.not. this%is_init) return

      if (this%nvars == this%nvars_max) then
         allocate (tmp(2*this%nvars_max))
         do i = 1, this%nvars
            tmp(i) = this%vars(i)
         end do
         call move_alloc(tmp, this%vars)
         this%nvars_max = size(this%vars)
      end if

      ovgrid = DIAG_VGRID_LAYER
      if (present(output_vgrid)) ovgrid = output_vgrid

      this%nvars = this%nvars + 1
      associate (v => this%vars(this%nvars))
         v%name = name
         v%units = units
         v%fill => fill
         v%output_vgrid = ovgrid
         if (present(remap)) v%remap => remap
         if (present(long_name)) v%long_name = long_name
         if (present(standard_name)) v%standard_name = standard_name
         if (present(time_op)) v%time_op = time_op
         if (present(dt_out)) v%dt_out = dt_out
         if (present(has_missing)) v%has_missing = has_missing
         if (present(is_extensive)) v%is_extensive = is_extensive

         if (ovgrid == DIAG_VGRID_LAYER) then
            allocate (v%output_buffer(n1, n2, n3), source=0.0_wp)
         else
            allocate (v%layer_buffer(n1, n2, n3), source=0.0_wp)
            select case (ovgrid)
            case (DIAG_VGRID_DENSITY)
               nzout = this%n_rho_out
            case (DIAG_VGRID_SIGMA)
               nzout = this%n_sigma_out
            case (DIAG_VGRID_ZSTAR)
               nzout = this%n_zstar_out
            case default
               nzout = this%nz_out
            end select
            if (nzout <= 0) nzout = n3
            allocate (v%output_buffer(n1, n2, nzout), source=0.0_wp)
         end if

         if (v%time_op /= DIAG_OP_INSTANT) then
            allocate (v%accumulator(size(v%output_buffer, 1), &
                                    size(v%output_buffer, 2), &
                                    size(v%output_buffer, 3)))
            call reset_accumulator(v)
         end if
         v%n_accum = 0
         v%dt_accum = 0.0_wp

         if (present(mask)) then
            allocate (v%mask, source=mask)
         end if
      end associate
   end subroutine ocean_diag_register

   subroutine ocean_diag_disable(this, name)
      !! Turn OFF the registered diagnostic `name` so the dispatcher skips
      !! it entirely (no fill, no fold, no emit).  Fail-loud if `name`
      !! matches no registered var — a typo must not silently leave a
      !! diagnostic running.  Lists the registered names on abort.
      class(ocean_diag_t), intent(inout) :: this
      character(len=*), intent(in) :: name
      integer :: i
      character(len=1024) :: avail

      do i = 1, this%nvars
         if (trim(this%vars(i)%name) == trim(name)) then
            this%vars(i)%enabled = .false.
            return
         end if
      end do

      avail = ""
      do i = 1, this%nvars
         avail = trim(avail)//" "//trim(this%vars(i)%name)
      end do
      call logger%error("ocean_diag%disable: no registered diagnostic named '"// &
                        trim(name)//"' — registered:"//trim(avail))
      error stop "ocean_diag%disable: unknown diagnostic name"
   end subroutine ocean_diag_disable

   pure function ocean_diag_is_registered(this, name) result(yes)
      !! `.true.` iff a diagnostic named `name` is registered (enabled or
      !! not).  Registration state only — says nothing about whether it
      !! will actually fire (see `enabled`); a `disable`d diagnostic is
      !! still registered and this returns `.true.` for it.
      class(ocean_diag_t), intent(in) :: this
      character(len=*), intent(in) :: name
      logical :: yes
      integer :: i
      yes = .false.
      do i = 1, this%nvars
         if (trim(this%vars(i)%name) == trim(name)) then
            yes = .true.
            return
         end if
      end do
   end function ocean_diag_is_registered

   pure subroutine reset_accumulator(v)
      !! Zero (MEAN), -huge (MAX), or +huge (MIN) the accumulator —
      !! so the first accumulate step seeds correctly.  Runs on device
      !! via the flat-impl shim because `v%accumulator` is reached
      !! through the `vars(:)` array-of-derived-types indirection that
      !! NVHPC can't follow inside a `do concurrent`.
      type(diag_var_t), intent(inout) :: v
      real(wp) :: seed
      if (.not. allocated(v%accumulator)) return
      select case (v%time_op)
      case (DIAG_OP_MAX)
         seed = -huge(0.0_wp)
      case (DIAG_OP_MIN)
         seed = huge(0.0_wp)
      case default
         seed = 0.0_wp
      end select
      call fill_buffer_impl(v%accumulator, seed)
   end subroutine reset_accumulator

   pure subroutine fill_buffer_impl(buf, val)
      !! Device-side scalar fill.  Used by reset_accumulator and (when
      !! `idx_temperature` / `idx_salinity` is unset) the manager could
      !! also seed `output_buffer` via this; today only the accumulator
      !! reset goes through here.
      ! assumed-shape-ok: diag accumulator reset — fires once per output frame.
      real(wp), intent(inout) :: buf(:, :, :)
      real(wp), intent(in)    :: val
      integer :: i, j, k, nx, ny, nz
      nx = size(buf, 1)
      ny = size(buf, 2)
      nz = size(buf, 3)
      do concurrent(k=1:nz, j=1:ny, i=1:nx)
         buf(i, j, k) = val
      end do
   end subroutine fill_buffer_impl

   subroutine ocean_diag_set_output_z_levels(this, z, ierr)
      !! Configure the fixed-z output grid.  Stored copy; the original
      !! `z` array is not retained.  Must be called BEFORE any
      !! `register` with `output_vgrid == DIAG_VGRID_Z_FIXED` so the
      !! manager knows the output buffer shape.
      class(ocean_diag_t), intent(inout) :: this
      real(wp), intent(in) :: z(:)
      integer, intent(out), optional :: ierr
         !! Non-zero (`OCEAN_STATUS_ERR_SETUP`) when `size(z) >
         !! NZ_STACK_MAX`, when present; absent behaves as today
         !! (`error stop`). (F5 residual, P2.4)
      if (present(ierr)) ierr = OCEAN_STATUS_OK
      if (.not. this%is_init) return
      ! The conservative remap pads to max(nz, nz_out) in NZ_STACK_MAX-sized
      ! per-column stack buffers; more output levels than that would overrun
      ! them (silent device illegal-address).  Fail loud instead.
      if (size(z) > NZ_STACK_MAX) then
         call fail("set_output_z_levels: number of z-levels exceeds "// &
                   "NZ_STACK_MAX; raise NZ_STACK_MAX or use fewer levels", &
                   ierr, OCEAN_STATUS_ERR_SETUP)
         return
      end if
      if (allocated(this%z_out)) deallocate (this%z_out)
      this%nz_out = size(z)
      allocate (this%z_out(this%nz_out), source=z)
   end subroutine ocean_diag_set_output_z_levels

   subroutine ocean_diag_set_output_density_levels(this, rho, ierr)
      !! Configure the isopycnal (DENSITY) output grid — monotone-increasing
      !! target potential densities (kg/m³).  Stored copy; must be called
      !! BEFORE any `register` with `output_vgrid == DIAG_VGRID_DENSITY` so
      !! the manager knows the output buffer shape (one cell per target).
      class(ocean_diag_t), intent(inout) :: this
      real(wp), intent(in) :: rho(:)
      integer, intent(out), optional :: ierr
      if (present(ierr)) ierr = OCEAN_STATUS_OK
      if (.not. this%is_init) return
      if (size(rho) > NZ_STACK_MAX) then
         call fail("set_output_density_levels: number of density bins "// &
                   "exceeds NZ_STACK_MAX; raise NZ_STACK_MAX or use fewer bins", &
                   ierr, OCEAN_STATUS_ERR_SETUP)
         return
      end if
      if (allocated(this%rho_out)) deallocate (this%rho_out)
      this%n_rho_out = size(rho)
      allocate (this%rho_out(this%n_rho_out), source=rho)
   end subroutine ocean_diag_set_output_density_levels

   subroutine ocean_diag_set_output_sigma_levels(this, sigma, ierr)
      !! Configure the terrain-following (SIGMA) output grid — cumulative
      !! sigma fractions (0..1, monotone shallow->deep).  Stored copy; must
      !! be called BEFORE any `register` with `output_vgrid ==
      !! DIAG_VGRID_SIGMA` so the manager knows the output buffer shape.
      class(ocean_diag_t), intent(inout) :: this
      real(wp), intent(in) :: sigma(:)
      integer, intent(out), optional :: ierr
      if (present(ierr)) ierr = OCEAN_STATUS_OK
      if (.not. this%is_init) return
      if (size(sigma) > NZ_STACK_MAX) then
         call fail("set_output_sigma_levels: number of sigma levels "// &
                   "exceeds NZ_STACK_MAX; raise NZ_STACK_MAX or use fewer levels", &
                   ierr, OCEAN_STATUS_ERR_SETUP)
         return
      end if
      if (allocated(this%sigma_out)) deallocate (this%sigma_out)
      this%n_sigma_out = size(sigma)
      allocate (this%sigma_out(this%n_sigma_out), source=sigma)
   end subroutine ocean_diag_set_output_sigma_levels

   subroutine ocean_diag_set_output_zstar_levels(this, zstar, ierr)
      !! Configure the SSH-tracking (ZSTAR) output grid — reference interface
      !! depths (m, positive-down, monotone shallow->deep; deepest = H_ref).
      !! Stored copy; must be called BEFORE any `register` with
      !! `output_vgrid == DIAG_VGRID_ZSTAR` so the manager knows the buffer
      !! shape.
      class(ocean_diag_t), intent(inout) :: this
      real(wp), intent(in) :: zstar(:)
      integer, intent(out), optional :: ierr
      if (present(ierr)) ierr = OCEAN_STATUS_OK
      if (.not. this%is_init) return
      if (size(zstar) > NZ_STACK_MAX) then
         call fail("set_output_zstar_levels: number of z* levels "// &
                   "exceeds NZ_STACK_MAX; raise NZ_STACK_MAX or use fewer levels", &
                   ierr, OCEAN_STATUS_ERR_SETUP)
         return
      end if
      if (allocated(this%zstar_out)) deallocate (this%zstar_out)
      this%n_zstar_out = size(zstar)
      allocate (this%zstar_out(this%n_zstar_out), source=zstar)
   end subroutine ocean_diag_set_output_zstar_levels

   subroutine ocean_diag_step(this, state_handle, dt, t)
      !! Advance every registered variable.  Behaviour by `time_op`:
      !!
      !!   * `INSTANT`: skip until `dt_accum >= dt_out`, then fill once
      !!     (snapshot) and emit.
      !!   * `MEAN` / `INTEGRAL` / `MAX` / `MIN`: fill EVERY step, fold
      !!     the sample into the accumulator.  When `dt_accum >= dt_out`,
      !!     finalise the accumulator into `output_buffer` (see
      !!     `finalise_accumulator`), emit, reset.
      !!
      !! Fill writes into `output_buffer` for LAYER vgrid or into
      !! `layer_buffer` (then remap to `output_buffer`) for Z_FIXED.
      !! The post-remap value is the unit of accumulation — so the
      !! mean of T at fixed z-levels is computed in z-space, not
      !! layer-space (matters when h_layer drifts).
      !!
      !! Time stamping (CF-1.8 convention):
      !!   * INSTANT, MAX, MIN -> emit at `t` (end of window).
      !!   * MEAN, INTEGRAL    -> emit at the centre of the window,
      !!     `t - dt_accum / 2`.  The centre lines up with the
      !!     temporal centroid of the time-weighted average so
      !!     downstream tools (CF-aware analysis) plot it correctly.
      !!
      !! The emitted `[diag]` line's min / max / mean are taken over the
      !! FINITE cells only (`diag_field_stats`) — land columns and
      !! vanished layers carry the NaN missing-data sentinel — and the
      !! line gains a `missing=<excluded>/<total>` suffix whenever any
      !! cell was excluded.
      class(ocean_diag_t), intent(inout) :: this
      class(*), intent(in) :: state_handle
      real(wp), intent(in) :: dt
      real(wp), intent(in) :: t
      integer :: i, n_valid, n_total
      real(wp) :: vmin, vmax, vmean, t_emit
      logical :: fire
      character(len=256) :: line
      character(len=64) :: missing_tag

      if (.not. this%is_init) return
      if (.not. this%enabled) return

      do i = 1, this%nvars
         associate (v => this%vars(i))
            v%dt_accum = v%dt_accum + dt
            fire = (v%dt_accum + 1.0e-9_wp >= v%dt_out)

            if (.not. associated(v%fill)) cycle
            if (.not. v%enabled) cycle

            select case (v%time_op)
            case (DIAG_OP_INSTANT)
               if (.not. fire) cycle
               call fill_and_remap(v, state_handle, this%z_out, this%rho_out, &
                                   this%sigma_out, this%zstar_out)
            case (DIAG_OP_MEAN, DIAG_OP_INTEGRAL, DIAG_OP_MAX, DIAG_OP_MIN)
               call fill_and_remap(v, state_handle, this%z_out, this%rho_out, &
                                   this%sigma_out, this%zstar_out)
               call fold_sample(v, dt)
               if (.not. fire) cycle
               call finalise_accumulator(v)
            case default
               error stop "ocean_diag%step: unknown v%time_op"
            end select

            select case (v%time_op)
            case (DIAG_OP_MEAN, DIAG_OP_INTEGRAL)
               t_emit = t - 0.5_wp*v%dt_accum
            case default
               t_emit = t
            end select

            ! Single device→host pull at cadence fire — the fills, fold,
            ! and finalise above all ran on device, so `output_buffer`
            ! lives on the device.  Pull it once here so both the log-line
            ! reductions and the NetCDF write below see fresh host data.
            !
            ! `if_present` keeps lightweight unit tests (which skip
            ! `ocean_state_enter_data` and run on host) working — when
            ! the buffer hasn't been attached, the directive is a no-op
            ! and the host buffer is already current from the host-side
            ! fills.
            ! Statistics first, where the data already is.  On a GPU build
            ! the buffer is device-resident, so one fused device reduction
            ! returns a handful of scalars instead of dragging the whole
            ! array through three host passes.  Without device residency
            ! (unit tests that skip `enter_data`) the host path is the only
            ! correct one -- a device read of an unmapped buffer under
            ! `-gpu=mem:separate` returns garbage without failing.  Both
            ! paths skip the NaN missing-data sentinel; see
            ! `diag_field_stats`.
            call diag_field_stats(v%output_buffer, this%on_device, &
                                  vmin, vmax, vmean, n_valid, n_total)

            ! The NetCDF write below needs the array itself on the host.
            !$acc update self(v%output_buffer) if_present
            v%fire_count = v%fire_count + 1
            write (line, "(A,F12.2,A,A,A,A,A,ES13.5,A,ES13.5,A,ES13.5)") &
               "[diag] t=", t_emit, " ", trim(v%name), " [", trim(v%units), &
               "]  min=", vmin, "  max=", vmax, "  mean=", vmean
            ! Only appended when cells were actually excluded, so a run with
            ! no masked cells emits the byte-identical line it always did.
            if (n_valid < n_total) then
               write (missing_tag, "(A,I0,A,I0)") "  missing=", n_total - n_valid, &
                  "/", n_total
               line = trim(line)//trim(missing_tag)
            end if
            call logger%info(trim(line))
            if (associated(this%emit_post_fire)) then
               call this%emit_post_fire(this, i, t_emit)
            end if
            v%dt_accum = 0.0_wp
            if (v%time_op /= DIAG_OP_INSTANT) then
               call reset_accumulator(v)
               v%n_accum = 0
            end if
         end associate
      end do
      this%dt_last_eval = t
   end subroutine ocean_diag_step

   pure subroutine diag_field_stats(buf, on_device, vmin, vmax, vmean, n_valid, n_total)
      !! The `[diag]` console line's min / max / mean for one diagnostic
      !! buffer, **over the finite cells only**.
      !!
      !! A diagnostic buffer legitimately carries IEEE NaN as its "no water
      !! here" sentinel: `fill_tracer_impl` writes one into every land
      !! column and every dynamically vanished layer (ZSTAR_FULL bed layers
      !! pinched out below `zstar_h_min`), because 0 degC / 0 PSU are legal
      !! ocean values and must not be confused with missing data.  Those
      !! cells are not data, so none of the three statistics may see them
      !! and the mean divides by `n_valid`, not by the array size.
      !!
      !! What this replaced, and why it was wrong: a plain
      !! `minval`/`maxval`/`sum` over the whole buffer.  Comparisons with
      !! NaN are FALSE, so `minval`/`maxval` silently skipped the sentinel
      !! cells while `sum` propagated them — emitting a self-contradictory
      !! `min= 1.5E+01  max= 1.5E+01  mean= NaN` for a run whose state was
      !! entirely healthy, and tripping the regression suite's NaN gate on
      !! every masked configuration (island / coastline / vanishing-layer
      !! cases).  Leaning on NaN-false comparisons is not portable either:
      !! nvfortran's relaxed-FP default may lower an unguarded `min`/`max`
      !! to a NaN-blind select (see CLAUDE.md's clamp-laundering gotcha).
      !!
      !! `n_valid == n_total` (the overwhelmingly common case — no land, no
      !! vanished layer) takes the unmasked intrinsics, so the emitted
      !! numbers stay BIT-IDENTICAL to the pre-fix behaviour on every
      !! all-finite field.  `n_valid == 0` reports `DIAG_MISSING_VALUE` for
      !! all three rather than the reduction's untouched `+huge`/`-huge`
      !! seeds or a zero that reads as a legal value.
      real(wp), intent(in) :: buf(:, :, :)  ! assumed-shape-ok: diag emit — cadence-bounded
      logical, intent(in) :: on_device
         !! `.true.` when `buf` is device-resident, so the fused device
         !! reduction is the correct (and only correct) reader — a host
         !! read of a mapped buffer under `-gpu=mem:separate` is stale.
      real(wp), intent(out) :: vmin, vmax, vmean
      integer, intent(out) :: n_valid, n_total
      real(wp) :: vsum

      n_total = size(buf)
      if (on_device) then
         call diag_reduce_stats(buf, size(buf, 1), size(buf, 2), size(buf, 3), &
                                vmin, vmax, vsum, n_valid)
      else
         n_valid = count(ieee_is_finite(buf))
         if (n_valid == n_total) then
            vmin = minval(buf)
            vmax = maxval(buf)
            vsum = sum(buf)
         else
            vmin = minval(buf, mask=ieee_is_finite(buf))
            vmax = maxval(buf, mask=ieee_is_finite(buf))
            vsum = sum(buf, mask=ieee_is_finite(buf))
         end if
      end if

      if (n_valid > 0) then
         vmean = vsum/real(n_valid, wp)
      else
         vmin = DIAG_MISSING_VALUE
         vmax = DIAG_MISSING_VALUE
         vmean = DIAG_MISSING_VALUE
      end if
   end subroutine diag_field_stats

   pure subroutine diag_reduce_stats(buf, n1, n2, n3, vmin, vmax, vsum, n_valid)
      !! Whole-array min / max / sum of a diagnostic buffer in ONE pass,
      !! over the FINITE cells only.
      !!
      !! Replaces three separate host passes (`minval`/`maxval`/`sum`) with
      !! a single `do concurrent ... reduce`, so on a GPU build this runs
      !! where the buffer already lives and only a few scalars come back.
      !! Explicit-shape dummies (never assumed-shape) so NVHPC does not walk
      !! a descriptor per launch; index order is `(k, j, i)` with the
      !! contiguous index last.
      !!
      !! **Missing data.**  A diagnostic buffer legitimately carries IEEE
      !! NaN as the "no water here" sentinel — `fill_tracer_impl` writes it
      !! into every land column and every dynamically vanished layer (see
      !! its docstring, and `test_fill_vanished_nan`).  Those cells are not
      !! data and must not enter the statistics, so every cell is
      !! `ieee_is_finite`-guarded and `n_valid` counts the cells that did
      !! contribute — the caller divides the sum by THAT, not by the array
      !! size.  Relying on "comparisons with NaN are false" to make
      !! `min`/`max` skip them is not enough and not portable: it leaves
      !! `sum` poisoned (the whole field's mean becomes NaN next to a
      !! perfectly finite min/max) and nvfortran's relaxed-FP default is
      !! free to lower an unguarded `min`/`max` to a NaN-blind select.
      !!
      !! For an all-finite buffer the guard is always taken, so the
      !! reduction order — and therefore the result — is bit-identical to
      !! the unguarded form.  `n_valid == 0` (nothing finite anywhere)
      !! leaves the `+huge` / `-huge` / `0` seeds untouched; the caller
      !! substitutes the missing-value sentinel.
      !!
      !! The caller MUST only invoke this when the buffer is device-resident
      !! on a GPU build — see `ocean_diag_t%on_device`.
      integer, intent(in) :: n1, n2, n3
      real(wp), intent(in) :: buf(n1, n2, n3)
      real(wp), intent(out) :: vmin, vmax, vsum
      integer, intent(out), optional :: n_valid
         !! Number of finite cells folded in. Optional so the pre-existing
         !! three-scalar call sites keep working unchanged.
      integer :: i, j, k, nv
      vmin = huge(1.0_wp)
      vmax = -huge(1.0_wp)
      vsum = 0.0_wp
      nv = 0
      do concurrent(k=1:n3, j=1:n2, i=1:n1) reduce(min:vmin) reduce(max:vmax) &
         reduce(+:vsum) reduce(+:nv)
         if (ieee_is_finite(buf(i, j, k))) then
            vmin = min(vmin, buf(i, j, k))
            vmax = max(vmax, buf(i, j, k))
            vsum = vsum + buf(i, j, k)
            nv = nv + 1
         end if
      end do
      if (present(n_valid)) n_valid = nv
   end subroutine diag_reduce_stats

   subroutine fill_and_remap(v, state_handle, z_out, rho_out, sigma_out, zstar_out)
      !! Invoke the var's `fill` (and `remap` if non-LAYER) so
      !! `output_buffer` holds the current sample.  The accumulation
      !! / log emission step consumes `output_buffer` after this.
      !!
      !! The single target-levels array the `remap` proc receives is
      !! selected by the var's `output_vgrid`: Z_FIXED gets the z-level
      !! depths (`z_out`), DENSITY the isopycnal targets (`rho_out`), SIGMA
      !! the sigma fractions (`sigma_out`), ZSTAR the reference depths
      !! (`zstar_out`); other non-LAYER vgrids fall back to `z_out`.
      type(diag_var_t), intent(inout) :: v
      class(*), intent(in) :: state_handle
      ! Allocatable so an unconfigured (unallocated) target array is a legal
      ! actual argument — the select case below dereferences only the array
      ! matching the var's output_vgrid, which is allocated when that vgrid
      ! is in use.  (Non-allocatable dummies would make passing an
      ! unallocated `this%*_out` undefined behaviour, F2018 15.5.2.4.)
      real(wp), intent(in), allocatable :: z_out(:)
      real(wp), intent(in), allocatable :: rho_out(:)
      real(wp), intent(in), allocatable :: sigma_out(:)
      real(wp), intent(in), allocatable :: zstar_out(:)
      if (v%output_vgrid == DIAG_VGRID_LAYER) then
         call v%fill(state_handle, v%output_buffer)
      else
         call v%fill(state_handle, v%layer_buffer)
         if (associated(v%remap)) then
            select case (v%output_vgrid)
            case (DIAG_VGRID_DENSITY)
               call v%remap(state_handle, rho_out, v%layer_buffer, v%output_buffer, &
                            v%is_extensive)
            case (DIAG_VGRID_SIGMA)
               call v%remap(state_handle, sigma_out, v%layer_buffer, v%output_buffer, &
                            v%is_extensive)
            case (DIAG_VGRID_ZSTAR)
               call v%remap(state_handle, zstar_out, v%layer_buffer, v%output_buffer, &
                            v%is_extensive)
            case default
               call v%remap(state_handle, z_out, v%layer_buffer, v%output_buffer, &
                            v%is_extensive)
            end select
         else
            ! Identity passthrough when caller registered a non-LAYER
            ! output vgrid but no remap proc — device-side copy because
            ! both buffers are device-resident under the GPU-resident
            ! pipeline.
            call finalise_copy_impl(v%output_buffer, v%layer_buffer)
         end if
      end if
   end subroutine fill_and_remap

   subroutine fold_sample(v, dt)
      !! Combine the current `output_buffer` sample into `accumulator`
      !! per `time_op`.
      !!
      !! MEAN and INTEGRAL fold dt-weighted: `accumulator += sample · dt`.
      !! With fixed `dt` this matches a count-weighted sum exactly, so
      !! callers running uniform timesteps see no change.  With variable
      !! `dt` it produces the true `(1/T) ∫ f dt` (MEAN) or `∫ f dt`
      !! (INTEGRAL), which a count-weighted scheme would not.
      !!
      !! MAX / MIN are dt-independent — the running extremum doesn't
      !! care about sample weight.
      !!
      !! When `v%mask` is allocated, the sample is multiplied by the
      !! mask weight (broadcast across z) before folding.  Cells with
      !! weight = 0 contribute nothing to MEAN / INTEGRAL.  For MAX /
      !! MIN, masked cells are taken as `-huge` / `+huge` respectively
      !! so they never win — masked output stays at the seed value.
      type(diag_var_t), intent(inout) :: v
      real(wp), intent(in) :: dt
      if (.not. allocated(v%accumulator)) return
      if (allocated(v%mask)) then
         call fold_sample_masked_impl(v%accumulator, v%output_buffer, &
                                      v%mask%weight, v%mask%nx, v%mask%ny, &
                                      dt, v%time_op)
      else
         call fold_sample_unmasked_impl(v%accumulator, v%output_buffer, &
                                        dt, v%time_op)
      end if
      v%n_accum = v%n_accum + 1
   end subroutine fold_sample

   pure subroutine fold_sample_unmasked_impl(accum, out, dt, time_op)
      !! Whole-buffer fold without a region mask.  One `do concurrent`
      !! per op so the compiler can specialise — case-inside-loop blocks
      !! NVHPC device codegen.
      ! assumed-shape-ok: diag fold — fires once per output frame (cadence-bounded).
      real(wp), intent(inout) :: accum(:, :, :)
      real(wp), intent(in)    :: out(:, :, :)  ! assumed-shape-ok: diag fold — cadence-bounded
      real(wp), intent(in)    :: dt
      integer, intent(in)    :: time_op
      integer :: i, j, k, nx, ny, nz
      nx = size(accum, 1)
      ny = size(accum, 2)
      nz = size(accum, 3)
      select case (time_op)
      case (DIAG_OP_MEAN, DIAG_OP_INTEGRAL)
         do concurrent(k=1:nz, j=1:ny, i=1:nx)
            accum(i, j, k) = accum(i, j, k) + out(i, j, k)*dt
         end do
      case (DIAG_OP_MAX)
         do concurrent(k=1:nz, j=1:ny, i=1:nx)
            accum(i, j, k) = max(accum(i, j, k), out(i, j, k))
         end do
      case (DIAG_OP_MIN)
         do concurrent(k=1:nz, j=1:ny, i=1:nx)
            accum(i, j, k) = min(accum(i, j, k), out(i, j, k))
         end do
      case default
         ! INSTANT and unknown ops don't accumulate.  The caller in
         ! `fold_sample` already returns early when `accumulator` is
         ! unallocated (which is the INSTANT path), so reaching here
         ! with an unknown op is a defensive no-op.
      end select
   end subroutine fold_sample_unmasked_impl

   pure subroutine fold_sample_masked_impl(accum, out, weight, mask_nx, mask_ny, &
                                           dt, time_op)
      !! Masked fold — multiplies sample by `weight(i, j)` before folding.
      !! MAX/MIN treat `weight == 0` as "don't update" so masked-out cells
      !! keep their seed value.  Loop bounds clip to whichever extent is
      !! smaller (accumulator vs mask) so an undersized mask doesn't OOB.
      ! assumed-shape-ok: diag fold — fires once per output frame (cadence-bounded).
      real(wp), intent(inout) :: accum(:, :, :)
      real(wp), intent(in)    :: out(:, :, :)  ! assumed-shape-ok: diag fold — cadence-bounded
      real(wp), intent(in)    :: weight(:, :)  ! assumed-shape-ok: diag fold — cadence-bounded
      integer, intent(in)    :: mask_nx, mask_ny
      real(wp), intent(in)    :: dt
      integer, intent(in)    :: time_op
      integer :: i, j, k, nx, ny, nz
      real(wp) :: w
      nx = min(size(accum, 1), mask_nx)
      ny = min(size(accum, 2), mask_ny)
      nz = size(accum, 3)
      select case (time_op)
      case (DIAG_OP_MEAN, DIAG_OP_INTEGRAL)
         do concurrent(k=1:nz, j=1:ny, i=1:nx)
            accum(i, j, k) = accum(i, j, k) + &
                             weight(i, j)*out(i, j, k)*dt
         end do
      case (DIAG_OP_MAX)
         do concurrent(k=1:nz, j=1:ny, i=1:nx) &
            local(w)
            w = weight(i, j)
            if (w > 0.0_wp) then
               accum(i, j, k) = max(accum(i, j, k), out(i, j, k))
            end if
         end do
      case (DIAG_OP_MIN)
         do concurrent(k=1:nz, j=1:ny, i=1:nx) &
            local(w)
            w = weight(i, j)
            if (w > 0.0_wp) then
               accum(i, j, k) = min(accum(i, j, k), out(i, j, k))
            end if
         end do
      case default
         ! INSTANT and unknown ops don't accumulate (caller-gated).
      end select
   end subroutine fold_sample_masked_impl

   subroutine finalise_accumulator(v)
      !! Copy the accumulator into `output_buffer`.  Called at
      !! cadence-fire BEFORE log / NetCDF emission consumes
      !! `output_buffer`.
      !!
      !! MEAN     -> `accumulator / dt_accum`  (dt-weighted time mean)
      !! INTEGRAL -> `accumulator`             (raw cumulative integral)
      !! MAX/MIN  -> `accumulator`             (running extremum)
      type(diag_var_t), intent(inout) :: v
      real(wp), parameter :: TINY_DT = 1.0e-12_wp
      if (.not. allocated(v%accumulator)) return
      if (v%n_accum == 0) return
      select case (v%time_op)
      case (DIAG_OP_MEAN)
         if (v%dt_accum < TINY_DT) return
         call finalise_scale_impl(v%output_buffer, v%accumulator, &
                                  1.0_wp/v%dt_accum)
      case (DIAG_OP_INTEGRAL, DIAG_OP_MAX, DIAG_OP_MIN)
         call finalise_copy_impl(v%output_buffer, v%accumulator)
      case default
         return
      end select
   end subroutine finalise_accumulator

   pure subroutine finalise_scale_impl(out, accum, scale_factor)
      !! Device-side `out = accum * scale_factor`.  Used for MEAN
      !! finalise where `scale_factor = 1/dt_accum`.
      !!
      !! Local var named `scale_factor` (not `scale`) to dodge the
      !! NVHPC 26.3 intrinsic-shadow bug
      !! (`feedback_nvhpc_local_intrinsic_shadow.md`).
      ! assumed-shape-ok: diag finalise — fires once per output frame (cadence-bounded).
      real(wp), intent(inout) :: out(:, :, :)
      real(wp), intent(in)    :: accum(:, :, :)  ! assumed-shape-ok: diag finalise — cadence-bounded
      real(wp), intent(in)    :: scale_factor
      integer :: i, j, k, nx, ny, nz
      nx = size(out, 1)
      ny = size(out, 2)
      nz = size(out, 3)
      do concurrent(k=1:nz, j=1:ny, i=1:nx)
         out(i, j, k) = accum(i, j, k)*scale_factor
      end do
   end subroutine finalise_scale_impl

   pure subroutine finalise_copy_impl(out, accum)
      ! assumed-shape-ok: diag finalise — fires once per output frame (cadence-bounded).
      real(wp), intent(inout) :: out(:, :, :)
      real(wp), intent(in)    :: accum(:, :, :)  ! assumed-shape-ok: diag finalise — cadence-bounded
      integer :: i, j, k, nx, ny, nz
      nx = size(out, 1)
      ny = size(out, 2)
      nz = size(out, 3)
      do concurrent(k=1:nz, j=1:ny, i=1:nx)
         out(i, j, k) = accum(i, j, k)
      end do
   end subroutine finalise_copy_impl

   pure function diag_var_bytes(this) result(nbytes)
      !! Counted allocatable footprint of ONE registered diagnostic
      !! (0 for every buffer that is unallocated).
      !!
      !! All four terms are device-mapped by `ocean_diag_enter_data_impl`
      !! and, until this function existed, none of them appeared in any
      !! `bytes()` total — the single largest hole in the startup estimate
      !! (~3.2 GB for the default catalog at 1000x800x50, up to ~9.9 GB
      !! with the optional diags).  Conditionality rides `arr_bytes`:
      !! `layer_buffer` is allocated only when `output_vgrid /= LAYER`,
      !! `accumulator` only when `time_op /= INSTANT`, `mask` only when a
      !! region was supplied at register time.
      class(diag_var_t), intent(in) :: this
      integer(int64) :: nbytes
      nbytes = arr_bytes(this%output_buffer) &
               + arr_bytes(this%layer_buffer) &
               + arr_bytes(this%accumulator)
      if (allocated(this%mask)) nbytes = nbytes + this%mask%bytes()
   end function diag_var_bytes

   pure function ocean_diag_bytes(this) result(nbytes)
      !! Counted allocatable footprint of the diagnostics slot: the flat
      !! remap-level arrays, the host NetCDF staging buffer, AND the
      !! per-variable registry buffers (0 when unallocated).  One
      !! arr_bytes term per array — add a term here when a new allocatable
      !! joins the type; new `diag_var_t` buffers go in `diag_var_bytes`.
      class(ocean_diag_t), intent(in) :: this
      integer(int64) :: nbytes
      integer :: i
      nbytes = arr_bytes(this%z_out) &
               + arr_bytes(this%rho_out) &
               + arr_bytes(this%sigma_out) &
               + arr_bytes(this%zstar_out) &
               + arr_bytes(this%send_buf)
      ! `nc_stream%stage` is real32, which `arr_bytes` (real(wp) only) does
      ! not cover — counted inline at 4 bytes/element.  Unallocated on the
      ! default double-precision diag stream.
      if (allocated(this%nc_stream%stage)) then
         nbytes = nbytes + 4_int64*int(size(this%nc_stream%stage), int64)
      end if
      ! Registry: `nvars` live entries out of `nvars_max` capacity — the
      ! tail slots past `nvars` are default-initialised and unallocated,
      ! but bound the loop by `nvars` anyway so the term tracks what is
      ! actually registered (and mapped).
      if (allocated(this%vars)) then
         do i = 1, min(this%nvars, size(this%vars))
            nbytes = nbytes + this%vars(i)%bytes()
         end do
      end if
   end function ocean_diag_bytes

end module rdb_ocean_diag