ALE remap orchestrator for the ocean dynamical core (centre-cell pass).
!! ALE remap orchestrator for the ocean dynamical core (centre-cell pass). module rdb_ocean_remap !! Drives the conservative vertical remap of `multilayer.h_layer` + every !! registered `multilayer.tracers(t)%hTr` from the current (Lagrangian) grid !! to `vcoord%target_h`. The kernel is the per-column `remap_column` from !! `rdb_remap_column` (shared with coastal); this module wires it across the !! registered tracer slot list plus the face-velocity pass. !! !! Per-column conservation: sum_k(hTr) is preserved to machine precision. !! The `c = hTr/h` ↔ `hTr = c·h` round trip is protected by a TWO-SIDED !! vanishing-layer guard (`rdb_vl_merge_content`, invariant I1′) which !! keeps the content of a sub-threshold layer in the column instead of !! deleting it, and leaves every sub-threshold layer holding `h·c_live`, !! the concentration of its donor live layer. See !! `src/core/ocean/README.md` ("The vanished-layer content rule"). #ifdef LFORTRAN_PASSING use rdb_constants, only: wp, REMAP_PPM, H_VANISHED, H_DIV_EPS #else use rdb_constants, only: NZ_STACK_MAX, wp, REMAP_PPM, H_VANISHED, H_DIV_EPS #endif use rdb_grid, only: hgrid_t use rdb_remap_column, only: remap_column use rdb_multilayer_state, only: multilayer_state_t use rdb_tracer, only: TRACER_BUDGET_HEAT, TRACER_BUDGET_SALT use rdb_ocean_vcoord, only: ocean_vcoord_t, VCOORD_EULERIAN_Z, VCOORD_LAGRANGIAN, & VCOORD_RHO, VCOORD_HYCOM use rdb_eos, only: eos_t implicit none private ! Degenerate-COLUMN guard for `rescale_anomaly_ke` — a face column whose ! whole depth is at or below the vanish marker has no barotropic mean to ! rescale about. Aliased to `H_VANISHED` (same value) so there is ONE ! definition of "vanished" in the tree; it used to be a bare `1.5e-4_wp` ! literal here, which is a third definition waiting to drift from the ! constant of record. ! ! The per-LAYER tests it used to serve — `c = hTr/h` and the I1′ merge — ! now go through `rdb_vl_column_conc` / `rdb_vl_merge_content`, the included ! single definition (`src/shared_module_utilities/rdb_vanished_layer.inc`). ! Every test of the marker is a STRICT `>`: a layer sitting exactly ON it ! reads as vanished, which is what the geometric vcoord families rely on ! (see `rdb_vcoord :: vcoord_h_min_role`). real(wp), parameter :: H_FLOOR = H_VANISHED real(wp), parameter, public :: OCEAN_REMAP_PRECOND_RTOL = 1.0e-9_wp !! Relative tolerance on `sum(h_old) == sum(target_h)` for the !! precondition assertion. The target builders reach the sum by a !! different arithmetic route than the continuity update does, so a !! few ulp of drift per layer is expected and is not the failure mode !! being hunted: the violations that matter (a degenerate column that !! manufactures `nz*h_min` of water, a short target that deletes its !! tail) are percent-level, decades above this. public :: ocean_apply_ale_remap_centres public :: ocean_remap_scan_preconditions public :: ocean_apply_ale_remap_faces public :: ocean_apply_ale_remap_step public :: ocean_remap_tracer_column ! exposed for unit tests public :: ocean_remap_merge_vanished_content ! exposed for unit tests #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 contains pure subroutine ocean_apply_ale_remap_centres(grid, vcoord, ms, bt_eta, bt_H_ref, method, eos, dt) !! Orchestrate the centre-cell pass of the ALE remap step (h_layer + !! tracers). Public only for the unit-test suite. !! Sequence: skip if EULERIAN_Z/LAGRANGIAN; snapshot column total + h_old; !! build `vcoord%target_h`; remap each tracer h_old→target_h via the PPM !! column kernel; set h_layer = target_h; recompute !! bt_eta = sum_k(h_layer) - bt_H_ref. Face velocities remapped separately. !! Takes bt_eta/bt_H_ref directly (not ocean_dyn_t) so this module sits !! below the split driver in the dependency tree. type(hgrid_t), intent(in) :: grid type(ocean_vcoord_t), intent(inout) :: vcoord type(multilayer_state_t), intent(inout) :: ms ! assumed-shape-ok: outer-driver allocatable; grid%nx_total available; deferred. real(wp), intent(inout) :: bt_eta(:, :) !! Free-surface anomaly η at cell centres (m). Updated to !! `sum_k(h_layer) - bt_H_ref` on exit (step 7). real(wp), intent(in) :: bt_H_ref(:, :) ! assumed-shape-ok: same as bt_eta above !! Reference column depth H (m, positive-down). Constant for !! the ocean path; passed in so the routine doesn't need a !! handle on the split-driver state. type(eos_t), intent(in), optional :: eos !! Device-resident EOS handle — required only for `VCOORD_RHO` !! (isopycnal density inversion); ignored by the geometric coords. real(wp), intent(in), optional :: dt !! Outer/thermo timestep (s) for the grid time-filter. Absent or !! `regrid_time_scale = 0` (default) ⇒ filter skipped ⇒ !! bit-identical. See `ocean_apply_ale_remap_step`. integer, intent(in), optional :: method !! REMAP_PCM / REMAP_PLM / REMAP_PPM. Defaults to PPM (parabolic stencil !! cuts the spurious vertical mixing a 1st-order limiter introduces). integer :: t, m, nx, ny, nz, i, j, k real(wp) :: wtd logical :: do_tfilter ! Eulerian-z and Lagrangian/isopycnal both skip remap: the former ! holds h at H·dsig via vert-advection cancellation, the latter ! lets h evolve freely (target = current h). if (vcoord%coord_type == VCOORD_EULERIAN_Z .or. & vcoord%coord_type == VCOORD_LAGRANGIAN) return if (.not. vcoord%is_init) return if (.not. allocated(ms%h_layer)) return m = REMAP_PPM if (present(method)) m = method nx = grid%nx_total ny = grid%ny_total nz = ms%nz_ml ! --- 2. Current column total (persistent device-mapped scratch on vcoord) do concurrent(j=1:ny, i=1:nx) local(k) vcoord%remap_total_h(i, j) = 0.0_wp do k = 1, nz vcoord%remap_total_h(i, j) = vcoord%remap_total_h(i, j) & + ms%h_layer(i, j, k) end do end do ! --- 3. Snapshot h_old on-device (host source= reads stale memory after a ! dynamics-side OpenACC kernel). Before target_h so VCOORD_RHO can read it. do concurrent(k=1:nz, j=1:ny, i=1:nx) vcoord%remap_h_old(i, j, k) = ms%h_layer(i, j, k) end do ! --- 4. Populate target_h (geometric vs isopycnal dispatch) --- do concurrent(j=1:ny, i=1:nx) vcoord%remap_h_ref(i, j) = vcoord%remap_total_h(i, j) - bt_eta(i, j) end do if ((vcoord%coord_type == VCOORD_RHO .or. vcoord%coord_type == VCOORD_HYCOM) & .and. present(eos) .and. allocated(ms%tracers) & .and. ms%idx_temperature > 0 .and. ms%idx_salinity > 0) then call build_ts_concentration(nx, ny, nz, vcoord%remap_h_old, & ms%tracers(ms%idx_temperature)%hTr, & ms%tracers(ms%idx_salinity)%hTr, & vcoord%remap_conc_t, vcoord%remap_conc_s) ! HYCOM = RHO inversion + z*-floor/monotonize deltas (hybrid=.true.); ! pure RHO passes hybrid=.false. (bit-identical to RHO-only kernel). call vcoord%compute_target_h_rho(vcoord%remap_h_ref, bt_eta, & vcoord%remap_conc_t, vcoord%remap_conc_s, eos, & hybrid=(vcoord%coord_type == VCOORD_HYCOM)) else call vcoord%compute_target_h(vcoord%remap_h_ref, bt_eta) end if ! --- 4b. Grid time-filter (White & Adcroft 2008): relax target toward it ! from the old grid by wtd = dt/(τ+dt). Convex blend ⇒ column total ! conserved. τ=0 (default) or dt absent ⇒ skipped, bit-identical. do_tfilter = vcoord%regrid_time_scale > 0.0_wp .and. present(dt) if (do_tfilter) then wtd = dt/(vcoord%regrid_time_scale + dt) do concurrent(k=1:nz, j=1:ny, i=1:nx) vcoord%target_h(i, j, k) = vcoord%remap_h_old(i, j, k) & + wtd*(vcoord%target_h(i, j, k) - vcoord%remap_h_old(i, j, k)) end do end if ! --- 5. Remap every tracer --- if (allocated(ms%tracers)) then do t = 1, size(ms%tracers) if (.not. allocated(ms%tracers(t)%hTr)) cycle select case (ms%tracers(t)%budget_id) case (TRACER_BUDGET_HEAT) call ocean_remap_tracer_field( & nx, ny, nz, vcoord%remap_h_old, vcoord%target_h, ms%tracers(t)%hTr, m, & vcoord%remap_boundary_extrap, vcoord%remap_nonuniform_weights, & budget=ms%heat_budget_remap) case (TRACER_BUDGET_SALT) call ocean_remap_tracer_field( & nx, ny, nz, vcoord%remap_h_old, vcoord%target_h, ms%tracers(t)%hTr, m, & vcoord%remap_boundary_extrap, vcoord%remap_nonuniform_weights, & budget=ms%salt_budget_remap) case default call ocean_remap_tracer_field( & nx, ny, nz, vcoord%remap_h_old, vcoord%target_h, ms%tracers(t)%hTr, m, & vcoord%remap_boundary_extrap, vcoord%remap_nonuniform_weights) end select end do end if ! --- 6. h_layer = target_h; capture mass-budget delta --- do concurrent(k=1:nz, j=1:ny, i=1:nx) ms%mass_budget_remap(i, j, k) = ms%mass_budget_remap(i, j, k) & + (vcoord%target_h(i, j, k) - vcoord%remap_h_old(i, j, k)) ms%h_layer(i, j, k) = vcoord%target_h(i, j, k) end do ! --- 7. Recompute bt_eta from new sum (round-off-tight) --- do concurrent(j=1:ny, i=1:nx) local(k) bt_eta(i, j) = -bt_H_ref(i, j) do k = 1, nz bt_eta(i, j) = bt_eta(i, j) + ms%h_layer(i, j, k) end do end do end subroutine ocean_apply_ale_remap_centres pure subroutine ocean_remap_tracer_field(nx, ny, nz, h_old, h_new, hTr, method, & bnd_extrap, nonunif, budget) !! Flat-impl tracer remap. Per (i,j) column: c = hTr/h (vanishing-layer- !! guarded) → per-column remap kernel → hTr_new = c_new·h_new. Conservative. !! `budget` (optional): when present, the per-cell hTr_new−hTr_old increment !! is accumulated into the slot (heat/salt remap deltas) before overwriting. !! Flat-arg so GPU codegen doesn't chase the array-of-derived-types pointer. !! !! **The vanishing-layer guard is two-sided** (`rdb_vl_merge_content`, !! invariant I1′): on the READ side a SOURCE filler is pooled with its !! donor live layer, and the reconstruction is handed the donor's !! concentration for it (`rdb_vl_column_conc`) — never one recovered !! from a near-zero divisor; on the WRITE side a TARGET filler is pooled !! with ITS donor live target layer, so it receives `h_new·c_live`, !! taken from that layer. Without the write half the remap parks !! arbitrary content in a filler (the day-16 z_fixed salt/heat break, !! when the read side then zeroed it); with a write half that ZEROES the !! filler (the previous I1), a uniform tracer stops being uniform — the !! thickness the continuity step later moves out of the filler arrives !! in the live layers carrying no content. !! Post-condition (I1′): `h_new <= H_VANISHED ⇒ hTr = h_new·c_live`, !! column sum conserved to round-off, so `budget` still telescopes per !! column. integer, intent(in) :: nx, ny, nz, method real(wp), intent(in) :: h_old(nx, ny, nz) real(wp), intent(in) :: h_new(nx, ny, nz) real(wp), intent(inout) :: hTr(nx, ny, nz) logical, intent(in) :: bnd_extrap !! Linear-exact boundary-cell reconstruction in the column kernel !! (`&vcoord_nml remap_boundary_extrap`); `.false.` ⇒ the PCM !! flatten, i.e. bit-identical to the pre-knob behaviour. logical, intent(in) :: nonunif !! Non-uniform-grid PLM/PPM reconstruction weights in the column !! kernel (`&vcoord_nml remap_nonuniform_weights`); `.false.` ⇒ the !! equal-thickness specialisations, i.e. bit-identical. real(wp), intent(inout), optional :: budget(nx, ny, nz) integer :: i, j, k real(wp) :: h_old_col(NZ_STACK_MAX), h_new_col(NZ_STACK_MAX) real(wp) :: c_old_col(NZ_STACK_MAX), c_new_col(NZ_STACK_MAX) real(wp) :: hTr_col(NZ_STACK_MAX), hTr_new_col(NZ_STACK_MAX) ! Gate the budget write INSIDE the one loop (as vdiff does); splitting ! present(budget) into two loops makes NVHPC compile the no-budget branch ! ~25x slower. Bit-identical to the split form. do concurrent(j=1:ny, i=1:nx) & local(k, h_old_col, h_new_col, c_old_col, c_new_col, hTr_col, hTr_new_col) do k = 1, nz h_old_col(k) = h_old(i, j, k) h_new_col(k) = h_new(i, j, k) hTr_col(k) = hTr(i, j, k) end do ! Read side. `hTr_col` is a LOCAL copy — the budget below still ! differences against the original `hTr` array, so the merge is ! invisible to the leak detector. call rdb_vl_merge_content(nz, h_old_col, hTr_col) call rdb_vl_column_conc(nz, h_old_col, hTr_col, c_old_col) call remap_column(method, nz, & h_old_col(1:nz), h_new_col(1:nz), & c_old_col(1:nz), c_new_col(1:nz), bnd_extrap, nonunif) ! Write side. do k = 1, nz hTr_new_col(k) = c_new_col(k)*h_new_col(k) end do call remap_fold_filler_defect(nz, h_old_col, h_new_col, hTr_col, hTr_new_col) call rdb_vl_merge_content(nz, h_new_col, hTr_new_col) if (present(budget)) then do k = 1, nz budget(i, j, k) = budget(i, j, k) + (hTr_new_col(k) - hTr(i, j, k)) hTr(i, j, k) = hTr_new_col(k) end do else do k = 1, nz hTr(i, j, k) = hTr_new_col(k) end do end if end do end subroutine ocean_remap_tracer_field subroutine ocean_apply_ale_remap_faces(grid, h_old, h_new, u_face_x, v_face_y, method, & conserve_ke, zlevel_faces, bnd_extrap, nonunif) !! Face-velocity pass of the ALE remap. Remaps u_face_x_layer and !! v_face_y_layer h_old→h_new using arithmetic-mean face thicknesses and !! the per-column kernel. Public only for the unit-test suite. !! Velocity treated as the face "concentration" (analogous to T=hTr/h at !! centres); per-face conservation sum_k(h_face·u_face) preserved !! (momentum-conserving). Outer-wall faces take the adjacent cell's !! thickness verbatim (no across-cell to average). type(hgrid_t), intent(in) :: grid real(wp), intent(in) :: h_old(:, :, :) real(wp), intent(in) :: h_new(:, :, :) real(wp), intent(inout) :: u_face_x(:, :, :) !! Eastward face velocity, shape `(nx+1, ny, nz)`. real(wp), intent(inout) :: v_face_y(:, :, :) !! Northward face velocity, shape `(nx, ny+1, nz)`. integer, intent(in), optional :: method logical, intent(in), optional :: conserve_ke !! Enable the KE-conserving baroclinic-anomaly rescale (default !! `.false.` ⇒ momentum-only remap, bit-identical). logical, intent(in), optional :: zlevel_faces !! `&vcoord_nml zfixed_closed_faces` — build the face columns as !! `min(h_L, h_R)` and drop the closed layers, so the remap !! neither fills nor drains an inert filler. Default `.false.` !! ⇒ bit-identical. See `remap_x_face_velocity`. logical, intent(in), optional :: bnd_extrap !! Linear-exact boundary-cell reconstruction (default `.false.` !! ⇒ the PCM flatten, bit-identical). logical, intent(in), optional :: nonunif !! Non-uniform-grid PLM/PPM weights (default `.false.` ⇒ the !! equal-thickness specialisations, bit-identical). integer :: m, nx, ny, nz logical :: ke, zf, be, nu m = REMAP_PPM if (present(method)) m = method ke = .false. if (present(conserve_ke)) ke = conserve_ke zf = .false. if (present(zlevel_faces)) zf = zlevel_faces be = .false. if (present(bnd_extrap)) be = bnd_extrap nu = .false. if (present(nonunif)) nu = nonunif nx = grid%nx_total ny = grid%ny_total nz = size(u_face_x, 3) call remap_x_face_velocity(nx, ny, nz, h_old, h_new, u_face_x, m, ke, zf, be, nu) call remap_y_face_velocity(nx, ny, nz, h_old, h_new, v_face_y, m, ke, zf, be, nu) end subroutine ocean_apply_ale_remap_faces pure subroutine remap_x_face_velocity(nx, ny, nz, h_old, h_new, u_face_x, method, & conserve_ke, zlevel_faces, bnd_extrap, nonunif) !! Flat-impl x-face remap. East faces at i+1/2; u_face_x(1..nx+1) covers !! west wall (I=1), interior (I=2..nx), east wall (I=nx+1). !! `conserve_ke` (default .false.): rescale the baroclinic anomaly so column !! anomaly KE matches pre-remap, barotropic mean preserved. See !! `rescale_anomaly_ke`. integer, intent(in) :: nx, ny, nz, method real(wp), intent(in) :: h_old(nx, ny, nz) real(wp), intent(in) :: h_new(nx, ny, nz) real(wp), intent(inout) :: u_face_x(nx + 1, ny, nz) logical, intent(in) :: conserve_ke logical, intent(in) :: zlevel_faces !! `&vcoord_nml zfixed_closed_faces` — z-level partial steps. !! Builds the face column as the OVERLAP of the two cell !! columns, `min(h_L, h_R)`, instead of their arithmetic mean, !! and then drops any layer at or below `H_VANISHED` from BOTH !! the source and the target column. A layer that is an inert !! filler on one side then has an exactly-zero target thickness, !! which is the one case every `remap_column` variant already !! short-circuits (`dz_new <= 0 ⇒ q_new = 0`), so no momentum is !! poured INTO a closed layer and none is carried OUT of one. !! !! With the arithmetic mean a one-sided filler gives !! `0.5·h_live > 0` — an ordinary half-thickness live layer as !! far as the column kernel is concerned — and the remap happily !! deposits momentum in a cell that geometrically holds no !! water. `min` is also the standard partial-cell face !! thickness, so this is not a special case bolted on: it is the !! face thickness a z-level model has all along. !! !! `.false.` (default) ⇒ every expression is textually the !! arithmetic-mean form ⇒ bit-identical. logical, intent(in) :: bnd_extrap !! Linear-exact boundary-cell reconstruction in the column kernel. logical, intent(in) :: nonunif !! Non-uniform-grid PLM/PPM weights in the column kernel. integer :: I, j, k real(wp) :: h_old_face(NZ_STACK_MAX), h_new_face(NZ_STACK_MAX) real(wp) :: u_old_col(NZ_STACK_MAX), u_new_col(NZ_STACK_MAX) do concurrent(j=1:ny, I=1:nx + 1) & local(k, h_old_face, h_new_face, u_old_col, u_new_col) do k = 1, nz if (I == 1) then h_old_face(k) = h_old(1, j, k) h_new_face(k) = h_new(1, j, k) else if (I == nx + 1) then h_old_face(k) = h_old(nx, j, k) h_new_face(k) = h_new(nx, j, k) else h_old_face(k) = 0.5_wp*(h_old(I - 1, j, k) + h_old(I, j, k)) h_new_face(k) = 0.5_wp*(h_new(I - 1, j, k) + h_new(I, j, k)) end if if (zlevel_faces) then if (I == 1) then h_old_face(k) = h_old(1, j, k) h_new_face(k) = h_new(1, j, k) else if (I == nx + 1) then h_old_face(k) = h_old(nx, j, k) h_new_face(k) = h_new(nx, j, k) else h_old_face(k) = min(h_old(I - 1, j, k), h_old(I, j, k)) h_new_face(k) = min(h_new(I - 1, j, k), h_new(I, j, k)) end if if (h_old_face(k) <= H_VANISHED .or. h_new_face(k) <= H_VANISHED) then h_old_face(k) = 0.0_wp h_new_face(k) = 0.0_wp end if end if u_old_col(k) = u_face_x(I, j, k) end do call remap_column(method, nz, & h_old_face(1:nz), h_new_face(1:nz), & u_old_col(1:nz), u_new_col(1:nz), bnd_extrap, nonunif) if (conserve_ke) then call rescale_anomaly_ke(nz, h_old_face, h_new_face, u_old_col, u_new_col) end if do k = 1, nz u_face_x(I, j, k) = u_new_col(k) end do end do end subroutine remap_x_face_velocity pure subroutine remap_y_face_velocity(nx, ny, nz, h_old, h_new, v_face_y, method, & conserve_ke, zlevel_faces, bnd_extrap, nonunif) !! Flat-impl y-face remap, mirror of `remap_x_face_velocity`. See !! that routine for the `conserve_ke` / `bnd_extrap` / `nonunif` !! semantics. integer, intent(in) :: nx, ny, nz, method real(wp), intent(in) :: h_old(nx, ny, nz) real(wp), intent(in) :: h_new(nx, ny, nz) real(wp), intent(inout) :: v_face_y(nx, ny + 1, nz) logical, intent(in) :: conserve_ke logical, intent(in) :: zlevel_faces !! `&vcoord_nml zfixed_closed_faces` — z-level partial steps. !! Builds the face column as the OVERLAP of the two cell !! columns, `min(h_L, h_R)`, instead of their arithmetic mean, !! and then drops any layer at or below `H_VANISHED` from BOTH !! the source and the target column. A layer that is an inert !! filler on one side then has an exactly-zero target thickness, !! which is the one case every `remap_column` variant already !! short-circuits (`dz_new <= 0 ⇒ q_new = 0`), so no momentum is !! poured INTO a closed layer and none is carried OUT of one. !! !! With the arithmetic mean a one-sided filler gives !! `0.5·h_live > 0` — an ordinary half-thickness live layer as !! far as the column kernel is concerned — and the remap happily !! deposits momentum in a cell that geometrically holds no !! water. `min` is also the standard partial-cell face !! thickness, so this is not a special case bolted on: it is the !! face thickness a z-level model has all along. !! !! `.false.` (default) ⇒ every expression is textually the !! arithmetic-mean form ⇒ bit-identical. logical, intent(in) :: bnd_extrap logical, intent(in) :: nonunif integer :: i, J, k real(wp) :: h_old_face(NZ_STACK_MAX), h_new_face(NZ_STACK_MAX) real(wp) :: v_old_col(NZ_STACK_MAX), v_new_col(NZ_STACK_MAX) do concurrent(J=1:ny + 1, i=1:nx) & local(k, h_old_face, h_new_face, v_old_col, v_new_col) do k = 1, nz if (J == 1) then h_old_face(k) = h_old(i, 1, k) h_new_face(k) = h_new(i, 1, k) else if (J == ny + 1) then h_old_face(k) = h_old(i, ny, k) h_new_face(k) = h_new(i, ny, k) else h_old_face(k) = 0.5_wp*(h_old(i, J - 1, k) + h_old(i, J, k)) h_new_face(k) = 0.5_wp*(h_new(i, J - 1, k) + h_new(i, J, k)) end if if (zlevel_faces) then if (J == 1) then h_old_face(k) = h_old(i, 1, k) h_new_face(k) = h_new(i, 1, k) else if (J == ny + 1) then h_old_face(k) = h_old(i, ny, k) h_new_face(k) = h_new(i, ny, k) else h_old_face(k) = min(h_old(i, J - 1, k), h_old(i, J, k)) h_new_face(k) = min(h_new(i, J - 1, k), h_new(i, J, k)) end if if (h_old_face(k) <= H_VANISHED .or. h_new_face(k) <= H_VANISHED) then h_old_face(k) = 0.0_wp h_new_face(k) = 0.0_wp end if end if v_old_col(k) = v_face_y(i, J, k) end do call remap_column(method, nz, & h_old_face(1:nz), h_new_face(1:nz), & v_old_col(1:nz), v_new_col(1:nz), bnd_extrap, nonunif) if (conserve_ke) then call rescale_anomaly_ke(nz, h_old_face, h_new_face, v_old_col, v_new_col) end if do k = 1, nz v_face_y(i, J, k) = v_new_col(k) end do end do end subroutine remap_y_face_velocity pure subroutine rescale_anomaly_ke(nz, h_old_face, h_new_face, u_old_col, u_new_col) !$acc routine seq !$omp declare target !! KE-conserving rescale of a remapped face-velocity column. !! The column remap conserves momentum (Σ h·u) but not KE; restore it by !! rescaling ONLY the baroclinic anomaly (Adcroft & Hallberg 2006): !! scale = sqrt(KE_old_anom/KE_new_anom), clamped to [0, 1.25] !! u_new(k) = u_bar_new + scale·(u_new(k) - u_bar_new) !! Barotropic mean u_bar preserved verbatim; degenerate columns untouched. integer, intent(in) :: nz real(wp), intent(in) :: h_old_face(NZ_STACK_MAX) real(wp), intent(in) :: h_new_face(NZ_STACK_MAX) real(wp), intent(in) :: u_old_col(NZ_STACK_MAX) real(wp), intent(inout) :: u_new_col(NZ_STACK_MAX) integer :: k real(wp) :: h_old_sum, h_new_sum, mom_old, mom_new real(wp) :: u_bar_old, u_bar_new, ke_old, ke_new, anom, scale_fac h_old_sum = 0.0_wp h_new_sum = 0.0_wp mom_old = 0.0_wp mom_new = 0.0_wp do k = 1, nz h_old_sum = h_old_sum + h_old_face(k) h_new_sum = h_new_sum + h_new_face(k) mom_old = mom_old + h_old_face(k)*u_old_col(k) mom_new = mom_new + h_new_face(k)*u_new_col(k) end do ! Dry / vanishing column: nothing to rescale. if (h_old_sum <= H_FLOOR .or. h_new_sum <= H_FLOOR) return u_bar_old = mom_old/h_old_sum u_bar_new = mom_new/h_new_sum ke_old = 0.0_wp ke_new = 0.0_wp do k = 1, nz anom = u_old_col(k) - u_bar_old ke_old = ke_old + 0.5_wp*h_old_face(k)*anom*anom anom = u_new_col(k) - u_bar_new ke_new = ke_new + 0.5_wp*h_new_face(k)*anom*anom end do ! No anomaly KE on either side ⇒ pure barotropic column, leave as-is. if (ke_new <= 0.0_wp .or. ke_old <= 0.0_wp) return scale_fac = sqrt(ke_old/ke_new) if (scale_fac > 1.25_wp) scale_fac = 1.25_wp if (scale_fac < 0.0_wp) scale_fac = 0.0_wp do k = 1, nz u_new_col(k) = u_bar_new + scale_fac*(u_new_col(k) - u_bar_new) end do end subroutine rescale_anomaly_ke pure subroutine ocean_apply_ale_remap_step(grid, vcoord, ms, bt_eta, bt_H_ref, method, eos, dt) !! Top-level entry the driver calls between outer steps: snapshot h_old once, !! remap centres (h_layer + tracers) AND faces using the same snapshot, then !! re-derive bt_eta. Returns early for EULERIAN_Z/LAGRANGIAN. !! `eos` (optional): required ONLY for VCOORD_RHO (isopycnal density inversion). !! `dt` (optional, s): only for the grid time-filter (regrid_time_scale > 0); !! absent or τ=0 (default) ⇒ filter skipped, bit-identical. type(hgrid_t), intent(in) :: grid type(ocean_vcoord_t), intent(inout) :: vcoord type(multilayer_state_t), intent(inout) :: ms ! assumed-shape-ok: outer-driver allocatable; grid%nx_total available; deferred. real(wp), intent(inout) :: bt_eta(:, :) real(wp), intent(in) :: bt_H_ref(:, :) ! assumed-shape-ok: outer-driver allocatable; grid%nx_total available; deferred integer, intent(in), optional :: method type(eos_t), intent(in), optional :: eos real(wp), intent(in), optional :: dt integer :: t, m, nx, ny, nz, i, j, k real(wp) :: wtd logical :: do_tfilter, conserve_ke, bnd_extrap, nonunif ! Eulerian-z and Lagrangian/isopycnal both skip remap: the former ! holds h at H·dsig via vert-advection cancellation, the latter ! lets h evolve freely (target = current h). if (vcoord%coord_type == VCOORD_EULERIAN_Z .or. & vcoord%coord_type == VCOORD_LAGRANGIAN) return if (.not. vcoord%is_init) return if (.not. allocated(ms%h_layer)) return m = REMAP_PPM if (present(method)) m = method bnd_extrap = vcoord%remap_boundary_extrap nonunif = vcoord%remap_nonuniform_weights nx = grid%nx_total ny = grid%ny_total nz = ms%nz_ml ! 1. Column total + target_h (persistent scratch on vcoord — per-call ! allocates here generate H↔D transfers, not being in the present table). do concurrent(j=1:ny, i=1:nx) local(k) vcoord%remap_total_h(i, j) = 0.0_wp do k = 1, nz vcoord%remap_total_h(i, j) = vcoord%remap_total_h(i, j) & + ms%h_layer(i, j, k) end do end do do concurrent(j=1:ny, i=1:nx) vcoord%remap_h_ref(i, j) = vcoord%remap_total_h(i, j) - bt_eta(i, j) end do ! 2. Snapshot h_old on-device (host source= reads stale memory after a ! dynamics-side OpenACC kernel). Before target_h for VCOORD_RHO's inversion. do concurrent(k=1:nz, j=1:ny, i=1:nx) vcoord%remap_h_old(i, j, k) = ms%h_layer(i, j, k) end do ! 3. Populate target_h. Geometric coords use compute_target_h; isopycnal ! needs per-layer T/S concentrations + EOS via compute_target_h_rho. ! Concentrations built into persistent scratch (guarded c = hTr/h). if ((vcoord%coord_type == VCOORD_RHO .or. vcoord%coord_type == VCOORD_HYCOM) & .and. present(eos) .and. allocated(ms%tracers) & .and. ms%idx_temperature > 0 .and. ms%idx_salinity > 0) then call build_ts_concentration(nx, ny, nz, vcoord%remap_h_old, & ms%tracers(ms%idx_temperature)%hTr, & ms%tracers(ms%idx_salinity)%hTr, & vcoord%remap_conc_t, vcoord%remap_conc_s) ! HYCOM = RHO inversion + z*-floor/monotonize deltas (hybrid=.true.); ! pure RHO passes hybrid=.false. (bit-identical to RHO-only kernel). call vcoord%compute_target_h_rho(vcoord%remap_h_ref, bt_eta, & vcoord%remap_conc_t, vcoord%remap_conc_s, eos, & hybrid=(vcoord%coord_type == VCOORD_HYCOM)) else call vcoord%compute_target_h(vcoord%remap_h_ref, bt_eta) end if ! 3b. Grid time-filter (White & Adcroft 2008): relax target toward it from ! the old grid by wtd = dt/(τ+dt). Convex blend ⇒ column total conserved. ! τ=0 (default) or dt absent ⇒ skipped, bit-identical. do_tfilter = vcoord%regrid_time_scale > 0.0_wp .and. present(dt) if (do_tfilter) then wtd = dt/(vcoord%regrid_time_scale + dt) do concurrent(k=1:nz, j=1:ny, i=1:nx) vcoord%target_h(i, j, k) = vcoord%remap_h_old(i, j, k) & + wtd*(vcoord%target_h(i, j, k) - vcoord%remap_h_old(i, j, k)) end do end if ! `vcoord%remap_h_old` and `vcoord%target_h` are the exact pair every ! column kernel below consumes, and neither is overwritten by steps 4-7 ! — so `ocean_remap_scan_preconditions` can assert them from the ! (impure) driver AFTER this call. See `&vcoord_nml ! remap_check_preconditions` and `rdb_ocean_dyn :: ocean_dyn_step_split`. ! 4. Tracer remap (centre cells) if (allocated(ms%tracers)) then do t = 1, size(ms%tracers) if (.not. allocated(ms%tracers(t)%hTr)) cycle select case (ms%tracers(t)%budget_id) case (TRACER_BUDGET_HEAT) call ocean_remap_tracer_field( & nx, ny, nz, vcoord%remap_h_old, vcoord%target_h, ms%tracers(t)%hTr, m, & bnd_extrap, nonunif, budget=ms%heat_budget_remap) case (TRACER_BUDGET_SALT) call ocean_remap_tracer_field( & nx, ny, nz, vcoord%remap_h_old, vcoord%target_h, ms%tracers(t)%hTr, m, & bnd_extrap, nonunif, budget=ms%salt_budget_remap) case default call ocean_remap_tracer_field( & nx, ny, nz, vcoord%remap_h_old, vcoord%target_h, ms%tracers(t)%hTr, m, & bnd_extrap, nonunif) end select end do end if ! 5. Face-velocity remap (h_old → target_h). KE-conserving anomaly rescale ! gated on the vcoord knob (default off ⇒ momentum-only, bit-identical). conserve_ke = vcoord%remap_vel_conserve_ke if (allocated(ms%u_face_x_layer) .and. allocated(ms%v_face_y_layer)) then call remap_x_face_velocity(nx, ny, nz, vcoord%remap_h_old, vcoord%target_h, & ms%u_face_x_layer, m, conserve_ke, & vcoord%zfixed_closed_faces, bnd_extrap, nonunif) call remap_y_face_velocity(nx, ny, nz, vcoord%remap_h_old, vcoord%target_h, & ms%v_face_y_layer, m, conserve_ke, & vcoord%zfixed_closed_faces, bnd_extrap, nonunif) end if ! 6. h_layer = target_h; capture mass-budget delta do concurrent(k=1:nz, j=1:ny, i=1:nx) ms%mass_budget_remap(i, j, k) = ms%mass_budget_remap(i, j, k) & + (vcoord%target_h(i, j, k) - vcoord%remap_h_old(i, j, k)) ms%h_layer(i, j, k) = vcoord%target_h(i, j, k) end do ! 7. Re-derive bt_eta do concurrent(j=1:ny, i=1:nx) local(k) bt_eta(i, j) = -bt_H_ref(i, j) do k = 1, nz bt_eta(i, j) = bt_eta(i, j) + ms%h_layer(i, j, k) end do end do end subroutine ocean_apply_ale_remap_step pure subroutine ocean_remap_scan_preconditions(nx, ny, nz, h_old, h_new, rel_tol, & n_bad, worst_rel, worst_neg) !! Scan every column for the two remap preconditions and report how !! badly they are missed — the domain-wide counterpart of !! `rdb_remap_column :: remap_column_preconditions_ok`. !! !! `pure` and flat-arg so the caller owns the fail-loud decision and !! the reduction can run on-device; three scalars come back rather than !! a per-column field, so a thermo-cadence call costs one pass plus a !! tiny D→H. Public for the unit-test suite. !! !! CALLER CONTRACT under `-gpu=...,mem:separate`: `h_old` and `h_new` !! must be device-present — the reduction declares them `present` rather !! than letting them copy in, so a caller that forgot the map ABORTS !! instead of silently scanning stale host values and reporting a clean !! bill of health. Production satisfies it through !! `ocean_vcoord_enter_data_impl`, which maps `remap_h_old` and !! `target_h`; a host-side caller must add its own !! `!$acc enter data copyin(...)` plus an `update device` after every !! host edit. integer, intent(in) :: nx, ny, nz real(wp), intent(in) :: h_old(nx, ny, nz) !! Source thicknesses (the pre-remap `h_layer` snapshot). real(wp), intent(in) :: h_new(nx, ny, nz) !! Target thicknesses (`vcoord%target_h`, after any time filter). real(wp), intent(in) :: rel_tol !! Relative tolerance on the column-total match. integer, intent(out) :: n_bad !! Number of columns violating either precondition. real(wp), intent(out) :: worst_rel !! Largest relative column-total mismatch over the domain. real(wp), intent(out) :: worst_neg !! Most negative thickness found (0 when there is none). integer :: i, j, k real(wp) :: s_old, s_new, rel, hmin n_bad = 0 worst_rel = 0.0_wp worst_neg = 0.0_wp !$acc parallel loop collapse(2) present(h_old, h_new) & !$acc reduction(+:n_bad) reduction(max:worst_rel) reduction(min:worst_neg) & !$acc private(k, s_old, s_new, rel, hmin) do j = 1, ny do i = 1, nx s_old = 0.0_wp s_new = 0.0_wp hmin = 0.0_wp do k = 1, nz s_old = s_old + h_old(i, j, k) s_new = s_new + h_new(i, j, k) hmin = min(hmin, h_old(i, j, k), h_new(i, j, k)) end do ! Relative to the LARGER total, so a land column (both zero) ! scores 0 rather than tripping on a 0/0. rel = abs(s_new - s_old)/max(abs(s_old), abs(s_new), H_DIV_EPS) worst_rel = max(worst_rel, rel) worst_neg = min(worst_neg, hmin) if (hmin < 0.0_wp .or. rel > rel_tol) n_bad = n_bad + 1 end do end do end subroutine ocean_remap_scan_preconditions pure subroutine build_ts_concentration(nx, ny, nz, h_old, hTr_T, hTr_S, conc_t, conc_s) !! Build layer-mean T/S concentrations (c = hTr/h) from extensive tracer !! content + pre-remap thicknesses, for the VCOORD_RHO density inversion. !! Per-layer `rdb_vl_conc` (a filler reads `hTr/h`, its donor's !! concentration by I1′; a zero-thickness layer reads 0). Flat-impl, !! explicit-shape; one cadence-bounded launch per remap. integer, intent(in) :: nx, ny, nz real(wp), intent(in) :: h_old(nx, ny, nz) real(wp), intent(in) :: hTr_T(nx, ny, nz) real(wp), intent(in) :: hTr_S(nx, ny, nz) real(wp), intent(out) :: conc_t(nx, ny, nz) real(wp), intent(out) :: conc_s(nx, ny, nz) integer :: i, j, k do concurrent(k=1:nz, j=1:ny, i=1:nx) conc_t(i, j, k) = rdb_vl_conc(hTr_T(i, j, k), h_old(i, j, k)) conc_s(i, j, k) = rdb_vl_conc(hTr_S(i, j, k), h_old(i, j, k)) end do end subroutine build_ts_concentration subroutine ocean_remap_tracer_column(nz, h_old, h_new, hTr_inout, method) !! Single-column unit-test entry: wraps `remap_column` with the !! c = hTr/h ↔ hTr_new = c_new·h_new pattern, including the SAME two-sided !! vanishing-layer merge the production kernel runs !! (`rdb_vl_merge_content` + `rdb_vl_column_conc`), so this entry cannot drift from !! `ocean_remap_tracer_field`. Production callers go through that one. integer, intent(in) :: nz, method real(wp), intent(in) :: h_old(nz), h_new(nz) real(wp), intent(inout) :: hTr_inout(nz) real(wp) :: c_old(NZ_STACK_MAX), c_new(NZ_STACK_MAX) real(wp) :: h_old_col(NZ_STACK_MAX), h_new_col(NZ_STACK_MAX) real(wp) :: hTr_col(NZ_STACK_MAX), hTr_src(NZ_STACK_MAX) integer :: k do k = 1, nz h_old_col(k) = h_old(k) h_new_col(k) = h_new(k) hTr_col(k) = hTr_inout(k) end do call rdb_vl_merge_content(nz, h_old_col, hTr_col) call rdb_vl_column_conc(nz, h_old_col, hTr_col, c_old) call remap_column(method, nz, h_old, h_new, c_old(1:nz), c_new(1:nz)) do k = 1, nz hTr_src(k) = hTr_col(k) hTr_col(k) = c_new(k)*h_new(k) end do call remap_fold_filler_defect(nz, h_old_col, h_new_col, hTr_src, hTr_col) call rdb_vl_merge_content(nz, h_new_col, hTr_col) do k = 1, nz hTr_inout(k) = hTr_col(k) end do end subroutine ocean_remap_tracer_column pure subroutine remap_fold_filler_defect(nz, h_old_col, h_new_col, q_src, q_new) !$acc routine seq !! On a column that carries a vanished layer (source or target), make !! the tracer remap conservative EXACTLY, not just on a matched column: !! the content the remap failed to place, `Σ q_src − Σ q_new`, is !! handed to the topmost live target layer (the donor of any fillers !! above it, so the write-side pool that follows shares it with them). !! !! Why it is needed. `remap_column` conserves only when `Σ h_old == !! Σ h_new`, and the target builders reach that sum by a different !! arithmetic route than continuity does, so they differ by a few ulp !! every step (`OCEAN_REMAP_PRECOND_RTOL`). The unmatched sliver is at !! the TOP of the column, and it goes with its content. Under I1 that !! sliver was an empty filler and the loss was invisible; under I1′ the !! filler carries `c_live`, and on a quasi-steady column the sliver has !! the same sign every step: `cavity_flat_lid_rest_zfixed.nml` AT REST !! lost salt at `-1.2e-13`/day (the whole run's residual: `-3.2e-12` at !! day 30, against `-1.4e-13` under I1) until this fold. !! !! **Bit-identity:** a column with no layer at or below `H_VANISHED` !! on either grid returns before touching anything, so every family !! without fillers (sigma, z*-lite, `eulerian_z`) is unchanged. A !! column with no live target layer is left to the pool (land: zero). integer, intent(in) :: nz real(wp), intent(in) :: h_old_col(NZ_STACK_MAX) real(wp), intent(in) :: h_new_col(NZ_STACK_MAX) real(wp), intent(in) :: q_src(NZ_STACK_MAX) !! Source content the remap was handed (after the read-side pool). real(wp), intent(inout) :: q_new(NZ_STACK_MAX) !! Target content `c_new·h_new`, before the write-side pool. integer :: k, k_top_live logical :: has_filler real(wp) :: defect has_filler = .false. k_top_live = 0 do k = 1, nz if (.not. rdb_vl_is_live(h_old_col(k)) .or. .not. rdb_vl_is_live(h_new_col(k))) then has_filler = .true. end if if (rdb_vl_is_live(h_new_col(k))) k_top_live = k end do if (.not. has_filler .or. k_top_live == 0) return defect = 0.0_wp do k = 1, nz defect = defect + (q_src(k) - q_new(k)) end do q_new(k_top_live) = q_new(k_top_live) + defect end subroutine remap_fold_filler_defect pure subroutine ocean_remap_merge_vanished_content(nz, h_col, q_col) !! Public test shim over the included `rdb_vl_merge_content` — the ONE !! definition of the vanished-layer content rule !! (`src/shared_module_utilities/rdb_vanished_layer.inc`). Production !! code calls the included copy directly; this exists so !! `tests/test_ocean_remap_vanished.F90` can assert the rule's own !! properties without a second transcription of it. integer, intent(in) :: nz real(wp), intent(in) :: h_col(NZ_STACK_MAX) real(wp), intent(inout) :: q_col(NZ_STACK_MAX) call rdb_vl_merge_content(nz, h_col, q_col) end subroutine ocean_remap_merge_vanished_content #include "rdb_vanished_layer.inc" end module rdb_ocean_remap