Ice-shelf basal melt, COUPLED: the slot, the far-field sampler and the
once-per-thermo-step driver that turns rdb_ocean_cavity_melt’s scalar
three-equation kernel into two surface-flux components the ocean
integrates.
&ocean_cavity_melt_nml enable (default .false.) is the gate; the
geometry it stands on (z_draft, cover_frac, p_ice_ref) is
&ocean_cavity_dyn_nml’s and is required. Knob off ⇒ the slot’s
arrays stay at their (1,1) placeholders, no kernel is launched, and
every path is byte-identical.
ocean_cavity_flux_t — the 2-D device-resident state of the
interface: the sampled far field, u*, the interface (T_b, S_b),
the canonical melt mass flux, the ocean → interface heat flux, and
the per-column solver status.cavity_far_field_impl — the far-field sample, in METRES below the
ice base, never “layer nz”.ocean_cavity_flux_step — the driver: sample, solve (through
cavity_melt_columns_2d, which must own the do concurrent; see
its docstring for the nvlink reason), deliver, count, fail loud.The melt rate a three-equation law returns is roughly linear in the
thermal driving it is handed, and the thermal driving depends on HOW
FAR FROM THE ICE the model sampled it. That is the dominant
resolution artefact in the subject, not a refinement: Gwyther et al.
(2020), Burchard et al. (2022) Table 2 p. 15 (the all-bulk melt-rate
error GROWS under refinement — -8 % at 6.66 m, -30 % at 0.015 m) and
Yung et al. (2026) p. 2074 all identify it. Sampling “the top layer”
would therefore make the melt rate a function of the vertical
coordinate’s cell thickness — which is precisely the quantity a
coordinate study must hold fixed. So far_field_depth is in metres
and the sampler is a thickness-weighted mean over the layers that
span it, with a PARTIAL last layer.
Vanished layers (h <= H_VANISHED, the D4 skip/merge marker) carry no
mass and are skipped rather than clamped. A column whose sample finds
no mass at all is dropped from the cover mask — zero melt, OK
status — not solved on a fabricated state.
The liquidus is evaluated at multilayer_state_t%p_top — THE
interface pressure, the same field the FV_MOM6 pressure-stack
boundary condition and the in-situ EOS read (src/core/ocean/README.md,
the p_top seam contract). It is NOT eos%p_ref (a scalar potential
-density reference, deliberately horizontally uniform) and NOT the
eta_forcing seam (a gradient). One pressure, three consumers, and
this module is a CONSUMER — it writes nothing to p_top. The sole
producer is configure_ocean_cavity, which assembles
p_top = metrics%p_ice_ref + sf%p_surf (re-assembled per outer step
in ocean_dyn_step_split when the psurf seam makes sf%p_surf
live); configure_ocean_cavity_melt asserts that it ran rather than
seeding a second copy, because a second writer running after it would
silently drop sf%p_surf. A consequence worth stating: an
atmospheric load under the shelf depresses the freezing point exactly
as the ice load does, with no extra wiring.
| quantity | symbol | unit | sign |
|---|---|---|---|
| melt | melt (m_mass) |
kg/m^2/s | > 0 melting |
| kernel heat flux | q_ocean |
W/m^2 | > 0 warms the INTERFACE (the ocean cools) |
| delivered heat | sf%heat_cavity |
W/m^2 | positive DOWN into the ocean |
| delivered salt | sf%salt_cavity |
same as sf%salt_flux |
positive SALINIFIES |
Heat. q_ocean = rho_w*c_w*gamma_t*(T_w - T_b) > 0 is the
turbulent flux the interface takes FROM the ocean, so the component
the ocean must be given is its negative:
heat_cavity = -q_ocean
The assembler adds it to Q_heat with a plain +, and
apply_surface_src_2d_impl deposits dt/(rho0*cp) * Q_heat at
k = nz — positive warms. Warm water under a shelf therefore COOLS
the top of the column, which is the whole point.
Salt — and why it is not the same construction. Melting adds
freshwater MASS the Boussinesq column does not yet carry (that is
Phase 3). Emulate the dilution with a salt flux and the fixed-mass
equivalent is exact, not approximate. For a column of mass M per
unit area receiving mass m at salinity S_i:
d(M*S)/dt = m*S_i , dM/dt = m
=> MdS/dt = mS_i - Sm = -m(S - S_i)
so the virtual flux that reproduces dS/dt at FIXED M is
salt_cavity = -m_mass*(S_far - s_ice)
referenced to the SAME far-field salinity the solve was handed.
Melting (m_mass > 0, S_far > s_ice) freshens.
The heat twin of that -S*dM/dt dilution term is -m*c_w*(T_w - T_b)
and it is DELIBERATELY DROPPED. The asymmetry is physical, not
sloppiness: S_far - s_ice ~ 34 g/kg is O(1) of the salinity itself,
while T_w - T_b is a few hundredths of a degree, so the dropped heat
term is m/(rho_w*gamma_t) ~ 1e-3 of q_ocean — a 0.1 % correction
that belongs with the real-mass PR, whereas the salt term is the
entire meltwater buoyancy signal and cannot wait for it.
NOTE the prototype’s column_demo.py removes f_turb = rho_w*gamma_s*
(S_w - S_b) = m_mass*(S_b - s_ice) instead. That is the salt flux
ACROSS the interface, not the dilution equivalent — it omits the
meltwater that in reality returns to the column carrying S_b, and so
under-freshens by (S_b - s_ice)/(S_far - s_ice). That demo is
explicitly not part of the oracle (its README: “for reading”); the
identity above is what the ocean’s fixed-mass salinity equation
requires, and it is what the budget test asserts.
&ocean_cavity_melt_nml freshwater)freshwater = "virtual" is the default and everything above is what
runs. freshwater = "mass" moves the meltwater as a real Boussinesq
VOLUME instead. The derivation, in full, because every sign and
every omitted term below is load-bearing.
m_mass is a MASS flux (kg m^-2 s^-1). The model is BOUSSINESQ: it
conserves VOLUME, h_layer is a thickness, and every extensive total
the console prints is a volume integral scaled by a constant density.
The one density that is allowed to convert a mass flux into a
thickness tendency is therefore the Boussinesq reference rho_0 —
the SAME rho_0 the surface-flux apply already divides by
(dS/dt = Q_salt/(rho_0 h)), and the same one the EOS carries.
Using the meltwater’s own freshwater density (~1000) instead would
make a kilogram of meltwater occupy more volume than a kilogram of
the sea water it joins, which in a Boussinesq model is not a
refinement but an inconsistency: the column’s mass total is
rho_0 * volume by construction, so a second density would put the
tracked source and the tracked total on different scales. So
w == m_mass / rho_0 [m/s, > 0 melting]
dh == w * dt added to `h_layer(:,:,nz)`
(The console’s total_mass = sum(h*areaT)*RHO_WATER uses the MODULE
constant RHO_WATER, which a run may configure rho_0 away from.
The tracked mass source is therefore RHO_WATER * sum(dh*areaT), i.e.
(RHO_WATER/rho_0) * sum(m*area*dt) — exactly sum(m*area*dt) at the
default rho_0 = RHO_WATER. The accumulator must measure the same
mass the total does; it is not free to pick its own density.)
Take the ocean column as the control volume. Its top boundary is the
ice base, and the only things that cross it are the meltwater (mass
m, salinity s_ice, temperature T_b) and the turbulent fluxes.
Mass. d h/dt = w.
Salt. Across the ocean’s top boundary, salt enters ADVECTIVELY
with the water leaving the interface (m*S_b) and leaves
DIFFUSIVELY into the interface (rho_w*gamma_S*(S_w - S_b)). The
interface itself stores nothing, so its own balance — the third of
the three equations — is
m*s_ice + rho_w*gamma_S*(S_w - S_b) = m*S_b
and the NET salt the ocean gains is
m*S_b - rho_w*gamma_S*(S_w - S_b) = m*S_b - m*(S_b - s_ice)
= m*s_ice
— the ice is the only thing on the other side of the boundary, and it
carries s_ice per kilogram. The advective and diffusive halves
cancel EXACTLY; there is nothing to approximate. So
d(h*S)/dt = w*s_ice
and for the ISOMIP+ s_ice = 0 the salt content of the column does
not change at all — the salinity falls purely because h grows.
Heat. The same control volume loses the turbulent flux
q_ocean and gains the meltwater’s enthalpy. The model’s heat
content is rho_0*c_p*integral(T dz) with an implicit 0 degC
reference (total_heat = sum(h*T*areaT)*RHO_WATER carries no
offset), so in hTr = h*T space the enthalpy of the added water is
simply dh*T_b: NO heat capacity enters, and the model’s c_p and
the melt law’s own c_w never have to agree.
d(h*T)/dt = -q_ocean/(rho_0*c_p) + w*T_b
Applying both would double-count: the dilution would happen once
through the growing h and once through the removed salt. But the
virtual flux is ALSO the entire meltwater buoyancy signal that KPP
and EPBL read out of Q_salt to build B_0 (beta*Q_salt/rho_0 is
~6x the alpha*Q_heat/(rho_0*c_p) term at these temperatures), and
the real volume source does not reach B_0 at all. Dropping it from
Q_salt would leave both boundary-layer schemes blind to the
dominant, destabilising half of the surface buoyancy flux.
So under "mass" the assembled Q_salt is UNCHANGED — salt_cavity
keeps carrying -m*(S_far - s_ice) and B_0 is the same under both
forms, which is the point — and ocean_cavity_mass_step removes that
increment again from the TRACER in the same stage, adding the real
w*s_ice in its place. The undo is the exact negation of what
apply_surface_src_2d_impl stamped (same dt/rho_0, same field, and
cover_frac = 1 makes the atmospheric group exactly zero on a
covered column), so x + (-x) is zero in floating point and the
salinity tracer load is left EXACTLY unchanged when s_ice = 0.
Both increments are mirrored into salt_budget_surface in the same
order, so the budget sees the identical pair and closes to round-off.
The pseudo-salt mirror receives the same correction, so it keeps
receiving “exactly salinity’s surface salt flux” — its documented
purpose — under either form.
Passive tracers need NOTHING: h grows, h*C does not, so a tracer
with zero concentration in the meltwater is diluted automatically.
The ideal-age tracer is diluted the same way, which is the right
answer (meltwater is new water).
With eps = w*dt/h0 the two forms differ EXACTLY by
dS_mass - dS_virtual = +(S0 - s_ice) * eps^2/(1 + eps)
dT_mass - dT_virtual = -eps*(A + T0 - T_b)/(1 + eps),
A = -q_ocean*dt/(rho_0*c_p*h0) = dT_virtual
so the salinity difference is second order in eps and the
temperature difference is eps times the (tiny) dropped dilution
term plus eps times the virtual increment itself. test_ocean_
cavity_freshwater asserts those two identities rather than asserting
“the two agree”, because agreeing to zero is what a double count
would also do.
volume_compensation)Real mass into a CLOSED domain raises it. ISOMIP+ Sect. 3.1.3 states the protocol’s position: the freshwater flux “is not compensated by a corresponding removal of water elsewhere in the domain” for the restored Ocean0-2 configurations — whose only open seam is the northern restoring band, which relaxes T and S and moves no volume — whereas the closed Ocean3/4 boxes may compensate. Scale, for Ocean0: ~30 m/yr of melt over ~1e10 m^2 of shelf into ~4e10 m^2 of surface is metres per year of sea level. Hence the knob.
volume_compensation = "uniform_open_ocean" removes the
domain-integrated melt volume again each thermo step, spread
uniformly PER UNIT AREA over the wet cells the ice does not cover,
each parcel carrying that cell’s own T and S so no concentration
there changes. It is tracked as a sink in all three budgets (a
parcel that carries S does change the domain SALT total, so the
salt and heat budgets must name it too).
Mass gains a tracked SOURCE (ms%mass_src), accumulated with the same
per-stage weight ms%mass_out uses, so the console residual
(M - M0) + mass_out - mass_src stays at round-off while the total
legitimately grows. Salt and heat need no new accumulator: every
increment this path makes is mirrored into the EXISTING
salt_budget_surface / heat_budget_surface contributors, by the
same in-stage kernel and at the same cadence as the surface fluxes,
so they carry the right ocean_budget_stage_weight by construction.
Both components ride Q_heat/Q_salt, so they are integrated by
ocean_surface_flux_apply_tracers and land in the EXISTING
ms%heat_budget_surface / ms%salt_budget_surface contributors —
which the console already folds in with the correct
ocean_budget_stage_weight. No separate frazil-style accumulator is
needed, and no full-weight/half-weight decision is taken here: the
source is applied by the same per-stage kernel as every other surface
flux, so it carries the same weight by construction.
mem:separateEvery array this module touches is device-resident and mapped by the
slot’s enter_data. Nothing is copied host↔device per step except
the four status COUNTS, which come back as do concurrent ... reduce
results (host scalars), exactly like continuity_t%n_limited_step.
!! Ice-shelf basal melt, COUPLED: the slot, the far-field sampler and the !! once-per-thermo-step driver that turns `rdb_ocean_cavity_melt`'s scalar !! three-equation kernel into two surface-flux components the ocean !! integrates. !! !! `&ocean_cavity_melt_nml enable` (default `.false.`) is the gate; the !! geometry it stands on (`z_draft`, `cover_frac`, `p_ice_ref`) is !! `&ocean_cavity_dyn_nml`'s and is required. Knob off ⇒ the slot's !! arrays stay at their `(1,1)` placeholders, no kernel is launched, and !! every path is byte-identical. !! !! ## What this module owns !! !! 1. `ocean_cavity_flux_t` — the 2-D device-resident state of the !! interface: the sampled far field, `u*`, the interface `(T_b, S_b)`, !! the canonical melt mass flux, the ocean → interface heat flux, and !! the per-column solver status. !! 2. `cavity_far_field_impl` — the far-field sample, in METRES below the !! ice base, never "layer `nz`". !! 3. `ocean_cavity_flux_step` — the driver: sample, solve (through !! `cavity_melt_columns_2d`, which must own the `do concurrent`; see !! its docstring for the nvlink reason), deliver, count, fail loud. !! !! ## Far-field sampling (why metres) !! !! The melt rate a three-equation law returns is roughly linear in the !! thermal driving it is handed, and the thermal driving depends on HOW !! FAR FROM THE ICE the model sampled it. That is the dominant !! resolution artefact in the subject, not a refinement: Gwyther et al. !! (2020), Burchard et al. (2022) Table 2 p. 15 (the all-bulk melt-rate !! error GROWS under refinement — -8 % at 6.66 m, -30 % at 0.015 m) and !! Yung et al. (2026) p. 2074 all identify it. Sampling "the top layer" !! would therefore make the melt rate a function of the vertical !! coordinate's cell thickness — which is precisely the quantity a !! coordinate study must hold fixed. So `far_field_depth` is in metres !! and the sampler is a thickness-weighted mean over the layers that !! span it, with a PARTIAL last layer. !! !! Vanished layers (`h <= H_VANISHED`, the D4 skip/merge marker) carry no !! mass and are skipped rather than clamped. A column whose sample finds !! no mass at all is dropped from the cover mask — zero melt, `OK` !! status — not solved on a fabricated state. !! !! ## The three pressures, and which one this is !! !! The liquidus is evaluated at `multilayer_state_t%p_top` — THE !! interface pressure, the same field the FV_MOM6 pressure-stack !! boundary condition and the in-situ EOS read (`src/core/ocean/README.md`, !! the `p_top` seam contract). It is NOT `eos%p_ref` (a scalar potential !! -density reference, deliberately horizontally uniform) and NOT the !! `eta_forcing` seam (a gradient). One pressure, three consumers, and !! this module is a CONSUMER — it writes nothing to `p_top`. The sole !! producer is `configure_ocean_cavity`, which assembles !! `p_top = metrics%p_ice_ref + sf%p_surf` (re-assembled per outer step !! in `ocean_dyn_step_split` when the psurf seam makes `sf%p_surf` !! live); `configure_ocean_cavity_melt` asserts that it ran rather than !! seeding a second copy, because a second writer running after it would !! silently drop `sf%p_surf`. A consequence worth stating: an !! atmospheric load under the shelf depresses the freezing point exactly !! as the ice load does, with no extra wiring. !! !! ## Sign and units — the table that decides whether the answer is right !! !! | quantity | symbol | unit | sign | !! |---|---|---|---| !! | melt | `melt` (`m_mass`) | kg/m^2/s | **> 0 melting** | !! | kernel heat flux | `q_ocean` | W/m^2 | **> 0 warms the INTERFACE** (the ocean cools) | !! | delivered heat | `sf%heat_cavity` | W/m^2 | positive DOWN into the ocean | !! | delivered salt | `sf%salt_cavity` | same as `sf%salt_flux` | positive SALINIFIES | !! !! **Heat.** `q_ocean = rho_w*c_w*gamma_t*(T_w - T_b) > 0` is the !! turbulent flux the interface takes FROM the ocean, so the component !! the ocean must be given is its negative: !! !! heat_cavity = -q_ocean !! !! The assembler adds it to `Q_heat` with a plain `+`, and !! `apply_surface_src_2d_impl` deposits `dt/(rho0*cp) * Q_heat` at !! `k = nz` — positive warms. Warm water under a shelf therefore COOLS !! the top of the column, which is the whole point. !! !! **Salt — and why it is not the same construction.** Melting adds !! freshwater MASS the Boussinesq column does not yet carry (that is !! Phase 3). Emulate the dilution with a salt flux and the fixed-mass !! equivalent is exact, not approximate. For a column of mass `M` per !! unit area receiving mass `m` at salinity `S_i`: !! !! d(M*S)/dt = m*S_i , dM/dt = m !! => M*dS/dt = m*S_i - S*m = -m*(S - S_i) !! !! so the virtual flux that reproduces `dS/dt` at FIXED `M` is !! !! salt_cavity = -m_mass*(S_far - s_ice) !! !! referenced to the SAME far-field salinity the solve was handed. !! Melting (`m_mass > 0`, `S_far > s_ice`) freshens. !! !! The heat twin of that `-S*dM/dt` dilution term is `-m*c_w*(T_w - T_b)` !! and it is DELIBERATELY DROPPED. The asymmetry is physical, not !! sloppiness: `S_far - s_ice ~ 34 g/kg` is O(1) of the salinity itself, !! while `T_w - T_b` is a few hundredths of a degree, so the dropped heat !! term is `m/(rho_w*gamma_t) ~ 1e-3` of `q_ocean` — a 0.1 % correction !! that belongs with the real-mass PR, whereas the salt term is the !! entire meltwater buoyancy signal and cannot wait for it. !! !! NOTE the prototype's `column_demo.py` removes `f_turb = rho_w*gamma_s* !! (S_w - S_b) = m_mass*(S_b - s_ice)` instead. That is the salt flux !! ACROSS the interface, not the dilution equivalent — it omits the !! meltwater that in reality returns to the column carrying `S_b`, and so !! under-freshens by `(S_b - s_ice)/(S_far - s_ice)`. That demo is !! explicitly not part of the oracle (its README: "for reading"); the !! identity above is what the ocean's fixed-mass salinity equation !! requires, and it is what the budget test asserts. !! !! ## Phase 3 — REAL freshwater MASS (`&ocean_cavity_melt_nml freshwater`) !! !! `freshwater = "virtual"` is the default and everything above is what !! runs. `freshwater = "mass"` moves the meltwater as a real Boussinesq !! VOLUME instead. The derivation, in full, because every sign and !! every omitted term below is load-bearing. !! !! ### Which density converts kg to m !! !! `m_mass` is a MASS flux (kg m^-2 s^-1). The model is BOUSSINESQ: it !! conserves VOLUME, `h_layer` is a thickness, and every extensive total !! the console prints is a volume integral scaled by a constant density. !! The one density that is allowed to convert a mass flux into a !! thickness tendency is therefore the Boussinesq reference `rho_0` — !! the SAME `rho_0` the surface-flux apply already divides by !! (`dS/dt = Q_salt/(rho_0 h)`), and the same one the EOS carries. !! Using the meltwater's own freshwater density (~1000) instead would !! make a kilogram of meltwater occupy more volume than a kilogram of !! the sea water it joins, which in a Boussinesq model is not a !! refinement but an inconsistency: the column's mass total is !! `rho_0 * volume` by construction, so a second density would put the !! tracked source and the tracked total on different scales. So !! !! w == m_mass / rho_0 [m/s, > 0 melting] !! dh == w * dt added to `h_layer(:,:,nz)` !! !! (The console's `total_mass = sum(h*areaT)*RHO_WATER` uses the MODULE !! constant `RHO_WATER`, which a run may configure `rho_0` away from. !! The tracked mass source is therefore `RHO_WATER * sum(dh*areaT)`, i.e. !! `(RHO_WATER/rho_0) * sum(m*area*dt)` — exactly `sum(m*area*dt)` at the !! default `rho_0 = RHO_WATER`. The accumulator must measure the same !! mass the total does; it is not free to pick its own density.) !! !! ### The three top-layer tendencies !! !! Take the ocean column as the control volume. Its top boundary is the !! ice base, and the only things that cross it are the meltwater (mass !! `m`, salinity `s_ice`, temperature `T_b`) and the turbulent fluxes. !! !! **Mass.** `d h/dt = w`. !! !! **Salt.** Across the ocean's top boundary, salt enters ADVECTIVELY !! with the water leaving the interface (`m*S_b`) and leaves !! DIFFUSIVELY into the interface (`rho_w*gamma_S*(S_w - S_b)`). The !! interface itself stores nothing, so its own balance — the third of !! the three equations — is !! !! m*s_ice + rho_w*gamma_S*(S_w - S_b) = m*S_b !! !! and the NET salt the ocean gains is !! !! m*S_b - rho_w*gamma_S*(S_w - S_b) = m*S_b - m*(S_b - s_ice) !! = m*s_ice !! !! — the ice is the only thing on the other side of the boundary, and it !! carries `s_ice` per kilogram. The advective and diffusive halves !! cancel EXACTLY; there is nothing to approximate. So !! !! d(h*S)/dt = w*s_ice !! !! and for the ISOMIP+ `s_ice = 0` the salt content of the column does !! not change at all — the salinity falls purely because `h` grows. !! !! **Heat.** The same control volume loses the turbulent flux !! `q_ocean` and gains the meltwater's enthalpy. The model's heat !! content is `rho_0*c_p*integral(T dz)` with an implicit 0 degC !! reference (`total_heat = sum(h*T*areaT)*RHO_WATER` carries no !! offset), so in `hTr = h*T` space the enthalpy of the added water is !! simply `dh*T_b`: NO heat capacity enters, and the model's `c_p` and !! the melt law's own `c_w` never have to agree. !! !! d(h*T)/dt = -q_ocean/(rho_0*c_p) + w*T_b !! !! ### Why the virtual salt flux must NOT also be applied — and why it !! ### is nevertheless still assembled !! !! Applying both would double-count: the dilution would happen once !! through the growing `h` and once through the removed salt. But the !! virtual flux is ALSO the entire meltwater buoyancy signal that KPP !! and EPBL read out of `Q_salt` to build `B_0` (`beta*Q_salt/rho_0` is !! ~6x the `alpha*Q_heat/(rho_0*c_p)` term at these temperatures), and !! the real volume source does not reach `B_0` at all. Dropping it from !! `Q_salt` would leave both boundary-layer schemes blind to the !! dominant, destabilising half of the surface buoyancy flux. !! !! So under `"mass"` the assembled `Q_salt` is UNCHANGED — `salt_cavity` !! keeps carrying `-m*(S_far - s_ice)` and `B_0` is the same under both !! forms, which is the point — and `ocean_cavity_mass_step` removes that !! increment again from the TRACER in the same stage, adding the real !! `w*s_ice` in its place. The undo is the exact negation of what !! `apply_surface_src_2d_impl` stamped (same `dt/rho_0`, same field, and !! `cover_frac = 1` makes the atmospheric group exactly zero on a !! covered column), so `x + (-x)` is zero in floating point and the !! salinity tracer load is left EXACTLY unchanged when `s_ice = 0`. !! Both increments are mirrored into `salt_budget_surface` in the same !! order, so the budget sees the identical pair and closes to round-off. !! The pseudo-salt mirror receives the same correction, so it keeps !! receiving "exactly salinity's surface salt flux" — its documented !! purpose — under either form. !! !! Passive tracers need NOTHING: `h` grows, `h*C` does not, so a tracer !! with zero concentration in the meltwater is diluted automatically. !! The ideal-age tracer is diluted the same way, which is the right !! answer (meltwater is new water). !! !! ### Agreement with the virtual form, and the size of the difference !! !! With `eps = w*dt/h0` the two forms differ EXACTLY by !! !! dS_mass - dS_virtual = +(S0 - s_ice) * eps^2/(1 + eps) !! dT_mass - dT_virtual = -eps*(A + T0 - T_b)/(1 + eps), !! A = -q_ocean*dt/(rho_0*c_p*h0) = dT_virtual !! !! so the salinity difference is second order in `eps` and the !! temperature difference is `eps` times the (tiny) dropped dilution !! term plus `eps` times the virtual increment itself. `test_ocean_ !! cavity_freshwater` asserts those two identities rather than asserting !! "the two agree", because agreeing to zero is what a double count !! would also do. !! !! ### Sea level (`volume_compensation`) !! !! Real mass into a CLOSED domain raises it. ISOMIP+ Sect. 3.1.3 states !! the protocol's position: the freshwater flux "is not compensated by a !! corresponding removal of water elsewhere in the domain" for the !! restored Ocean0-2 configurations — whose only open seam is the !! northern restoring band, which relaxes T and S and moves no volume — !! whereas the closed Ocean3/4 boxes may compensate. Scale, for Ocean0: !! ~30 m/yr of melt over ~1e10 m^2 of shelf into ~4e10 m^2 of surface is !! metres per year of sea level. Hence the knob. !! !! `volume_compensation = "uniform_open_ocean"` removes the !! domain-integrated melt volume again each thermo step, spread !! uniformly PER UNIT AREA over the wet cells the ice does not cover, !! each parcel carrying that cell's own `T` and `S` so no concentration !! there changes. It is tracked as a sink in all three budgets (a !! parcel that carries `S` does change the domain SALT total, so the !! salt and heat budgets must name it too). !! !! ### Budgets !! !! Mass gains a tracked SOURCE (`ms%mass_src`), accumulated with the same !! per-stage weight `ms%mass_out` uses, so the console residual !! `(M - M0) + mass_out - mass_src` stays at round-off while the total !! legitimately grows. Salt and heat need no new accumulator: every !! increment this path makes is mirrored into the EXISTING !! `salt_budget_surface` / `heat_budget_surface` contributors, by the !! same in-stage kernel and at the same cadence as the surface fluxes, !! so they carry the right `ocean_budget_stage_weight` by construction. !! !! ## Budgets !! !! Both components ride `Q_heat`/`Q_salt`, so they are integrated by !! `ocean_surface_flux_apply_tracers` and land in the EXISTING !! `ms%heat_budget_surface` / `ms%salt_budget_surface` contributors — !! which the console already folds in with the correct !! `ocean_budget_stage_weight`. No separate frazil-style accumulator is !! needed, and no full-weight/half-weight decision is taken here: the !! source is applied by the same per-stage kernel as every other surface !! flux, so it carries the same weight by construction. !! !! ## `mem:separate` !! !! Every array this module touches is device-resident and mapped by the !! slot's `enter_data`. Nothing is copied host↔device per step except !! the four status COUNTS, which come back as `do concurrent ... reduce` !! results (host scalars), exactly like `continuity_t%n_limited_step`. !! !! ## Citations (papers, never another model's source) !! !! * Holland, D. M. and Jenkins, A. (1999): J. Phys. Oceanogr. 29, !! 1787-1800. !! * Jenkins, A., Nicholls, K. W. and Corr, H. F. J. (2010): J. Phys. !! Oceanogr. 40, 2298-2312. !! * Asay-Davis, X. S. et al. (2016): Geosci. Model Dev. 9, 2471-2497 !! (ISOMIP+). !! * Burchard, H. et al. (2022): Ocean Modelling 179, 102119. !! * Yung, C. K. et al. (2025): The Cryosphere 19, 5827-5861. !! * Yung, C. K. et al. (2026): The Cryosphere 20, 2053-2088. module rdb_ocean_cavity_flux use, intrinsic :: iso_fortran_env, only: int64 use rdb_constants, only: wp, H_VANISHED, H_DIV_EPS, RHO_WATER use rdb_grid, only: hgrid_t use rdb_eos, only: eos_t use rdb_mem_report, only: arr_bytes use rdb_multilayer_state, only: multilayer_state_t use rdb_ocean_metrics, only: ocean_metrics_t use rdb_ocean_surface_flux, only: ocean_surface_flux_t use rdb_ocean_cavity_melt, only: ocean_cavity_const_t, ocean_cavity_exchange_t, & ocean_cavity_ice_t, cavity_melt_columns_2d, & CAVITY_MELT_OK, CAVITY_MELT_NONFINITE_INPUT, & CAVITY_MELT_NONFINITE_STATE, & CAVITY_MELT_NOT_CONVERGED, & CAVITY_MELT_NO_PHYSICAL_ROOT, & CAVITY_FW_VIRTUAL, CAVITY_FW_MASS, & CAVITY_VC_NONE, CAVITY_VC_UNIFORM_OPEN implicit none private public :: ocean_cavity_flux_t public :: ocean_cavity_flux_step public :: ocean_cavity_mass_step public :: cavity_far_field_impl public :: cavity_flux_fill_impl public :: cavity_status_counts_impl public :: cavity_melt_status_is_fatal public :: cavity_mass_apply_impl public :: cavity_mass_salt_mirror_impl public :: cavity_mass_totals_impl public :: cavity_comp_apply_impl public :: cavity_comp_scale_tracer_impl public :: cavity_mass_thin_is_fatal public :: cavity_comp_withdrawal public :: H_CAVITY_FLOOR real(wp), parameter :: H_CAVITY_FLOOR = 2.0_wp*H_VANISHED !! Thickness (m) a clamped top-layer WITHDRAWAL is allowed to leave !! behind — one full marker ABOVE `H_VANISHED`, not on it. !! !! `H_VANISHED` is the D4 skip/merge MARKER, and every gate in the !! tree tests it with a strict `>`: a layer sitting exactly ON the !! marker reads as VANISHED. So clamping a melt or compensation !! withdrawal to `H_VANISHED` exactly — which is what these kernels !! used to do, with an in-line comment arguing for landing on the !! marker — hands the next ALE remap a layer it treats as empty: !! `ocean_remap_tracer_column`'s `c = hTr/h` guard returns 0 there, !! so the layer's heat and salt content is DELETED, silently and at !! the next thermo step. One ulp above the marker it is preserved. !! A clamped withdrawal is already a fail-loud condition !! (`cavity_mass_thin_is_fatal`), but the abort happens AFTER the !! state is written, so the value written has to survive being read. !! !! `2*H_VANISHED` is the convention the rest of the tree already !! uses where a producer must land ABOVE the marker on purpose — !! `compute_target_h_rho_impl`'s `h_floor_eff = max(zstar_h_min, !! 2*H_VANISHED)` and `seed_land_h_floor`'s `max(angstrom_h, !! 2*H_VANISHED)`. Written as a multiple of the constant of record !! rather than as a literal, so it cannot drift from it. type :: ocean_cavity_flux_t !! Ice-shelf basal-melt slot: the 2-D interface state plus the !! parameter bundles the kernel is called with. !! !! Every array is `(nx_total, ny_total)` when `enable`, `(1,1)` !! otherwise — the `z_draft` gating convention, latched in !! `ocean_state_init_from_config` BEFORE `init` so the allocation !! gate can read it. logical :: is_init = .false. !! Set by `init` after every allocation succeeds; cleared by !! `destroy` first. Always test this, never `allocated(...)`. logical :: enable = .false. !! `&ocean_cavity_melt_nml enable`, latched before `init`. ! ---- Knobs the kernel is called with (host scalars) ---- real(wp) :: far_field_depth = 10.0_wp !! Thickness (m) below the ice base the far field is averaged !! over. See the module docstring: metres, not layers. real(wp) :: cdrag_top = 2.5e-3_wp !! Top drag coefficient for the MELT friction velocity only. real(wp) :: u_tide = 1.0e-2_wp !! RMS tidal velocity (m/s) in the melt `u*`. real(wp) :: ustar_min = 1.0e-4_wp !! Friction-velocity floor (m/s). real(wp) :: s_ice = 0.0_wp !! Ice salinity (g/kg). integer :: freshwater = CAVITY_FW_VIRTUAL !! `&ocean_cavity_melt_nml freshwater`, parsed. !! `CAVITY_FW_VIRTUAL` (default) ⇒ `ocean_cavity_mass_step` is !! an immediate return and the path is byte-identical. integer :: volume_comp = CAVITY_VC_NONE !! `&ocean_cavity_melt_nml volume_compensation`, parsed. !! Requires `freshwater = CAVITY_FW_MASS` (validate_config). real(wp) :: rho0 = 1035.0_wp !! Boussinesq reference density (kg/m^3) — THE density that !! converts the melt MASS flux into a thickness tendency, and !! the same one `ocean_surface_flux_t%rho0` divides the surface !! tracer sources by. Assigned at configure from the single !! `rho_0` of record; the literal is only the pre-configure type !! default. The two MUST agree or the salt undo is not the !! exact negation of what the apply kernel stamped, which is !! asserted at configure. type(ocean_cavity_exchange_t) :: par !! Exchange-law selector + coefficients. Flat POD; its !! `f_cor` member is unused here (the driver substitutes the !! per-column `f_cor` array). type(ocean_cavity_ice_t) :: ice !! Ice-conduction selector + `T_ice`. Flat POD. type(ocean_cavity_const_t) :: const !! Thermodynamic + turbulence constants. Flat POD, left at the !! ISOMIP+ protocol values (Asay-Davis et al. (2016) Table 4): !! `rho_w`, `c_w`, `alpha_T`, `beta_S` here are the MELT LAW's !! own calibrated constants, NOT copies of the model's !! Boussinesq `rho_0` — the same "out of scope on purpose" !! category as `RHO_WATER` and `&ocean_ice_nml rho_ocean` in !! `src/core/ocean/README.md`'s reference-density table. !! Overriding them would break ISOMIP+ comparability, which is !! the reason this path exists. ! ---- Static, filled once at configure ---- real(wp), allocatable :: f_cor(:, :) !! Cell-centred Coriolis parameter (1/s), from the same !! `fill_coriolis_centre` every other slot uses. Read by !! `hj99` only, as `|f|`; the law does not exist at `f = 0`. ! ---- Per-step interface state (all device-resident) ---- real(wp), allocatable :: active(:, :) !! Composed solve mask (0/1): `cover_frac` AND wet AND "the !! far-field sample found mass". This is what the kernel's !! `cover` argument is given. real(wp), allocatable :: t_far(:, :) !! Far-field temperature (degC), thickness-weighted over !! `far_field_depth`. real(wp), allocatable :: s_far(:, :) !! Far-field salinity (g/kg), same average. real(wp), allocatable :: u_far(:, :) !! Far-field x velocity at the cell CENTRE (m/s), same average. real(wp), allocatable :: v_far(:, :) !! Far-field y velocity at the cell CENTRE (m/s), same average. real(wp), allocatable :: ustar(:, :) !! Melt friction velocity (m/s). real(wp), allocatable :: t_b(:, :) !! Interface temperature (degC), on the liquidus. real(wp), allocatable :: s_b(:, :) !! Interface salinity (g/kg). real(wp), allocatable :: melt(:, :) !! **Canonical** melt mass flux (kg/m^2/s), > 0 melting. real(wp), allocatable :: q_ocean(:, :) !! Turbulent heat flux ocean → interface (W/m^2), > 0 cools the !! ocean. real(wp), allocatable :: gamma_t(:, :) !! Thermal exchange velocity (m/s) the column's solve converged !! on — NOT `par%gamma_t_coeff`, which is the dimensionless !! coefficient. Under `hj99` / `yung25` it is an implicit !! function of the converged interface state, so it is stored !! rather than re-derived: a diagnostic that rebuilt it from !! `u*` alone would report the NEUTRAL value instead of the !! stratification-suppressed one. Zero where inactive. real(wp), allocatable :: gamma_s(:, :) !! Haline exchange velocity (m/s), same convention. integer, allocatable :: status(:, :) !! `CAVITY_MELT_*` per column; `CAVITY_MELT_OK` where inactive. real(wp), allocatable :: comp_scale(:, :) !! Per-cell top-layer thickness RATIO `h_new/h_old` left behind !! by the `volume_compensation` sink, exactly `1` everywhere the !! sink did not act. Every PASSIVE tracer's top-layer load is !! multiplied by it (`cavity_comp_scale_tracer_impl`) so the !! removed parcel carries that cell's own concentration and !! changes none of them. Allocated with the rest of the slot !! (full size iff `enable`); left at `1` when !! `volume_comp = CAVITY_VC_NONE`. ! ---- Status counters (host scalars, reduced off the device) ---- integer :: n_nonfinite_step = 0 !! Active columns returning `NONFINITE_INPUT`/`NONFINITE_STATE` !! this step. **FATAL** — the driver fails loud. integer :: n_not_converged_step = 0 !! Active columns returning `NOT_CONVERGED` this step (zero melt !! applied there — the kernel's documented safe state). integer :: n_no_root_step = 0 !! Active columns returning `NO_PHYSICAL_ROOT` this step. integer :: n_other_step = 0 !! Active columns returning any other non-OK status !! (`BAD_INPUT`, `NO_CORIOLIS`, `LAW_DOMAIN`, ...). integer(int64) :: n_not_converged_total = 0_int64 !! Running total, drained to the console like !! `continuity_t%n_limited_total`. int64 because a !! long run with one stubborn column can exceed 2e9. integer(int64) :: n_no_root_total = 0_int64 !! Running total of `NO_PHYSICAL_ROOT`. integer(int64) :: n_other_total = 0_int64 !! Running total of every other non-OK, non-fatal status. integer :: n_thin_step = 0 !! Columns this thermo step whose top layer could NOT give up !! the thickness the real-mass path asked of it without falling !! through `H_CAVITY_FLOOR` — a FREEZING column (`m < 0`) whose !! top layer is thinner than `|m|*dt/rho_0`, or a compensation !! sink deeper than the open-ocean top layer. The withdrawal is !! CLAMPED to what is there ABOVE the floor (so `h` stays !! strictly above the vanish marker, never on or below it) and !! the count is FATAL: a clamped withdrawal is a !! withdrawal the tracked mass source no longer matches, so !! continuing would print a budget that silently stopped !! closing. Same stance as the non-finite melt column. integer(int64) :: n_thin_total = 0_int64 !! Running total of `n_thin_step`, for the record. real(wp) :: melt_volume_step = 0.0_wp !! Domain-integrated meltwater VOLUME (m^3) added this thermo !! step over the INTERIOR (ghosts excluded, globally combined). !! Diagnostic + the input the compensation sink spends. real(wp) :: open_area = 0.0_wp !! Interior wet area (m^2) the ice does NOT cover, globally !! combined — the denominator of the uniform sink. real(wp) :: comp_withdrawal_step = 0.0_wp !! Thickness (m) the compensation sink removed from each open !! -ocean column this thermo step; zero when compensation is off. contains procedure, non_overridable :: init => ocean_cavity_flux_init procedure, non_overridable :: destroy => ocean_cavity_flux_destroy procedure, non_overridable :: enter_data => ocean_cavity_flux_enter_data procedure, non_overridable :: exit_data => ocean_cavity_flux_exit_data procedure, non_overridable :: bytes => ocean_cavity_flux_bytes end type ocean_cavity_flux_t contains ! ====================================================================== ! Lifecycle ! ====================================================================== subroutine ocean_cavity_flux_init(this, grid) !! Allocate the slot. Gated on `enable` (latched by !! `ocean_state_init_from_config` before this runs), so a run !! without a cavity pays fourteen `(1,1)` placeholders. class(ocean_cavity_flux_t), intent(inout) :: this type(hgrid_t), intent(in) :: grid integer :: nx, ny if (this%enable) then nx = grid%nx_total ny = grid%ny_total else nx = 1 ny = 1 end if allocate (this%f_cor(nx, ny), source=0.0_wp) allocate (this%active(nx, ny), source=0.0_wp) allocate (this%t_far(nx, ny), source=0.0_wp) allocate (this%s_far(nx, ny), source=0.0_wp) allocate (this%u_far(nx, ny), source=0.0_wp) allocate (this%v_far(nx, ny), source=0.0_wp) allocate (this%ustar(nx, ny), source=0.0_wp) allocate (this%t_b(nx, ny), source=0.0_wp) allocate (this%s_b(nx, ny), source=0.0_wp) allocate (this%melt(nx, ny), source=0.0_wp) allocate (this%q_ocean(nx, ny), source=0.0_wp) allocate (this%gamma_t(nx, ny), source=0.0_wp) allocate (this%gamma_s(nx, ny), source=0.0_wp) allocate (this%status(nx, ny), source=CAVITY_MELT_OK) ! `comp_scale` is a RATIO, so its inert value is 1, not 0 — a ! zero-initialised plane would annihilate every passive tracer's ! top layer the first time it was applied. allocate (this%comp_scale(nx, ny), source=1.0_wp) this%is_init = .true. end subroutine ocean_cavity_flux_init subroutine ocean_cavity_flux_destroy(this) !! Release the slot. `is_init` is cleared FIRST. class(ocean_cavity_flux_t), intent(inout) :: this this%is_init = .false. if (allocated(this%f_cor)) deallocate (this%f_cor) if (allocated(this%active)) deallocate (this%active) if (allocated(this%t_far)) deallocate (this%t_far) if (allocated(this%s_far)) deallocate (this%s_far) if (allocated(this%u_far)) deallocate (this%u_far) if (allocated(this%v_far)) deallocate (this%v_far) if (allocated(this%ustar)) deallocate (this%ustar) if (allocated(this%t_b)) deallocate (this%t_b) if (allocated(this%s_b)) deallocate (this%s_b) if (allocated(this%melt)) deallocate (this%melt) if (allocated(this%q_ocean)) deallocate (this%q_ocean) if (allocated(this%gamma_t)) deallocate (this%gamma_t) if (allocated(this%gamma_s)) deallocate (this%gamma_s) if (allocated(this%status)) deallocate (this%status) if (allocated(this%comp_scale)) deallocate (this%comp_scale) end subroutine ocean_cavity_flux_destroy subroutine ocean_cavity_flux_enter_data(this) !! Type-bound wrapper — delegates to the non-polymorphic impl so !! the device-attach map base is the heap object, not a !! polymorphic stack box. class(ocean_cavity_flux_t), intent(inout) :: this select type (this) type is (ocean_cavity_flux_t) call ocean_cavity_flux_enter_data_impl(this) end select end subroutine ocean_cavity_flux_enter_data subroutine ocean_cavity_flux_enter_data_impl(this) !! `copyin` (not `create`) throughout: `f_cor` carries a !! configure-time host fill that MUST reach the device, and the !! rest carry the zero `init` promised on both toolchains. type(ocean_cavity_flux_t), intent(inout) :: this !$acc enter data copyin(this%f_cor, this%active, this%t_far, this%s_far, & !$acc& this%u_far, this%v_far, this%ustar, this%t_b, & !$acc& this%s_b, this%melt, this%q_ocean, this%gamma_t, & !$acc& this%gamma_s, this%status, this%comp_scale) !$acc update device(this%f_cor, this%active, this%t_far, this%s_far, & !$acc& this%u_far, this%v_far, this%ustar, this%t_b, & !$acc& this%s_b, this%melt, this%q_ocean, this%gamma_t, & !$acc& this%gamma_s, this%status, this%comp_scale) end subroutine ocean_cavity_flux_enter_data_impl subroutine ocean_cavity_flux_exit_data(this) class(ocean_cavity_flux_t), intent(inout) :: this select type (this) type is (ocean_cavity_flux_t) call ocean_cavity_flux_exit_data_impl(this) end select end subroutine ocean_cavity_flux_exit_data subroutine ocean_cavity_flux_exit_data_impl(this) type(ocean_cavity_flux_t), intent(inout) :: this !$acc exit data delete(this%f_cor, this%active, this%t_far, this%s_far, & !$acc& this%u_far, this%v_far, this%ustar, this%t_b, & !$acc& this%s_b, this%melt, this%q_ocean, this%gamma_t, & !$acc& this%gamma_s, this%status, this%comp_scale) end subroutine ocean_cavity_flux_exit_data_impl pure function ocean_cavity_flux_bytes(this) result(nbytes) !! Counted allocatable footprint (0 when unallocated). One !! `arr_bytes` term per array — add one here when an array joins !! the type. class(ocean_cavity_flux_t), intent(in) :: this integer(int64) :: nbytes nbytes = arr_bytes(this%f_cor) & + arr_bytes(this%active) & + arr_bytes(this%t_far) & + arr_bytes(this%s_far) & + arr_bytes(this%u_far) & + arr_bytes(this%v_far) & + arr_bytes(this%ustar) & + arr_bytes(this%t_b) & + arr_bytes(this%s_b) & + arr_bytes(this%melt) & + arr_bytes(this%q_ocean) & + arr_bytes(this%gamma_t) & + arr_bytes(this%gamma_s) & + arr_bytes(this%status) & + arr_bytes(this%comp_scale) end function ocean_cavity_flux_bytes ! ====================================================================== ! Far-field sampling ! ====================================================================== pure subroutine cavity_far_field_impl(nx, ny, nz, far_depth, cover, wet_mask, & h_layer, hTr_T, hTr_S, u_face_x, v_face_y, & active, t_far, s_far, u_far, v_far) !! Thickness-weighted mean of `(T, S, u, v)` over `far_depth` !! METRES below the ice base, with a PARTIAL last layer. !! !! Bottom-up stack (`k = nz` is the surface layer, i.e. the one !! against the ice base), so the walk runs `k = nz, 1, -1` and !! stops when the budget is spent. `far_depth >= column !! thickness` ⇒ the whole column, which is the correct limit and !! not an error. !! !! Vanished layers (`h <= H_VANISHED`) are SKIPPED, not clamped — !! the D4 taxonomy's skip/merge marker. They carry no mass, so !! including them would be dividing a zero tracer load by a !! near-zero thickness. !! !! Velocities are centred from the C-grid faces per layer BEFORE !! the vertical average (`u_c = 1/2 (u_{i} + u_{i+1})`), not after: !! the two commute only for a uniform column, and the order that !! matches "the speed the ice base feels" is the one that averages !! the centred, per-layer velocity. !! !! `active` is the composed solve mask handed to the melt kernel: !! covered AND wet AND the sample found mass. A covered column !! whose entire sample is vanished gets `active = 0` and zeroed !! outputs — no melt, no status noise. integer, intent(in) :: nx !! First dimension (ghosts included). integer, intent(in) :: ny !! Second dimension. integer, intent(in) :: nz !! Layer count; `k = nz` is the surface / ice-base layer. real(wp), intent(in) :: far_depth !! Sampling thickness (m), > 0. real(wp), intent(in) :: cover(nx, ny) !! Ice-cover fraction (v1 binary). real(wp), intent(in) :: wet_mask(nx, ny) !! Static wet (1) / land (0) mask. real(wp), intent(in) :: h_layer(nx, ny, nz) !! Layer thickness (m). real(wp), intent(in) :: hTr_T(nx, ny, nz) !! `h*T` (degC m). real(wp), intent(in) :: hTr_S(nx, ny, nz) !! `h*S` ((g/kg) m). real(wp), intent(in) :: u_face_x(nx + 1, ny, nz) !! Zonal face velocity (m/s). real(wp), intent(in) :: v_face_y(nx, ny + 1, nz) !! Meridional face velocity (m/s). real(wp), intent(out) :: active(nx, ny) !! Composed solve mask, 0 or 1. real(wp), intent(out) :: t_far(nx, ny) !! Sampled temperature (degC); 0 where inactive. real(wp), intent(out) :: s_far(nx, ny) !! Sampled salinity (g/kg); 0 where inactive. real(wp), intent(out) :: u_far(nx, ny) !! Sampled centred x velocity (m/s); 0 where inactive. real(wp), intent(out) :: v_far(nx, ny) !! Sampled centred y velocity (m/s); 0 where inactive. integer :: i, j, k real(wp) :: acc_h, acc_t, acc_s, acc_u, acc_v, remain, h, w, inv_h do concurrent(j=1:ny, i=1:nx) & local(k, acc_h, acc_t, acc_s, acc_u, acc_v, remain, h, w, inv_h) acc_h = 0.0_wp acc_t = 0.0_wp acc_s = 0.0_wp acc_u = 0.0_wp acc_v = 0.0_wp remain = far_depth if (cover(i, j) > 0.5_wp .and. wet_mask(i, j) > 0.5_wp) then do k = nz, 1, -1 if (remain <= 0.0_wp) exit h = h_layer(i, j, k) ! vanished-ok: the far-field sampler SKIPS a vanished layer entirely (it carries ! no water to melt against) rather than weighting a substituted ! concentration into the average. if (h <= H_VANISHED) cycle w = min(h, remain) inv_h = 1.0_wp/h acc_h = acc_h + w acc_t = acc_t + w*hTr_T(i, j, k)*inv_h acc_s = acc_s + w*hTr_S(i, j, k)*inv_h acc_u = acc_u + w*0.5_wp*(u_face_x(i, j, k) + u_face_x(i + 1, j, k)) acc_v = acc_v + w*0.5_wp*(v_face_y(i, j, k) + v_face_y(i, j + 1, k)) remain = remain - w end do end if if (acc_h > 0.0_wp) then active(i, j) = 1.0_wp t_far(i, j) = acc_t/acc_h s_far(i, j) = acc_s/acc_h u_far(i, j) = acc_u/acc_h v_far(i, j) = acc_v/acc_h else active(i, j) = 0.0_wp t_far(i, j) = 0.0_wp s_far(i, j) = 0.0_wp u_far(i, j) = 0.0_wp v_far(i, j) = 0.0_wp end if end do end subroutine cavity_far_field_impl ! ====================================================================== ! Flux delivery ! ====================================================================== pure subroutine cavity_flux_fill_impl(nx, ny, s_ice, active, melt, q_ocean, s_far, & heat_cavity, salt_cavity) !! Fill the two OWNED surface-flux components from the solved !! interface. FULL OVERWRITE of the whole plane, never `+=` — !! the same rule the sea-ice coupler's fillers follow, and for the !! same reason (a `+=` ratchets across outer steps with no bound). !! !! See the module docstring for the sign derivation. In one line: !! the ocean loses the heat the interface takes (`-q_ocean`), and !! the dilution by meltwater of salinity `s_ice` is emulated at !! fixed column mass by `-m_mass*(S_far - s_ice)`. !! !! No wet-mask factor here: `active` already carries it (the !! sampler composes it), and the assembler multiplies `Q_heat` / !! `Q_salt` by `ms%wet_mask` again. integer, intent(in) :: nx !! First dimension (ghosts included). integer, intent(in) :: ny !! Second dimension. real(wp), intent(in) :: s_ice !! Ice salinity (g/kg). real(wp), intent(in) :: active(nx, ny) !! Composed solve mask. real(wp), intent(in) :: melt(nx, ny) !! Melt mass flux (kg/m^2/s), > 0 melting. real(wp), intent(in) :: q_ocean(nx, ny) !! Ocean → interface heat flux (W/m^2). real(wp), intent(in) :: s_far(nx, ny) !! Far-field salinity the solve used (g/kg). real(wp), intent(out) :: heat_cavity(nx, ny) !! Heat component (W/m^2), positive down into the ocean. real(wp), intent(out) :: salt_cavity(nx, ny) !! Virtual salt component, positive salinifies. integer :: i, j do concurrent(j=1:ny, i=1:nx) if (active(i, j) > 0.5_wp) then heat_cavity(i, j) = -q_ocean(i, j) salt_cavity(i, j) = -melt(i, j)*(s_far(i, j) - s_ice) else heat_cavity(i, j) = 0.0_wp salt_cavity(i, j) = 0.0_wp end if end do end subroutine cavity_flux_fill_impl ! ====================================================================== ! Status accounting ! ====================================================================== pure subroutine cavity_status_counts_impl(nx, ny, active, status, n_nonfinite, & n_not_converged, n_no_root, n_other) !! Reduce the per-column status plane into four counts, ON DEVICE. !! !! `do concurrent ... reduce(+:)` and not `count(...)`: the array !! is device-resident under `mem:separate`, and a host intrinsic !! would silently read the stale host shadow — the same trap !! `continuity_t%n_limited_step` documents. !! !! Only ACTIVE columns are counted. An inactive column always !! carries `CAVITY_MELT_OK`, but gating the count on the mask !! keeps that a property of the caller rather than of the kernel. integer, intent(in) :: nx !! First dimension. integer, intent(in) :: ny !! Second dimension. real(wp), intent(in) :: active(nx, ny) !! Composed solve mask. integer, intent(in) :: status(nx, ny) !! `CAVITY_MELT_*` per column. integer, intent(out) :: n_nonfinite !! Count of `NONFINITE_INPUT` + `NONFINITE_STATE`. integer, intent(out) :: n_not_converged !! Count of `NOT_CONVERGED`. integer, intent(out) :: n_no_root !! Count of `NO_PHYSICAL_ROOT`. integer, intent(out) :: n_other !! Count of every other non-OK status. integer :: i, j, c_nf, c_nc, c_nr, c_ot c_nf = 0 c_nc = 0 c_nr = 0 c_ot = 0 do concurrent(j=1:ny, i=1:nx) reduce(+:c_nf, c_nc, c_nr, c_ot) if (active(i, j) > 0.5_wp) then select case (status(i, j)) case (CAVITY_MELT_OK) continue case (CAVITY_MELT_NONFINITE_INPUT, CAVITY_MELT_NONFINITE_STATE) c_nf = c_nf + 1 case (CAVITY_MELT_NOT_CONVERGED) c_nc = c_nc + 1 case (CAVITY_MELT_NO_PHYSICAL_ROOT) c_nr = c_nr + 1 case default c_ot = c_ot + 1 end select end if end do n_nonfinite = c_nf n_not_converged = c_nc n_no_root = c_nr n_other = c_ot end subroutine cavity_status_counts_impl pure function cavity_melt_status_is_fatal(n_nonfinite) result(is_fatal) !! Is this step's status tally a FAIL-LOUD condition? !! !! A non-finite input or intermediate on a covered column means the !! state feeding the interface is already corrupt; the kernel's !! safe state (zero melt) would hide it behind a plausible run. !! `NOT_CONVERGED` and `NO_PHYSICAL_ROOT` are NOT fatal — they are !! counted and warned, and those columns take the documented !! zero-melt safe state. !! !! A `pure` predicate so the test suite can assert the DECISION !! without provoking `error stop`, the way the repo tests every !! other fail-loud rule. integer, intent(in) :: n_nonfinite !! `n_nonfinite_step` from `cavity_status_counts_impl`. logical :: is_fatal is_fatal = (n_nonfinite > 0) end function cavity_melt_status_is_fatal ! ====================================================================== ! Phase 3 — real freshwater MASS ! ====================================================================== pure subroutine cavity_mass_apply_impl(nx, ny, nz, dt_over_rho0, inv_rho0_dt, s_ice, & active, melt, s_far, t_b, & h_layer, hTr_S, hTr_T, & salt_budget, heat_budget, k_top, n_thin) !! The real-mass top-layer source: add the meltwater VOLUME to !! `h_layer` at the first LIVE layer `k_top(i,j)`, replace the !! virtual salt flux the assembler !! stamped with the real advective salt `w*s_ice`, and add the !! enthalpy `dh*T_b` — mirroring both tracer increments into the !! existing surface budget contributors. !! !! See the module docstring for the derivation. In one line: !! `dh = m*dt/rho_0`, `d(hS) = dh*s_ice`, `d(hT) = dh*T_b`, on top !! of the `-q_ocean` the assembler already delivers through !! `Q_heat`. !! !! `dt_over_rho0` and `inv_rho0_dt` are the SAME scalar, passed !! twice on purpose: the first multiplies `melt`, the second is the !! `dt/rho_0` the surface-flux apply used, and the salt undo is !! only the EXACT negation of that stamp if the two are the same !! bit pattern. Passing one argument and reusing it makes that !! structural rather than a comment. !! !! FREEZING (`m < 0`) withdraws. A column whose top layer cannot !! give up `|dh|` without falling through `H_CAVITY_FLOOR` is !! CLAMPED to what is there above that floor and COUNTED; the !! caller treats a non-zero count as fatal !! (`cavity_mass_thin_is_fatal`), because a clamped withdrawal no !! longer matches the tracked mass source. The clamp is a plain !! `max` on a quantity that has already been range-tested, not a !! NaN-laundering `if/else` chain: a non-finite `melt` is caught !! upstream by the solver's fatal status. !! !! The floor is `H_CAVITY_FLOOR`, not `H_VANISHED`, and that gap is !! load-bearing: the abort fires AFTER this kernel has written the !! state, so whatever it leaves behind gets read at least once more. !! A layer left exactly ON the marker reads as VANISHED to every !! strict-`>` gate downstream, and `ocean_remap_tracer_column` then !! returns `hTr = 0` for it — the heat and salt the clamp was !! protecting are deleted by the very next remap. See !! `H_CAVITY_FLOOR`. integer, intent(in) :: nx !! First dimension (ghosts included). integer, intent(in) :: ny !! Second dimension. integer, intent(in) :: nz !! Layer count; `k = nz` is the surface / ice-base layer. real(wp), intent(in) :: dt_over_rho0 !! `dt/rho_0` (m^3 s / kg) — multiplies `melt` to give `dh`. real(wp), intent(in) :: inv_rho0_dt !! The same `dt/rho_0`, used to undo the surface-flux stamp. real(wp), intent(in) :: s_ice !! Ice salinity (g/kg). real(wp), intent(in) :: active(nx, ny) !! Composed solve mask; 1 only on covered, wet, sampled columns. real(wp), intent(in) :: melt(nx, ny) !! Melt mass flux (kg/m^2/s), > 0 melting. real(wp), intent(in) :: s_far(nx, ny) !! Far-field salinity (g/kg) the solve used. The VIRTUAL salt !! component `-melt*(s_far - s_ice)` is REBUILT from it here, !! with the same expression `cavity_flux_fill_impl` uses, rather !! than read back off `sf`: on a covered column the assembler's !! open-water factor is exactly zero, so `Q_salt` IS that !! component bit for bit, and rebuilding it keeps this kernel !! free of the surface-flux slot. real(wp), intent(in) :: t_b(nx, ny) !! Interface temperature (degC): the temperature the meltwater !! joins the column at. real(wp), intent(inout) :: h_layer(nx, ny, nz) !! Layer thickness (m); only `k = k_top(i,j)` is touched. real(wp), intent(inout) :: hTr_S(nx, ny, nz) !! `h*S` ((g/kg) m). real(wp), intent(inout) :: hTr_T(nx, ny, nz) !! `h*T` (degC m). real(wp), intent(inout) :: salt_budget(nx, ny, nz) !! `ms%salt_budget_surface`. real(wp), intent(inout) :: heat_budget(nx, ny, nz) !! `ms%heat_budget_surface`. integer, intent(in) :: k_top(nx, ny) !! `ms%k_top` — the first LIVE layer counting down from the !! top, `nz` wherever nothing vanishes against the top. Under !! a quasi-geopotential coordinate beneath the shelf `k = nz` is !! an inert filler on every covered column: growing IT would put !! the meltwater where the remap drain deletes it, and shrinking !! it would trip the fatal thin-withdrawal clamp on every column !! at once (a filler is already AT the marker). integer, intent(out) :: n_thin !! Columns whose withdrawal had to be clamped. integer :: i, j, kt, c_thin real(wp) :: dh, h0, hn, cell_s, cell_t, virt c_thin = 0 do concurrent(j=1:ny, i=1:nx) local(kt, dh, h0, hn, cell_s, cell_t, virt) reduce(+:c_thin) if (active(i, j) > 0.5_wp) then kt = k_top(i, j) h0 = h_layer(i, j, kt) dh = melt(i, j)*dt_over_rho0 hn = h0 + dh if (dh < 0.0_wp .and. hn < H_CAVITY_FLOOR) then ! Pin the RESULT at the floor and derive the applied `dh` ! from it, rather than pinning `dh` and adding: `h0 + ! (H_CAVITY_FLOOR - h0)` rounds and can land a ulp BELOW ! the floor, which is the one place a thin-layer gate must ! never be. ! ! `min(h0, ...)` takes only what sits ABOVE the floor: a ! column already at or below it has its withdrawal REFUSED ! (`hn = h0`, `dh = 0`) rather than turned into a deposit. ! Clamping `hn` UP to the floor there would invent mass the ! tracked source does not name — a worse failure than the ! one being prevented, and it is what the unconditional ! `hn = marker` form used to do. ! ! Gated on `dh < 0` for the same reason: a MELTING column ! (`dh >= 0`) is adding mass, and a thin result then means ! the layer was already thin on arrival — not this kernel's ! doing, and not this kernel's to fabricate away. hn = min(h0, H_CAVITY_FLOOR) dh = hn - h0 c_thin = c_thin + 1 end if virt = -melt(i, j)*(s_far(i, j) - s_ice) cell_s = -inv_rho0_dt*virt + dh*s_ice cell_t = dh*t_b(i, j) h_layer(i, j, kt) = hn hTr_S(i, j, kt) = hTr_S(i, j, kt) + cell_s salt_budget(i, j, kt) = salt_budget(i, j, kt) + cell_s hTr_T(i, j, kt) = hTr_T(i, j, kt) + cell_t heat_budget(i, j, kt) = heat_budget(i, j, kt) + cell_t end if end do n_thin = c_thin end subroutine cavity_mass_apply_impl pure subroutine cavity_mass_salt_mirror_impl(nx, ny, nz, dt_over_rho0, inv_rho0_dt, & s_ice, active, melt, s_far, hTr, k_top) !! The pseudo-salt mirror of `cavity_mass_apply_impl`'s SALT !! increment, with no budget accumulation — `budget_id = NONE`, so !! it must not touch `salt_budget_surface`. !! !! Pseudo-salt's documented purpose is to be a PASSIVE tracer given !! exactly salinity's surface salt flux, so the deviation measures !! the passive-vs-active transport-path error. Under the real-mass !! form salinity's surface salt flux is `w*s_ice` (and the dilution !! by the added volume, which pseudo-salt gets for free because it !! shares `h`), so the mirror must receive the same correction — or !! it would keep the virtual flux salinity no longer has and the !! deviation would measure the bookkeeping instead of the transport. !! !! The arithmetic is the SAME EXPRESSION as the salt branch above, !! so the increment is bit-identical to salinity's. integer, intent(in) :: nx !! First dimension. integer, intent(in) :: ny !! Second dimension. integer, intent(in) :: nz !! Layer count. real(wp), intent(in) :: dt_over_rho0 !! `dt/rho_0`. real(wp), intent(in) :: inv_rho0_dt !! The same `dt/rho_0` (see the sibling's docstring). real(wp), intent(in) :: s_ice !! Ice salinity (g/kg). real(wp), intent(in) :: active(nx, ny) !! Composed solve mask. real(wp), intent(in) :: melt(nx, ny) !! Melt mass flux (kg/m^2/s). real(wp), intent(in) :: s_far(nx, ny) !! Far-field salinity (g/kg); the virtual component being !! undone is rebuilt from it, exactly as in the sibling. real(wp), intent(inout) :: hTr(nx, ny, nz) !! Pseudo-salt `h*C`. integer, intent(in) :: k_top(nx, ny) !! The SAME first-live-layer index the salt branch used — the !! mirror's whole contract is that its increment is bit-identical !! to salinity's, which includes landing on the same row. integer :: i, j real(wp) :: cell_s, virt do concurrent(j=1:ny, i=1:nx) local(cell_s, virt) if (active(i, j) > 0.5_wp) then virt = -melt(i, j)*(s_far(i, j) - s_ice) cell_s = -inv_rho0_dt*virt + melt(i, j)*dt_over_rho0*s_ice hTr(i, j, k_top(i, j)) = hTr(i, j, k_top(i, j)) + cell_s end if end do end subroutine cavity_mass_salt_mirror_impl pure subroutine cavity_mass_totals_impl(nx, ny, nghost, dt_over_rho0, active, & wet_mask, cover_frac, melt, areaT, & vol_melt, area_open) !! The two INTERIOR integrals the mass budget and the compensation !! sink need, reduced on device in one pass: !! !! `vol_melt` — the meltwater volume (m^3) added this step, !! `sum(melt*dt/rho_0 * areaT)` over active columns; !! `area_open` — the wet area (m^2) the ice does NOT cover. !! !! GHOSTS ARE EXCLUDED (`nghost+1 .. n-nghost`), exactly as !! `ocean_accumulate_mass_out` excludes them: the console's totals !! are interior integrals, so a source term that counted the halo !! would be measuring a different domain than the total it has to !! close. The per-cell APPLY still runs over the whole array, like !! every other surface kernel — the halo exchange owns the ghosts. !! !! `melt*dt_over_rho0` is formed with the same scalar and in the !! same order as the apply kernel's `dh`, so the two agree to the !! last bit per cell and the residual is reduction round-off only. integer, intent(in) :: nx !! First dimension (ghosts included). integer, intent(in) :: ny !! Second dimension. integer, intent(in) :: nghost !! Halo width to exclude on every side. real(wp), intent(in) :: dt_over_rho0 !! `dt/rho_0`. real(wp), intent(in) :: active(nx, ny) !! Composed solve mask. real(wp), intent(in) :: wet_mask(nx, ny) !! Static wet (1) / land (0) mask. real(wp), intent(in) :: cover_frac(nx, ny) !! Ice-cover fraction (v1 binary). real(wp), intent(in) :: melt(nx, ny) !! Melt mass flux (kg/m^2/s). real(wp), intent(in) :: areaT(nx, ny) !! Cell area (m^2). real(wp), intent(out) :: vol_melt !! Meltwater volume this step (m^3). real(wp), intent(out) :: area_open !! Uncovered wet area (m^2). integer :: i, j, i_lo, i_hi, j_lo, j_hi real(wp) :: acc_v, acc_a i_lo = nghost + 1 i_hi = nx - nghost j_lo = nghost + 1 j_hi = ny - nghost acc_v = 0.0_wp acc_a = 0.0_wp do concurrent(j=j_lo:j_hi, i=i_lo:i_hi) reduce(+:acc_v, acc_a) if (active(i, j) > 0.5_wp) then acc_v = acc_v + melt(i, j)*dt_over_rho0*areaT(i, j) end if if (wet_mask(i, j) > 0.5_wp .and. cover_frac(i, j) < 0.5_wp) then acc_a = acc_a + areaT(i, j) end if end do vol_melt = acc_v area_open = acc_a end subroutine cavity_mass_totals_impl pure function cavity_comp_withdrawal(vol_melt, area_open) result(dw) !! The uniform per-unit-area thickness the `uniform_open_ocean` !! sink removes: `vol_melt/area_open`, and exactly zero when there !! is no open ocean to remove it from (a fully ice-covered domain — !! the sink then does nothing rather than dividing by zero, and the !! volume stays in, which the mass budget reports honestly as a !! growing total). !! !! `pure`, separate from the kernels, so the test suite can assert !! the arithmetic and the no-open-ocean fallback directly. real(wp), intent(in) :: vol_melt !! Meltwater volume this step (m^3); either sign. real(wp), intent(in) :: area_open !! Uncovered wet area (m^2), >= 0. real(wp) :: dw if (area_open > H_DIV_EPS) then dw = vol_melt/area_open else dw = 0.0_wp end if end function cavity_comp_withdrawal pure subroutine cavity_comp_apply_impl(nx, ny, nz, dw, wet_mask, cover_frac, & h_layer, hTr_S, hTr_T, & salt_budget, heat_budget, scale, n_thin) !! The `volume_compensation = "uniform_open_ocean"` sink: remove !! `dw` metres of the top layer from every wet cell the ice does !! NOT cover, the removed parcel carrying that cell's own `T` and !! `S` so no concentration there changes. !! !! "Carries its own concentration" is implemented as a RATIO: !! `hTr *= h_new/h_old`, which leaves `Tr = hTr/h` algebraically !! identical. The ratio is published in `scale` so every PASSIVE !! tracer can be given exactly the same treatment !! (`cavity_comp_scale_tracer_impl`) — a tracer left alone here !! would be CONCENTRATED by the sink, which is a different physical !! statement from the one the knob makes. !! !! The salt and heat increments ARE mirrored into the surface !! budget contributors: a parcel that carries `S` out of the domain !! changes the domain salt total, so the budget has to name it. !! !! Same clamp (`H_CAVITY_FLOOR`, strictly above the vanish marker) !! + count + fatal policy as the mass source. integer, intent(in) :: nx !! First dimension (ghosts included). integer, intent(in) :: ny !! Second dimension. integer, intent(in) :: nz !! Layer count; only `k = nz` is touched. real(wp), intent(in) :: dw !! Thickness (m) to remove from each open-ocean column. real(wp), intent(in) :: wet_mask(nx, ny) !! Static wet (1) / land (0) mask. real(wp), intent(in) :: cover_frac(nx, ny) !! Ice-cover fraction (v1 binary). real(wp), intent(inout) :: h_layer(nx, ny, nz) !! Layer thickness (m). real(wp), intent(inout) :: hTr_S(nx, ny, nz) !! `h*S`. real(wp), intent(inout) :: hTr_T(nx, ny, nz) !! `h*T`. real(wp), intent(inout) :: salt_budget(nx, ny, nz) !! `ms%salt_budget_surface`. real(wp), intent(inout) :: heat_budget(nx, ny, nz) !! `ms%heat_budget_surface`. real(wp), intent(out) :: scale(nx, ny) !! `h_new/h_old` where the sink acted, exactly 1 elsewhere. integer, intent(out) :: n_thin !! Columns whose withdrawal had to be clamped. integer :: i, j, c_thin real(wp) :: h0, hn, d, f, cell_s, cell_t c_thin = 0 do concurrent(j=1:ny, i=1:nx) local(h0, hn, d, f, cell_s, cell_t) reduce(+:c_thin) if (wet_mask(i, j) > 0.5_wp .and. cover_frac(i, j) < 0.5_wp) then h0 = h_layer(i, j, nz) d = dw hn = h0 - d if (hn < H_CAVITY_FLOOR) then ! Same floor and same refusal rule as the mass source: pin ! the RESULT (never the increment — `h0 - (h0 - floor)` ! rounds and can land a ulp below), take only what sits ! ABOVE the floor, and take nothing at all from a column ! already at or below it (`min(h0, ...)`, so `hn <= h0` ! always and the sink can never become a deposit). hn = min(h0, H_CAVITY_FLOOR) c_thin = c_thin + 1 end if if (h0 > H_DIV_EPS) then f = hn/h0 else f = 1.0_wp end if cell_s = hTr_S(i, j, nz)*(f - 1.0_wp) cell_t = hTr_T(i, j, nz)*(f - 1.0_wp) h_layer(i, j, nz) = hn hTr_S(i, j, nz) = hTr_S(i, j, nz) + cell_s salt_budget(i, j, nz) = salt_budget(i, j, nz) + cell_s hTr_T(i, j, nz) = hTr_T(i, j, nz) + cell_t heat_budget(i, j, nz) = heat_budget(i, j, nz) + cell_t scale(i, j) = f else scale(i, j) = 1.0_wp end if end do n_thin = c_thin end subroutine cavity_comp_apply_impl pure subroutine cavity_comp_scale_tracer_impl(nx, ny, nz, scale, hTr) !! Apply the compensation sink's top-layer ratio to one PASSIVE !! tracer's load. Unconditional (`scale = 1` off the sink), so !! there is no mask branch inside the kernel. integer, intent(in) :: nx !! First dimension. integer, intent(in) :: ny !! Second dimension. integer, intent(in) :: nz !! Layer count. real(wp), intent(in) :: scale(nx, ny) !! `h_new/h_old` from `cavity_comp_apply_impl`. real(wp), intent(inout) :: hTr(nx, ny, nz) !! The tracer's `h*C`. integer :: i, j do concurrent(j=1:ny, i=1:nx) hTr(i, j, nz) = hTr(i, j, nz)*scale(i, j) end do end subroutine cavity_comp_scale_tracer_impl pure function cavity_mass_thin_is_fatal(n_thin) result(is_fatal) !! Is this step's clamped-withdrawal tally a FAIL-LOUD condition? !! !! Yes, on any count. A clamped withdrawal is one the tracked mass !! source no longer matches, so from that step on the console's !! `Mass Error` would stop being a leak measurement while still !! being printed as one — the exact failure mode the budget gate !! exists to make impossible. `h` is nevertheless clamped first, !! so the state handed to the abort path is finite and the last !! printed line is meaningful. !! !! A `pure` predicate, like `cavity_melt_status_is_fatal`, so the !! suite can assert the DECISION without provoking `error stop`. integer, intent(in) :: n_thin !! `n_thin_step`. logical :: is_fatal is_fatal = (n_thin > 0) end function cavity_mass_thin_is_fatal subroutine ocean_cavity_mass_step(grid, metrics, cav, ms, dt, weight, active) !! The real-freshwater MASS update — one call, at the THERMO !! cadence, from inside the RK2 stage immediately after !! `ocean_surface_flux_apply_tracers`. !! !! WHY THERE, and not in `engine_step_finalize` next to the melt !! solve. Three things have to line up: !! !! 1. **The same melt value.** The salt and heat halves of the !! melt ride `Q_salt`/`Q_heat`, which the stage's surface-flux !! apply spends. Putting the volume anywhere else would spend !! `melt(n)` for mass and `melt(n-1)` for salt. !! 2. **The same stage weight, by construction.** Under !! `ssp_rk2` both stages apply and `rk2_average` halves the !! pair; under `pred_corr` `therm_active` is false in the !! predictor and the corrector's single application IS the !! step. The caller passes the matching `weight` for the !! budget accumulator, the same one `ocean_accumulate_mass_out` !! takes. !! 3. **The barotropic mode sees it.** `derive_bt_from_layers` !! rebuilds `bt_eta = sum_k h - bt_H_ref` at the TOP of every !! stage, so a thickness source applied here is in `bt_eta` !! one stage later with no separate barotropic forcing term — !! the free surface under the cavity datum simply rises. The !! BT substep's transport renormalisation (`bt_uhbt`) is a !! constraint on the layer TRANSPORTS within a stage and never !! reads a thickness source, so it is undisturbed. This is !! the documented `F_slow`-style operator split, and it is what !! MOM6 does with its own surface mass fluxes. !! !! It runs BEFORE the vertical-mixing block in the same stage, so !! the implicit vdiff/drag solves see the thickened top layer — !! which is the right order: the added volume is part of the column !! before the column is mixed. The ALE remap then redistributes it !! to the coordinate's target after `rk2_average`. !! !! NON-`pure`: it logs, it fails loud, it mutates `ms%mass_src`, !! and (with compensation on) it issues one collective. Its six !! kernels are all `pure`. !! !! `mem:separate`: every array is mapped by its owning slot's !! `enter_data`; the tracer-registry derefs happen on the HOST !! before each kernel (the outer-shim rule). use pic_logger, only: global_logger use pic_strings, only: to_string use rdb_error_ring, only: fail use rdb_ocean_status, only: OCEAN_STATUS_ERR_SETUP use rdb_halo, only: halo_allreduce_sum type(hgrid_t), intent(in) :: grid !! Horizontal grid (`nx_total`/`ny_total`/`nghost`). type(ocean_metrics_t), intent(in) :: metrics !! Reads `cover_frac` and `areaT`. type(ocean_cavity_flux_t), intent(inout) :: cav !! The cavity-melt slot; reads `melt`/`active`/`t_b`, writes !! `comp_scale` and the thin counters. type(multilayer_state_t), intent(inout) :: ms !! Writes `h_layer(:,:,nz)`, the S/T/passive tracer loads, the !! two surface budget contributors and `mass_src`. real(wp), intent(in) :: dt !! Thermo timestep (s) — the same `therm_dt` the surface-flux !! apply was given. real(wp), intent(in) :: weight !! Per-stage weight for the `mass_src` accumulator (0.5 per !! SSP-RK2 stage; 0 / 1 for the pred_corr predictor / !! corrector), matching `ocean_accumulate_mass_out`. logical, intent(in), optional :: active !! Thermo-cadence gate. Present-and-false ⇒ early return; !! absent ⇒ run. integer :: nx, ny, nz, idx_t, idx_s, idx_ps, it, n_thin_src, n_thin_comp real(wp) :: dt_over_rho0, vol_melt, area_open, dw, tmp if (.not. cav%is_init) return if (.not. cav%enable) return if (cav%freshwater /= CAVITY_FW_MASS) return if (present(active)) then if (.not. active) return end if if (.not. allocated(ms%tracers)) return idx_t = ms%idx_temperature idx_s = ms%idx_salinity if (idx_t <= 0 .or. idx_s <= 0) return nx = grid%nx_total ny = grid%ny_total nz = ms%nz_ml idx_ps = ms%idx_pseudo_salt dt_over_rho0 = dt/cav%rho0 ! (1) Interior integrals FIRST: the melt volume the budget tracks ! and the open-ocean area the sink would spend it over. Combined ! across ranks so a decomposed domain removes the same total the ! whole domain gained. call cavity_mass_totals_impl(nx, ny, grid%nghost, dt_over_rho0, cav%active, & ms%wet_mask, metrics%cover_frac, cav%melt, & metrics%areaT, vol_melt, area_open) tmp = vol_melt call halo_allreduce_sum(tmp, vol_melt) tmp = area_open call halo_allreduce_sum(tmp, area_open) cav%melt_volume_step = vol_melt cav%open_area = area_open ! (2) The source. call cavity_mass_apply_impl(nx, ny, nz, dt_over_rho0, dt_over_rho0, cav%s_ice, & cav%active, cav%melt, cav%s_far, cav%t_b, & ms%h_layer, ms%tracers(idx_s)%hTr, ms%tracers(idx_t)%hTr, & ms%salt_budget_surface, ms%heat_budget_surface, & ms%k_top, n_thin_src) if (idx_ps > 0) then call cavity_mass_salt_mirror_impl(nx, ny, nz, dt_over_rho0, dt_over_rho0, & cav%s_ice, cav%active, cav%melt, & cav%s_far, ms%tracers(idx_ps)%hTr, ms%k_top) end if ms%mass_src = ms%mass_src + weight*RHO_WATER*vol_melt ! (3) The sink, when the knob asks for it. n_thin_comp = 0 cav%comp_withdrawal_step = 0.0_wp if (cav%volume_comp == CAVITY_VC_UNIFORM_OPEN) then dw = cavity_comp_withdrawal(vol_melt, area_open) cav%comp_withdrawal_step = dw call cavity_comp_apply_impl(nx, ny, nz, dw, ms%wet_mask, metrics%cover_frac, & ms%h_layer, ms%tracers(idx_s)%hTr, & ms%tracers(idx_t)%hTr, ms%salt_budget_surface, & ms%heat_budget_surface, cav%comp_scale, n_thin_comp) ! NOTE the sink stays on `k = nz` and is NOT routed through ! `k_top`, deliberately: its own gate is `cover_frac < 0.5`, so ! it only ever acts on OPEN-OCEAN columns, and an open-ocean ! column has `z_top = 0` ⇒ no top-side filler ⇒ `k_top ≡ nz`. ! Routing it would be a provable no-op; leaving it spells out ! that "the top layer" and "the first live layer" are the same ! row wherever this kernel runs. `cavity_comp_scale_tracer_impl` ! below rides the same argument (it is unconditional, but ! `comp_scale` is exactly 1 off the sink). do it = 1, size(ms%tracers) if (it == idx_s .or. it == idx_t) cycle call cavity_comp_scale_tracer_impl(nx, ny, nz, cav%comp_scale, & ms%tracers(it)%hTr) end do ! The removed interior volume is exactly `dw*area_open` — the ! same product the withdrawal was derived from — so with no ! clamping this cancels the source term to the last bit and the ! domain mass is constant. ms%mass_src = ms%mass_src - weight*RHO_WATER*dw*area_open end if ! (4) Accounting, then fail loud. cav%n_thin_step = n_thin_src + n_thin_comp cav%n_thin_total = cav%n_thin_total + int(cav%n_thin_step, int64) if (cavity_mass_thin_is_fatal(cav%n_thin_step)) then call global_logger%error("cavity real freshwater: "// & to_string(n_thin_src)//" melt column(s) and "// & to_string(n_thin_comp)//" compensation column(s) "// & "could not give up the requested thickness") call fail("&ocean_cavity_melt_nml freshwater='mass': "// & to_string(cav%n_thin_step)//" column(s) would have been driven "// & "below H_CAVITY_FLOOR (2*H_VANISHED) by the top-layer "// & "withdrawal (a freezing "// & "column thinner than |m|*dt/rho_0, or a compensation sink "// & "deeper than the open-ocean top layer). The withdrawal was "// & "clamped so the state stays finite, but a clamped withdrawal "// & "no longer matches the tracked mass source, so the console's "// & "Mass Error would stop being a leak measurement while still "// & "being printed as one. Use a thicker top layer (a coordinate "// & "with a larger surface target), a shorter dt, or "// & "volume_compensation='none'.", code=OCEAN_STATUS_ERR_SETUP) end if end subroutine ocean_cavity_mass_step ! ====================================================================== ! The driver ! ====================================================================== subroutine ocean_cavity_flux_step(grid, cav, metrics, ms, eos, sf, active) !! One cavity basal-melt update: sample the far field, solve the !! three-equation interface on every covered column, deliver the !! two owned surface-flux components, and account for the solver !! status. !! !! CADENCE + PLACEMENT. Called once per outer step at THERMO !! cadence from `engine_step_finalize`, immediately BEFORE !! `ocean_surface_flux_assemble` — the assembler must see the !! components this routine writes. The tracers then integrate the !! assembled `Q_heat`/`Q_salt` on the NEXT outer step, which is the !! same one-step lag the sea-ice coupler documents. !! !! NON-`pure`, and it is the only non-pure procedure this module !! adds: it logs (`n_not_converged` warnings) and it FAILS LOUD on !! a non-finite column. Its three kernels are all `pure`. !! !! `mem:separate`: every array read or written here is mapped by !! its owning slot's `enter_data`. `ms%tracers(idx)%hTr` is !! dereferenced on the HOST before the kernels (the outer-shim !! rule for the array-of-derived-types registry). use pic_logger, only: global_logger use pic_strings, only: to_string use rdb_error_ring, only: fail use rdb_ocean_status, only: OCEAN_STATUS_ERR_SETUP type(hgrid_t), intent(in) :: grid !! Horizontal grid (for `nx_total`/`ny_total`). type(ocean_cavity_flux_t), intent(inout) :: cav !! The cavity-melt slot. type(ocean_metrics_t), intent(in) :: metrics !! Reads `cover_frac` only. type(multilayer_state_t), intent(in) :: ms !! Reads `h_layer`, the S/T tracer loads, the face velocities, !! `p_top` (THE interface pressure) and `wet_mask`. type(eos_t), intent(in) :: eos !! Shared EOS handle — the liquidus. Flat POD, by value. type(ocean_surface_flux_t), intent(inout) :: sf !! Writes `heat_cavity`/`salt_cavity` and latches !! `has_heat`/`has_salt` host-side. logical, intent(in), optional :: active !! Thermo-cadence gate. Present-and-false ⇒ early return; !! absent ⇒ run (the `ocean_surface_flux_assemble` convention). integer :: nx, ny, nz, idx_t, idx_s if (.not. cav%is_init) return if (.not. cav%enable) return if (present(active)) then if (.not. active) return end if if (.not. allocated(ms%tracers)) return idx_t = ms%idx_temperature idx_s = ms%idx_salinity if (idx_t <= 0 .or. idx_s <= 0) return nx = grid%nx_total ny = grid%ny_total nz = ms%nz_ml call cavity_far_field_impl(nx, ny, nz, cav%far_field_depth, & metrics%cover_frac, ms%wet_mask, ms%h_layer, & ms%tracers(idx_t)%hTr, ms%tracers(idx_s)%hTr, & ms%u_face_x_layer, ms%v_face_y_layer, & cav%active, cav%t_far, cav%s_far, cav%u_far, cav%v_far) call cavity_melt_columns_2d(nx, ny, cav%active, cav%t_far, cav%s_far, ms%p_top, & cav%u_far, cav%v_far, cav%s_ice, cav%f_cor, & cav%cdrag_top, cav%u_tide, cav%ustar_min, & cav%par, cav%ice, eos, cav%const, & cav%ustar, cav%t_b, cav%s_b, cav%melt, cav%q_ocean, & cav%gamma_t, cav%gamma_s, cav%status) call cavity_flux_fill_impl(nx, ny, cav%s_ice, cav%active, cav%melt, cav%q_ocean, & cav%s_far, sf%heat_cavity, sf%salt_cavity) ! The filler's own obligations (PR-12 fill contract): latch the ! has_* flags HOST-side, never from a device reduction. They stay ! latched for the rest of the run — the melt rate can legitimately ! pass through zero, and un-latching there would change the ! apply-path operand count mid-run. sf%has_heat = .true. sf%has_salt = .true. call cavity_status_counts_impl(nx, ny, cav%active, cav%status, & cav%n_nonfinite_step, cav%n_not_converged_step, & cav%n_no_root_step, cav%n_other_step) cav%n_not_converged_total = cav%n_not_converged_total & + int(cav%n_not_converged_step, int64) cav%n_no_root_total = cav%n_no_root_total + int(cav%n_no_root_step, int64) cav%n_other_total = cav%n_other_total + int(cav%n_other_step, int64) if (cavity_melt_status_is_fatal(cav%n_nonfinite_step)) then call fail("&ocean_cavity_melt_nml: "//to_string(cav%n_nonfinite_step)// & " ice-covered column(s) fed the basal-melt solver a non-finite "// & "far-field temperature, salinity, velocity or interface pressure "// & "(CAVITY_MELT_NONFINITE_*). The kernel's safe state is zero melt, "// & "which would hide an already-corrupt column behind a plausible "// & "run, so this is fatal. NOT_CONVERGED / NO_PHYSICAL_ROOT are "// & "counted and warned instead.", code=OCEAN_STATUS_ERR_SETUP) end if if (cav%n_not_converged_step > 0 .or. cav%n_no_root_step > 0 .or. & cav%n_other_step > 0) then call global_logger%warning("cavity melt: zero melt applied on "// & to_string(cav%n_not_converged_step)// & " non-converged, "//to_string(cav%n_no_root_step)// & " no-physical-root and "// & to_string(cav%n_other_step)// & " otherwise-refused column(s) this step") end if end subroutine ocean_cavity_flux_step end module rdb_ocean_cavity_flux