From 52d08cc5cc7f6014dad0cd3ff2080e9861576581 Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Fri, 19 Jun 2026 14:18:42 -0700 Subject: [PATCH 01/36] port forcing tendencies (thickness and tracers) + update doc, yml --- components/omega/configs/Default.yml | 2 + components/omega/doc/devGuide/Forcing.md | 57 ++++- components/omega/doc/userGuide/Forcing.md | 68 ++++++ .../omega/doc/userGuide/TendencyTerms.md | 9 +- components/omega/src/ocn/Forcing.cpp | 35 ++- components/omega/src/ocn/Forcing.h | 2 + components/omega/src/ocn/GlobalConstants.h | 7 +- components/omega/src/ocn/Tendencies.cpp | 73 +++++++ components/omega/src/ocn/Tendencies.h | 2 + components/omega/src/ocn/TendencyTerms.cpp | 11 + components/omega/src/ocn/TendencyTerms.h | 86 ++++++++ .../src/ocn/forcingVars/TracerForcingVars.cpp | 203 ++++++++++++++++++ .../src/ocn/forcingVars/TracerForcingVars.h | 49 +++++ .../test/timeStepping/TimeStepperTest.cpp | 2 + 14 files changed, 598 insertions(+), 8 deletions(-) create mode 100644 components/omega/src/ocn/forcingVars/TracerForcingVars.cpp create mode 100644 components/omega/src/ocn/forcingVars/TracerForcingVars.h diff --git a/components/omega/configs/Default.yml b/components/omega/configs/Default.yml index 9f589f9466cf..e33403c7cc91 100644 --- a/components/omega/configs/Default.yml +++ b/components/omega/configs/Default.yml @@ -58,6 +58,8 @@ Omega: Mode: Implicit Type: Constant BottomDragCoeff: 1.0e-3 + SfcThicknessForcingTendencyEnable: false + SfcTracerForcingTendencyEnable: false TracerHorzAdvTendencyEnable: true TracerDiffTendencyEnable: true EddyDiff2: 10.0 diff --git a/components/omega/doc/devGuide/Forcing.md b/components/omega/doc/devGuide/Forcing.md index 97d38d8ae42d..18e9c2072190 100644 --- a/components/omega/doc/devGuide/Forcing.md +++ b/components/omega/doc/devGuide/Forcing.md @@ -6,7 +6,8 @@ This page describes design and implementation details for forcing-related pathways in Omega, currently this includes: - Surface stress forcing (e.g. wind stress) -- Surface tracer restoring +- Surface flux forcing (actively coupled or data-forced) +- Surface tracer restoring (soon to be ported) ## Surface stress forcing design @@ -37,6 +38,60 @@ pathways in Omega, currently this includes: - `Omega.Tendencies.SfcStressForcingTendencyEnable` - gates execution of surface stress forcing tendency kernel +## Surface flux forcing design + +### Surface flux forcing data flow + +**Thickness equation pathway:** + +1. External fields provide freshwater and salt flux components: + - `SnowFlux`, `RainFlux`, `EvaporationFlux` + - `SeaIceFreshWaterFlux`, `IceRunoffFlux`, `RiverRunoffFlux` + - `SeaIceSaltFlux` +2. `Forcing` stores the flux fields in `TracerForcingVars` +3. The tendency term `SfcThicknessForcingOnCell` sums the freshwater and salt mass fluxes and applies them to +the surface layer pseudo-thickness. + +**Tracer equation pathway:** + +1. External fields provide heat and salt flux components: + - `LatentHeatFlux`, `SensibleHeatFlux` + - `LongWaveHeatFluxUp`, `LongWaveHeatFluxDown` + - `SeaIceHeatFlux`, `ShortWaveHeatFlux` + - `SeaIceSaltFlux`, `SnowFlux`, `IceRunoffFlux` +2. `Forcing` stores the flux fields in `TracerForcingVars` +3. The tendency term `SfcTracerForcingOnCell` converts the summed external heat fluxes to a conservative-temperature tendency, + and applies the external sea-ice salt flux to salinity (g/kg) in the surface layer. + +### Surface flux forcing key classes/components + +- `TracerForcingVars` + - Stores 13 coupled flux cell-centered fields: 6 freshwater fluxes, 6 heat + fluxes, and 1 salt flux component + - Fields initialized to zero and registered in `Forcing` field group +- `SfcThicknessForcingOnCell` tendency term + - Computes freshwater flux contribution: $\sum (\text{SnowFlux} + \text{RainFlux} + \text{EvaporationFlux} + \text{SeaIceFreshWaterFlux} + \text{IceRunoffFlux} + \text{RiverRunoffFlux} + \text{SeaIceSaltFlux}) / \rho_{sw}$ + - Applied only at surface layer (top active layer) using `MinLayerCell` +- `SfcTracerForcingOnCell` tendency term + - For temperature: computes the sum of the six heat-flux fields and scales it by $H_{\text{FluxFac}}$ + - For salinity: applies salt flux with unit conversion: $\text{SeaIceSaltFlux} \times S_{\text{FluxFac}}$ + - Applied only at surface layer using `MinLayerCell` + - Uses tracer index validation to apply to specific tracers only +- `Forcing` + - Manages `TracerForcingVars` instance +- `Tendencies` + - Calls `SfcThicknessForcingOnCell` in `computeThicknessTendenciesOnly` + - Calls `SfcTracerForcingOnCell` in `computeTracerTendenciesOnly` after surface tracer restoring + +### Surface flux forcing config coupling + +- `Omega.Tendencies.SfcThicknessForcingTendencyEnable` + - gates execution of coupled flux thickness kernel + - controls freshwater and salt flux forcing on sea surface height +- `Omega.Tendencies.SfcTracerForcingTendencyEnable` + - gates execution of coupled flux tracer kernel + - controls heat flux forcing on temperature and salt flux forcing on salinity + ## Surface tracer restoring design ### Surface tracer restoring data flow diff --git a/components/omega/doc/userGuide/Forcing.md b/components/omega/doc/userGuide/Forcing.md index d8fac4383730..b49eabd0586f 100644 --- a/components/omega/doc/userGuide/Forcing.md +++ b/components/omega/doc/userGuide/Forcing.md @@ -5,6 +5,7 @@ This page documents the user-facing configuration and behavior for current forcing in Omega: - Surface stress forcing (e.g. wind stress) +- Coupled flux forcing - Surface tracer restoring ## Surface stress forcing @@ -39,6 +40,73 @@ Surface stress forcing uses surface stress input fields: These are stored in forcing variables and used to form edge-normal stress (`NormalStressEdge`) that enters momentum tendencies. +## Surface flux forcing + +Surface flux forcing applies ocean-atmosphere and ocean-sea ice fluxes from the other model +components (atmosphere, sea ice) to the thickness and tracer equations. This enables +the ocean to respond to heat, freshwater, and salt exchanges at the surface. These fluxes can be from data or (active) coupled components. + +### Surface flux forcing configuration + +Surface flux forcing is controlled by two configuration flags: + +```yaml +Omega: + Tendencies: + SfcThicknessForcingTendencyEnable: false + SfcTracerForcingTendencyEnable: false +``` + +- `Tendencies.SfcThicknessForcingTendencyEnable`: enables coupled freshwater and salt flux forcing on thickness +- `Tendencies.SfcTracerForcingTendencyEnable`: enables coupled heat and salt flux forcing on tracers + +### Required input fields + +Coupled flux forcing uses 13 auxiliary fields organized by type: + +**Freshwater mass fluxes (kg m⁻² s⁻¹):** +- `SnowFlux`: precipitation from snow +- `RainFlux`: precipitation from rain +- `EvaporationFlux`: evaporative water loss +- `SeaIceFreshWaterFlux`: freshwater input from sea-ice melt or formation +- `IceRunoffFlux`: runoff from land ice +- `RiverRunoffFlux`: runoff from rivers + +**Heat fluxes (W m⁻²):** +- `LatentHeatFlux`: latent heat transfer +- `SensibleHeatFlux`: sensible heat transfer +- `LongWaveHeatFluxUp`: upward longwave radiation +- `LongWaveHeatFluxDown`: downward longwave radiation +- `SeaIceHeatFlux`: heat from sea-ice interaction +- `ShortWaveHeatFlux`: shortwave (solar) radiation + +**Salt mass flux (kg m⁻² s⁻¹):** +- `SeaIceSaltFlux`: salt flux from sea-ice formation/melt processes + +These fields are populated by external coupling components (typically atmosphere +and ice models). Omega assumes the incoming values match the documented units. +For now, there are assumed to come from a `forcing.nc` file, but later will be provided +by the equivalent `ocn_comp_mct.F`. + +### Notes + +- Coupled fluxes are applied only at the surface layer (top active layer) for each cell. +- Pseudo-thickness tendency is computed from the (six) freshwater mass fluxes and the salt mass flux + `SeaIceSaltFlux`, converted to a pseudo-thickness change. +- Temperature tendency is computed from the sum of the six heat-flux fields, + converted to conservative-temperature tendency via + $H_{\text{FluxFac}} = 1.0 / (\rho_{sw} c^0_{p,sw})$ where $c^0_{p,sw}$ is the reference + specific heat of seawater defined by TEOS-10. [soon to be updated with latent heat and enthalpy of liquid water] +- Salinity tendency from `SeaIceSaltFlux` is scaled by + $S_{\text{FluxFac}} = 1.0e3 / \rho_{sw}$ to account for unit conversion from + kg/(m²·s) to salinity units (g/kg). +- Fluxes are assumed to be in the documented units (i.e. net mass fluxes); + any unit conversion should be performed by the coupling component before providing flux + values to Omega. +- The reference density used here ($\rho_{sw}$) is not a Boussinesq density, it is the + conversion factor from mass to pseudo-thickness. +- No iceberg fluxes are included for now. + ## Surface tracer restoring Surface tracer restoring applies a piston-velocity tendency, or damping, at the ocean diff --git a/components/omega/doc/userGuide/TendencyTerms.md b/components/omega/doc/userGuide/TendencyTerms.md index 3db2b4c10098..1259d3387054 100644 --- a/components/omega/doc/userGuide/TendencyTerms.md +++ b/components/omega/doc/userGuide/TendencyTerms.md @@ -20,6 +20,8 @@ tendency terms are currently implemented: | TracerHyperDiffOnCell | biharmonic horizontal mixing of thickness-weighted tracers | SfcStressForcingOnEdge | forcing by surface stress (e.g. wind), defined on edges | BottomDragOnEdge | bottom drag, defined on edges +| SfcThicknessForcingOnCell | surface pseudo-thickness forcing from coupled freshwater and salt fluxes, defined on cells +| SfcTracerForcingOnCell | surface tracer forcing from coupled heat and salt fluxes, defined on cells | SurfaceTracerRestoringOnCell | surface tracer restoring, defined on cells Among the internal data stored by each functor is a `bool` which can enable or @@ -57,6 +59,8 @@ the currently available tendency terms: | | BottomDragTendency:Mode | bottom drag mode; `Implicit` or `Explicit` | | BottomDragTendency:Type | bottom drag type; `Constant` | | BottomDragTendency:BottomDragCoeff | bottom drag coefficient +| SfcThicknessForcingOnCell | SfcThicknessForcingTendencyEnable | enable/disable term +| SfcTracerForcingOnCell | SfcTracerForcingTendencyEnable | enable/disable term | SurfaceTracerRestoringOnCell | SurfaceTracerRestoringEnable | enable/disable term ## Second Order Horizontal Advection Algorithm @@ -142,5 +146,6 @@ Tracer higer order convergence example of a cosine bell advected on a sphere sho ## See Also -Additional information on forcing (currently wind forcing and surface tracer -restoring) is detailed in [](omega-user-forcing). +Additional information on forcing, including surface stress forcing, +surface flux forcing, and surface tracer restoring, is detailed in +[](omega-user-forcing). diff --git a/components/omega/src/ocn/Forcing.cpp b/components/omega/src/ocn/Forcing.cpp index 42643dba5385..2c51af33ae7d 100644 --- a/components/omega/src/ocn/Forcing.cpp +++ b/components/omega/src/ocn/Forcing.cpp @@ -29,7 +29,7 @@ static std::string stripDefault(const std::string &Name) { // mesh/halo. Forcing::Forcing(const std::string &Name, const HorzMesh *Mesh, Halo *MeshHalo) : Name(stripDefault(Name)), SfcStressForcing(stripDefault(Name), Mesh), - Mesh(Mesh), MeshHalo(MeshHalo) {} + TracerForcing(stripDefault(Name), Mesh), Mesh(Mesh), MeshHalo(MeshHalo) {} // Destructor. Unregisters fields from IO streams. Forcing::~Forcing() { unregisterFields(); } @@ -37,10 +37,14 @@ Forcing::~Forcing() { unregisterFields(); } // Register surface stress fields with IO streams for a given mesh. void Forcing::registerFields(const std::string &MeshName) const { SfcStressForcing.registerFields(MeshName); + TracerForcing.registerFields(MeshName); } // Unregister surface stress fields from IO streams. -void Forcing::unregisterFields() const { SfcStressForcing.unregisterFields(); } +void Forcing::unregisterFields() const { + SfcStressForcing.unregisterFields(); + TracerForcing.unregisterFields(); +} // Create and register a non-default forcing instance. Forcing *Forcing::create(const std::string &Name, const HorzMesh *Mesh, @@ -162,6 +166,33 @@ I4 Forcing::exchangeHalo() const { Err += MeshHalo->exchangeFullArrayHalo(SfcStressForcing.MeridStressCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SnowFluxCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.RainFluxCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.EvaporationFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo( + TracerForcing.SeaIceFreshWaterFluxCell, OnCell); + Err += + MeshHalo->exchangeFullArrayHalo(TracerForcing.IceRunoffFluxCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.RiverRunoffFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.LatentHeatFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SensibleHeatFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.LongWaveHeatFluxUpCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo( + TracerForcing.LongWaveHeatFluxDownCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SeaIceHeatFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.ShortWaveHeatFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SeaIceSaltFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SurfInsituTemperature, + OnCell); + return Err; } diff --git a/components/omega/src/ocn/Forcing.h b/components/omega/src/ocn/Forcing.h index 5fdae7e550b8..fda7b91d414e 100644 --- a/components/omega/src/ocn/Forcing.h +++ b/components/omega/src/ocn/Forcing.h @@ -17,6 +17,7 @@ #include "Halo.h" #include "HorzMesh.h" #include "forcingVars/SfcStressForcingVars.h" +#include "forcingVars/TracerForcingVars.h" #include #include @@ -32,6 +33,7 @@ class Forcing { std::string Name; ///< Name identifier for this forcing instance SfcStressForcingVars SfcStressForcing; ///< Surface stress forcing variables + TracerForcingVars TracerForcing; ///< Tracer forcing vars (thickness and T,S) ~Forcing(); diff --git a/components/omega/src/ocn/GlobalConstants.h b/components/omega/src/ocn/GlobalConstants.h index d31bae11f489..0fe2adb1f54e 100644 --- a/components/omega/src/ocn/GlobalConstants.h +++ b/components/omega/src/ocn/GlobalConstants.h @@ -115,11 +115,12 @@ constexpr Real Pa2Db = 1.0e-4; // Pascal to Decibar constexpr Real Cm2M = 1.0e-2; // Centimeters to meters constexpr Real M2Cm = 1.0e2; // Meters to centimeters constexpr Real HFluxFac = - 1.0 / (RhoSw * CpSw); // Heat flux (W/m^2) to temp flux (C*m/s) + 1.0 / (RhoSw * Cp0Sw); // Heat flux (W/m^2) to Conserv Temp flux (C*m/s) constexpr Real FwFluxFac = 1.e-6; // Fw flux (kg/m^2/s) to salt((msu/psu)*m/s) constexpr Real SaltFac = - -OcnRefSal * FwFluxFac; // Fw flux (kg/m^2/s) to salt flux (msu*m/s) -constexpr Real SFluxFac = 1.0; // Salt flux (kg/m^2/s) to salt flux (msu*m/s) + -OcnRefSal * FwFluxFac; // Fw flux (kg/m^2/s) to salt flux (msu*m/s) +constexpr Real SFluxFac = + 1.e3 / RhoSw; // Salt flux (kg/m^2/s) to salinity flux (m*(g/kg)/s) } // namespace OMEGA #endif diff --git a/components/omega/src/ocn/Tendencies.cpp b/components/omega/src/ocn/Tendencies.cpp index d8273a844de0..562badac7437 100644 --- a/components/omega/src/ocn/Tendencies.cpp +++ b/components/omega/src/ocn/Tendencies.cpp @@ -283,6 +283,18 @@ void Tendencies::readConfig(Config *OmegaConfig ///< [in] Omega config } } + Err += TendConfig.get("SfcThicknessForcingTendencyEnable", + this->SfcThicknessForcing.Enabled); + CHECK_ERROR_ABORT( + Err, + "Tendencies: SfcThicknessForcingTendencyEnable not found in TendConfig"); + + Err += TendConfig.get("SfcTracerForcingTendencyEnable", + this->SfcTracerForcing.Enabled); + CHECK_ERROR_ABORT( + Err, + "Tendencies: SfcTracerForcingTendencyEnable not found in TendConfig"); + if (this->TracerDiffusion.Enabled) { Err += TendConfig.get("EddyDiff2", this->TracerDiffusion.EddyDiff2); CHECK_ERROR_ABORT(Err, "Tendencies: EddyDiff2 not found in TendConfig"); @@ -461,6 +473,8 @@ Tendencies::Tendencies(const std::string &Name_, ///< [in] Name for tendencies KEGrad(Mesh, VCoord), SSHGrad(Mesh, VCoord), VelocityDiffusion(Mesh, VCoord), VelocityHyperDiff(Mesh, VCoord), SfcStressForcing(Mesh, VCoord), ExplicitBottomDrag(Mesh, VCoord), + SfcThicknessForcing(Mesh, VCoord), + SfcTracerForcing(Mesh, VCoord, Tracers::IndxTemp, Tracers::IndxSalt), TracerDiffusion(Mesh, VCoord), TracerHyperDiff(Mesh, VCoord), TracerHorzAdv(Mesh, VCoord), SurfaceTracerRestoring(Mesh), CustomThicknessTend(InCustomThicknessTend), @@ -510,6 +524,7 @@ void Tendencies::computePseudoThicknessTendenciesOnly( OMEGA_SCOPE(LocPseudoThicknessTend, PseudoThicknessTend); OMEGA_SCOPE(LocThicknessFluxDiv, PseudoThicknessFluxDiv); + OMEGA_SCOPE(LocSfcThicknessForcing, SfcThicknessForcing); OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); @@ -553,6 +568,32 @@ void Tendencies::computePseudoThicknessTendenciesOnly( VAdv->computePseudoThicknessVAdvTend(PseudoThicknessTend); Pacer::stop("Tend:computePseudoThicknessVAdvTend", 2); + if (LocSfcThicknessForcing.Enabled) { + Pacer::start("Tend:sfcThicknessForcing", 2); + const auto *ForcingState = Forcing::getDefault(); + + const auto &SnowFlux = ForcingState->TracerForcing.SnowFluxCell; + const auto &RainFlux = ForcingState->TracerForcing.RainFluxCell; + const auto &EvaporationFlux = + ForcingState->TracerForcing.EvaporationFluxCell; + const auto &SeaIceFreshWaterFlux = + ForcingState->TracerForcing.SeaIceFreshWaterFluxCell; + const auto &IceRunoffFlux = ForcingState->TracerForcing.IceRunoffFluxCell; + const auto &RiverRunoffFlux = + ForcingState->TracerForcing.RiverRunoffFluxCell; + const auto &SeaIceSaltFlux = + ForcingState->TracerForcing.SeaIceSaltFluxCell; + + parallelFor( + {Mesh->NCellsAll}, KOKKOS_LAMBDA(int ICell) { + LocSfcThicknessForcing(LocPseudoThicknessTend, ICell, SnowFlux, + RainFlux, EvaporationFlux, + SeaIceFreshWaterFlux, IceRunoffFlux, + RiverRunoffFlux, SeaIceSaltFlux); + }); + Pacer::stop("Tend:sfcThicknessForcing", 2); + } + if (CustomThicknessTend) { Pacer::start("Tend:customThicknessTend", 2); CustomThicknessTend(LocPseudoThicknessTend, State, AuxState, @@ -772,6 +813,7 @@ void Tendencies::computeTracerTendenciesOnly( OMEGA_SCOPE(LocTracerDiffusion, TracerDiffusion); OMEGA_SCOPE(LocTracerHyperDiff, TracerHyperDiff); OMEGA_SCOPE(LocSurfaceTracerRestoring, SurfaceTracerRestoring); + OMEGA_SCOPE(LocSfcTracerForcing, SfcTracerForcing); OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); OMEGA_SCOPE(MinLayerEdgeBot, VCoord->MinLayerEdgeBot); @@ -893,6 +935,37 @@ void Tendencies::computeTracerTendenciesOnly( Pacer::stop("Tend:surfaceTracerRestoring", 2); } + // compute tracer forcing tendency + if (LocSfcTracerForcing.Enabled) { + Pacer::start("Tend:sfcTracerForcing", 2); + const auto *ForcingState = Forcing::getDefault(); + const auto &LatentHeatFlux = + ForcingState->TracerForcing.LatentHeatFluxCell; + const auto &SensibleHeatFlux = + ForcingState->TracerForcing.SensibleHeatFluxCell; + const auto &LongWaveHeatFluxUp = + ForcingState->TracerForcing.LongWaveHeatFluxUpCell; + const auto &LongWaveHeatFluxDown = + ForcingState->TracerForcing.LongWaveHeatFluxDownCell; + const auto &SeaIceHeatFlux = + ForcingState->TracerForcing.SeaIceHeatFluxCell; + const auto &ShortWaveHeatFlux = + ForcingState->TracerForcing.ShortWaveHeatFluxCell; + const auto &SnowFlux = ForcingState->TracerForcing.SnowFluxCell; + const auto &IceRunoffFlux = ForcingState->TracerForcing.IceRunoffFluxCell; + const auto &SeaIceSaltFlux = + ForcingState->TracerForcing.SeaIceSaltFluxCell; + + parallelFor( + {Mesh->NCellsAll}, KOKKOS_LAMBDA(int ICell) { + LocSfcTracerForcing( + LocTracerTend, ICell, LatentHeatFlux, SensibleHeatFlux, + LongWaveHeatFluxUp, LongWaveHeatFluxDown, SeaIceHeatFlux, + ShortWaveHeatFlux, SnowFlux, IceRunoffFlux, SeaIceSaltFlux); + }); + Pacer::stop("Tend:sfcTracerForcing", 2); + } + Pacer::stop("Tend:computeTracerTendenciesOnly", 1); } // end tracer tendency compute diff --git a/components/omega/src/ocn/Tendencies.h b/components/omega/src/ocn/Tendencies.h index 877ba22faf2b..c60a2783ddb3 100644 --- a/components/omega/src/ocn/Tendencies.h +++ b/components/omega/src/ocn/Tendencies.h @@ -74,6 +74,8 @@ class Tendencies { VelocityHyperDiffOnEdge VelocityHyperDiff; SfcStressForcingOnEdge SfcStressForcing; BottomDragOnEdge ExplicitBottomDrag; + SfcThicknessForcingOnCell SfcThicknessForcing; + SfcTracerForcingOnCell SfcTracerForcing; TracerHorzAdvOnCell TracerHorzAdv; TracerDiffOnCell TracerDiffusion; TracerHyperDiffOnCell TracerHyperDiff; diff --git a/components/omega/src/ocn/TendencyTerms.cpp b/components/omega/src/ocn/TendencyTerms.cpp index 5b142888142c..f2c6bdca73ad 100644 --- a/components/omega/src/ocn/TendencyTerms.cpp +++ b/components/omega/src/ocn/TendencyTerms.cpp @@ -71,6 +71,17 @@ BottomDragOnEdge::BottomDragOnEdge(const HorzMesh *Mesh, NVertLayers(VCoord->NVertLayers), EdgeMask(VCoord->EdgeMask), MaxLayerEdgeTop(VCoord->MaxLayerEdgeTop) {} +SfcThicknessForcingOnCell::SfcThicknessForcingOnCell(const HorzMesh *Mesh, + const VertCoord *VCoord) + : MinLayerCell(VCoord->MinLayerCell), MaxLayerCell(VCoord->MaxLayerCell) {} + +SfcTracerForcingOnCell::SfcTracerForcingOnCell(const HorzMesh *Mesh, + const VertCoord *VCoord, + I4 TempTracerIndex, + I4 SaltTracerIndex) + : TempIndex(TempTracerIndex), SaltIndex(SaltTracerIndex), + MinLayerCell(VCoord->MinLayerCell), MaxLayerCell(VCoord->MaxLayerCell) {} + TracerHorzAdvOnCell::TracerHorzAdvOnCell(const HorzMesh *Mesh, const VertCoord *VCoord) : HorzontalMesh(Mesh), VerticalCoord(VCoord), diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index 548832b290bf..1745ee713734 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -372,6 +372,92 @@ class BottomDragOnEdge { Array1DI4 MaxLayerEdgeTop; }; +/// Coupled freshwater flux forcing for thickness equation. +class SfcThicknessForcingOnCell { + public: + bool Enabled = false; + + SfcThicknessForcingOnCell(const HorzMesh *Mesh, const VertCoord *VCoord); + + KOKKOS_FUNCTION void operator()(const Array2DReal &Tend, I4 ICell, + const Array1DReal &SnowFlux, + const Array1DReal &RainFlux, + const Array1DReal &EvaporationFlux, + const Array1DReal &SeaIceFreshWaterFlux, + const Array1DReal &IceRunoffFlux, + const Array1DReal &RiverRunoffFlux, + const Array1DReal &SeaIceSaltFlux) const { + + const I4 KTop = MinLayerCell(ICell); + if (KTop > MaxLayerCell(ICell)) { + return; + } + + const Real FreshWaterFlux = SnowFlux(ICell) + RainFlux(ICell) + + EvaporationFlux(ICell) + + SeaIceFreshWaterFlux(ICell) + + IceRunoffFlux(ICell) + RiverRunoffFlux(ICell); + + Tend(ICell, KTop) += (FreshWaterFlux + SeaIceSaltFlux(ICell)) / RhoSw; + } + + private: + Array1DI4 MinLayerCell; + Array1DI4 MaxLayerCell; +}; + +/// Coupled surface flux forcing for active tracers. +class SfcTracerForcingOnCell { + public: + bool Enabled = false; + + SfcTracerForcingOnCell(const HorzMesh *Mesh, const VertCoord *VCoord, + I4 TempTracerIndex, I4 SaltTracerIndex); + + KOKKOS_FUNCTION void operator()(const Array3DReal &Tend, I4 ICell, + const Array1DReal &LatentHeatFlux, + const Array1DReal &SensibleHeatFlux, + const Array1DReal &LongWaveHeatFluxUp, + const Array1DReal &LongWaveHeatFluxDown, + const Array1DReal &SeaIceHeatFlux, + const Array1DReal &ShortWaveHeatFlux, + const Array1DReal &SnowFlux, + const Array1DReal &IceRunoffFlux, + const Array1DReal &SeaIceSaltFlux) const { + + const I4 KTop = MinLayerCell(ICell); + if (KTop > MaxLayerCell(ICell)) { + return; + } + + if (TempIndex >= 0) { + const Real HeatFlux = LatentHeatFlux(ICell) + SensibleHeatFlux(ICell) + + LongWaveHeatFluxUp(ICell) + + LongWaveHeatFluxDown(ICell) + + SeaIceHeatFlux(ICell) + ShortWaveHeatFlux(ICell); + // + + // (RainFlux(ICell) + RiverRunoffFlux(ICell)) * + // Cp0Sw * TracerCell(TempIndex, ICell, KTop) + + // (SnowFlux(ICell) + IceRunoffFlux(ICell)) * + // (Cp0Sw * Eos.Ctfreez - LatIce; + + Tend(TempIndex, ICell, KTop) += HeatFlux * HFluxFactor; + } + + if (SaltIndex >= 0) { + Tend(SaltIndex, ICell, KTop) += SeaIceSaltFlux(ICell) * SFluxFactor; + } + } + + private: + I4 TempIndex; + I4 SaltIndex; + Real HFluxFactor; + Real SFluxFactor; + Array1DI4 MinLayerCell; + Array1DI4 MaxLayerCell; +}; + // Tracer horizontal advection term class TracerHorzAdvOnCell { public: diff --git a/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp b/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp new file mode 100644 index 000000000000..38016216a410 --- /dev/null +++ b/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp @@ -0,0 +1,203 @@ +#include "TracerForcingVars.h" +#include "Eos.h" +#include "Field.h" +#include "Tracers.h" +#include "VertCoord.h" + +#include + +namespace OMEGA { + +TracerForcingVars::TracerForcingVars(const std::string &Suffix, + const HorzMesh *Mesh) + : SnowFluxCell("snowFlux" + Suffix, Mesh->NCellsSize), + RainFluxCell("rainFlux" + Suffix, Mesh->NCellsSize), + EvaporationFluxCell("evaporationFlux" + Suffix, Mesh->NCellsSize), + SeaIceFreshWaterFluxCell("seaIceFreshWaterFlux" + Suffix, + Mesh->NCellsSize), + IceRunoffFluxCell("iceRunoffFlux" + Suffix, Mesh->NCellsSize), + RiverRunoffFluxCell("riverRunoffFlux" + Suffix, Mesh->NCellsSize), + LatentHeatFluxCell("latentHeatFlux" + Suffix, Mesh->NCellsSize), + SensibleHeatFluxCell("sensibleHeatFlux" + Suffix, Mesh->NCellsSize), + LongWaveHeatFluxUpCell("longWaveHeatFluxUp" + Suffix, Mesh->NCellsSize), + LongWaveHeatFluxDownCell("longWaveHeatFluxDown" + Suffix, + Mesh->NCellsSize), + SeaIceHeatFluxCell("seaIceHeatFlux" + Suffix, Mesh->NCellsSize), + ShortWaveHeatFluxCell("shortWaveHeatFlux" + Suffix, Mesh->NCellsSize), + SeaIceSaltFluxCell("seaIceSalinityFlux" + Suffix, Mesh->NCellsSize), + SurfInsituTemperature("surfInsituTemperature" + Suffix, + Mesh->NCellsSize) { + deepCopy(SnowFluxCell, 0.0_Real); + deepCopy(RainFluxCell, 0.0_Real); + deepCopy(EvaporationFluxCell, 0.0_Real); + deepCopy(SeaIceFreshWaterFluxCell, 0.0_Real); + deepCopy(IceRunoffFluxCell, 0.0_Real); + deepCopy(RiverRunoffFluxCell, 0.0_Real); + deepCopy(LatentHeatFluxCell, 0.0_Real); + deepCopy(SensibleHeatFluxCell, 0.0_Real); + deepCopy(LongWaveHeatFluxUpCell, 0.0_Real); + deepCopy(LongWaveHeatFluxDownCell, 0.0_Real); + deepCopy(SeaIceHeatFluxCell, 0.0_Real); + deepCopy(ShortWaveHeatFluxCell, 0.0_Real); + deepCopy(SeaIceSaltFluxCell, 0.0_Real); + deepCopy(SurfInsituTemperature, 0.0_Real); +} + +void TracerForcingVars::registerFields(const std::string &MeshName) const { + const Real FillValue = -9.99e30; + const int NDims = 1; + std::vector DimNames(NDims); + + std::string DimSuffix; + if (MeshName == "Default") { + DimSuffix = ""; + } else { + DimSuffix = MeshName; + } + + DimNames[0] = "NCells" + DimSuffix; + + auto SnowFluxField = Field::create( + SnowFluxCell.label(), "snow freshwater flux", "kg m^-2 s^-1", "", + std::numeric_limits::lowest(), std::numeric_limits::max(), + FillValue, NDims, DimNames); + auto RainFluxField = Field::create( + RainFluxCell.label(), "rain freshwater flux", "kg m^-2 s^-1", "", + std::numeric_limits::lowest(), std::numeric_limits::max(), + FillValue, NDims, DimNames); + auto EvaporationFluxField = Field::create( + EvaporationFluxCell.label(), "evaporation freshwater flux", + "kg m^-2 s^-1", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), FillValue, NDims, DimNames); + auto SeaIceFreshWaterFluxField = Field::create( + SeaIceFreshWaterFluxCell.label(), "sea-ice freshwater flux", + "kg m^-2 s^-1", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), FillValue, NDims, DimNames); + auto IceRunoffFluxField = Field::create( + IceRunoffFluxCell.label(), "ice runoff freshwater flux", "kg m^-2 s^-1", + "", std::numeric_limits::lowest(), + std::numeric_limits::max(), FillValue, NDims, DimNames); + auto RiverRunoffFluxField = Field::create( + RiverRunoffFluxCell.label(), "river runoff freshwater flux", + "kg m^-2 s^-1", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), FillValue, NDims, DimNames); + + auto LatentHeatFluxField = Field::create( + LatentHeatFluxCell.label(), "latent heat flux", "W m^-2", "", + std::numeric_limits::lowest(), std::numeric_limits::max(), + FillValue, NDims, DimNames); + auto SensibleHeatFluxField = Field::create( + SensibleHeatFluxCell.label(), "sensible heat flux", "W m^-2", "", + std::numeric_limits::lowest(), std::numeric_limits::max(), + FillValue, NDims, DimNames); + auto LongWaveHeatFluxUpField = Field::create( + LongWaveHeatFluxUpCell.label(), "upward longwave heat flux", "W m^-2", + "", std::numeric_limits::lowest(), + std::numeric_limits::max(), FillValue, NDims, DimNames); + auto LongWaveHeatFluxDownField = Field::create( + LongWaveHeatFluxDownCell.label(), "downward longwave heat flux", + "W m^-2", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), FillValue, NDims, DimNames); + auto SeaIceHeatFluxField = Field::create( + SeaIceHeatFluxCell.label(), "sea-ice heat flux", "W m^-2", "", + std::numeric_limits::lowest(), std::numeric_limits::max(), + FillValue, NDims, DimNames); + auto ShortWaveHeatFluxField = Field::create( + ShortWaveHeatFluxCell.label(), "shortwave heat flux", "W m^-2", "", + std::numeric_limits::lowest(), std::numeric_limits::max(), + FillValue, NDims, DimNames); + + auto SeaIceSaltFluxField = Field::create( + SeaIceSaltFluxCell.label(), "sea-ice salt flux", "kg m^-2 s^-1", "", + std::numeric_limits::lowest(), std::numeric_limits::max(), + FillValue, NDims, DimNames); + + auto SurfInsituTemperatureField = Field::create( + SurfInsituTemperature.label(), + "insitu (potential) temperature at surface layer", "degrees Celsius", "", + std::numeric_limits::lowest(), std::numeric_limits::max(), + FillValue, NDims, DimNames); + + FieldGroup::addFieldToGroup(SnowFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(RainFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(EvaporationFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(SeaIceFreshWaterFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(IceRunoffFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(RiverRunoffFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(LatentHeatFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(SensibleHeatFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(LongWaveHeatFluxUpCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(LongWaveHeatFluxDownCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(SeaIceHeatFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(ShortWaveHeatFluxCell.label(), "Forcing"); + FieldGroup::addFieldToGroup(SeaIceSaltFluxCell.label(), "Forcing"); + + SnowFluxField->attachData(SnowFluxCell); + RainFluxField->attachData(RainFluxCell); + EvaporationFluxField->attachData(EvaporationFluxCell); + SeaIceFreshWaterFluxField->attachData(SeaIceFreshWaterFluxCell); + IceRunoffFluxField->attachData(IceRunoffFluxCell); + RiverRunoffFluxField->attachData(RiverRunoffFluxCell); + LatentHeatFluxField->attachData(LatentHeatFluxCell); + SensibleHeatFluxField->attachData(SensibleHeatFluxCell); + LongWaveHeatFluxUpField->attachData(LongWaveHeatFluxUpCell); + LongWaveHeatFluxDownField->attachData(LongWaveHeatFluxDownCell); + SeaIceHeatFluxField->attachData(SeaIceHeatFluxCell); + ShortWaveHeatFluxField->attachData(ShortWaveHeatFluxCell); + SurfInsituTemperatureField->attachData(SurfInsituTemperature); + SeaIceSaltFluxField->attachData(SeaIceSaltFluxCell); +} + +void TracerForcingVars::unregisterFields() const { + Field::destroy(SnowFluxCell.label()); + Field::destroy(RainFluxCell.label()); + Field::destroy(EvaporationFluxCell.label()); + Field::destroy(SeaIceFreshWaterFluxCell.label()); + Field::destroy(IceRunoffFluxCell.label()); + Field::destroy(RiverRunoffFluxCell.label()); + Field::destroy(LatentHeatFluxCell.label()); + Field::destroy(SensibleHeatFluxCell.label()); + Field::destroy(LongWaveHeatFluxUpCell.label()); + Field::destroy(LongWaveHeatFluxDownCell.label()); + Field::destroy(SeaIceHeatFluxCell.label()); + Field::destroy(ShortWaveHeatFluxCell.label()); + Field::destroy(SeaIceSaltFluxCell.label()); + Field::destroy(SurfInsituTemperature.label()); +} + +void TracerForcingVars::computeSurfInsituTemp(const Array3DReal &TracerArray, + const VertCoord *VCoord, + const Eos *EosInst) const { + const int IndxTemp = Tracers::IndxTemp; + const int IndxSalt = Tracers::IndxSalt; + + // Skip computation if temperature or salinity tracers are not defined + if (IndxTemp < 0 || IndxSalt < 0) { + return; + } + + OMEGA_SCOPE(LocMinLayerCell, VCoord->MinLayerCell); + OMEGA_SCOPE(LocMaxLayerCell, VCoord->MaxLayerCell); + OMEGA_SCOPE(LocSurfInsituTemp, SurfInsituTemperature); + + int NCellsOwned = SurfInsituTemperature.extent_int(0); + + parallelFor( + "TracerForcing:computeSurfInsituTemp", {NCellsOwned}, + KOKKOS_LAMBDA(int ICell) { + const int KMin = LocMinLayerCell(ICell); + const int KMax = LocMaxLayerCell(ICell); + + // Only compute for valid ocean cells + if (KMin <= KMax) { + const Real ConservTemp = TracerArray(IndxTemp, ICell, KMin); + const Real AbsSalinity = TracerArray(IndxSalt, ICell, KMin); + + // Call EOS function to compute potential temperature from + // conservative temperature at surface (reference pressure = 0) + LocSurfInsituTemp(ICell) = + EosInst->calcPtFromCt(AbsSalinity, ConservTemp); + } + }); +} +} // namespace OMEGA diff --git a/components/omega/src/ocn/forcingVars/TracerForcingVars.h b/components/omega/src/ocn/forcingVars/TracerForcingVars.h new file mode 100644 index 000000000000..1a0747121ea2 --- /dev/null +++ b/components/omega/src/ocn/forcingVars/TracerForcingVars.h @@ -0,0 +1,49 @@ +#ifndef OMEGA_TRACER_FORCING_H +#define OMEGA_TRACER_FORCING_H + +#include "DataTypes.h" +#include "HorzMesh.h" + +#include + +namespace OMEGA { + +// Forward declarations. Full definitions not needed in this header since only +// pointers are used. +class VertCoord; +class Eos; + +class TracerForcingVars { + public: + Array1DReal SnowFluxCell; + Array1DReal RainFluxCell; + Array1DReal EvaporationFluxCell; + Array1DReal SeaIceFreshWaterFluxCell; + Array1DReal IceRunoffFluxCell; + Array1DReal RiverRunoffFluxCell; + + Array1DReal LatentHeatFluxCell; + Array1DReal SensibleHeatFluxCell; + Array1DReal LongWaveHeatFluxUpCell; + Array1DReal LongWaveHeatFluxDownCell; + Array1DReal SeaIceHeatFluxCell; + Array1DReal ShortWaveHeatFluxCell; + + Array1DReal SeaIceSaltFluxCell; + + Array1DReal SurfInsituTemperature; + + TracerForcingVars(const std::string &Suffix, const HorzMesh *Mesh); + + void registerFields(const std::string &MeshName) const; + void unregisterFields() const; + + /// Compute surface insitu temperature from conservative temperature + void computeSurfInsituTemp(const Array3DReal &TracerArray, + const VertCoord *VCoord, + const Eos *EosInst) const; +}; + +} // namespace OMEGA + +#endif diff --git a/components/omega/test/timeStepping/TimeStepperTest.cpp b/components/omega/test/timeStepping/TimeStepperTest.cpp index af84424217ff..d64b461896a5 100644 --- a/components/omega/test/timeStepping/TimeStepperTest.cpp +++ b/components/omega/test/timeStepping/TimeStepperTest.cpp @@ -255,6 +255,8 @@ int initTimeStepperTest(const std::string &mesh) { TestTendencies->TracerDiffusion.Enabled = false; TestTendencies->TracerHyperDiff.Enabled = false; TestTendencies->SfcStressForcing.Enabled = false; + TestTendencies->SfcTracerForcing.Enabled = false; + TestTendencies->SfcThicknessForcing.Enabled = false; TestTendencies->SurfaceTracerRestoring.Enabled = false; TestTendencies->ExplicitBottomDrag.Enabled = false; DefVAdv->ThickVertAdvEnabled = false; From 363a505d0744d2b04a77c062cc338b5ceffc984f Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Fri, 26 Jun 2026 09:18:03 -0700 Subject: [PATCH 02/36] added the enthalpy of mass fluxes; CtFrz has public interface --- components/omega/src/ocn/Eos.cpp | 12 ++++ components/omega/src/ocn/Eos.h | 6 ++ components/omega/src/ocn/Tendencies.cpp | 14 +++-- components/omega/src/ocn/TendencyTerms.cpp | 7 ++- components/omega/src/ocn/TendencyTerms.h | 66 +++++++++++++--------- 5 files changed, 73 insertions(+), 32 deletions(-) diff --git a/components/omega/src/ocn/Eos.cpp b/components/omega/src/ocn/Eos.cpp index 16ea4da9f5a2..516cb629e154 100644 --- a/components/omega/src/ocn/Eos.cpp +++ b/components/omega/src/ocn/Eos.cpp @@ -343,6 +343,18 @@ Real Eos::calcCtFromPt(const Real &Sa, const Real &Pt) const { return Pt; } +Real Eos::calcCtFreezing(const Real Sa, const Real P, + const Real SaturationFract) const { + if (EosChoice == EosType::Teos10Eos) { + return ComputeSpecVolTeos10.calcCtFreezing(Sa, P, SaturationFract); + } + + ABORT_ERROR("Eos::calcCtFreezing: CT freezing temperature is only " + "implemented for TEOS-10. Support for the current EOS " + "choice has not yet been developed."); + return 0; +} + /// Define IO fields and metadata for output void Eos::defineFields() { diff --git a/components/omega/src/ocn/Eos.h b/components/omega/src/ocn/Eos.h index 5e4fd89cae21..2b3d6d78f462 100644 --- a/components/omega/src/ocn/Eos.h +++ b/components/omega/src/ocn/Eos.h @@ -761,6 +761,12 @@ class Eos { /// Convert potential temperature to Conservative Temperature Real calcCtFromPt(const Real &Sa, const Real &Pt) const; + /// Calculate freezing Conservative Temperature for TEOS-10. + /// Aborts if EOS is not TEOS-10: CT freezing is not yet implemented + /// for other equation-of-state choices. + Real calcCtFreezing(const Real Sa, const Real P, + const Real SaturationFract) const; + /// Initialize EOS from config and mesh static void init(); diff --git a/components/omega/src/ocn/Tendencies.cpp b/components/omega/src/ocn/Tendencies.cpp index 562badac7437..419c105292a6 100644 --- a/components/omega/src/ocn/Tendencies.cpp +++ b/components/omega/src/ocn/Tendencies.cpp @@ -474,7 +474,8 @@ Tendencies::Tendencies(const std::string &Name_, ///< [in] Name for tendencies VelocityDiffusion(Mesh, VCoord), VelocityHyperDiff(Mesh, VCoord), SfcStressForcing(Mesh, VCoord), ExplicitBottomDrag(Mesh, VCoord), SfcThicknessForcing(Mesh, VCoord), - SfcTracerForcing(Mesh, VCoord, Tracers::IndxTemp, Tracers::IndxSalt), + SfcTracerForcing(Mesh, VCoord, Tracers::IndxTemp, Tracers::IndxSalt, + EqState), TracerDiffusion(Mesh, VCoord), TracerHyperDiff(Mesh, VCoord), TracerHorzAdv(Mesh, VCoord), SurfaceTracerRestoring(Mesh), CustomThicknessTend(InCustomThicknessTend), @@ -952,16 +953,21 @@ void Tendencies::computeTracerTendenciesOnly( const auto &ShortWaveHeatFlux = ForcingState->TracerForcing.ShortWaveHeatFluxCell; const auto &SnowFlux = ForcingState->TracerForcing.SnowFluxCell; + const auto &RainFlux = ForcingState->TracerForcing.RainFluxCell; const auto &IceRunoffFlux = ForcingState->TracerForcing.IceRunoffFluxCell; + const auto &RiverRunoffFlux = + ForcingState->TracerForcing.RiverRunoffFluxCell; const auto &SeaIceSaltFlux = ForcingState->TracerForcing.SeaIceSaltFluxCell; + const auto &PressureMid = VCoord->PressureMid; parallelFor( {Mesh->NCellsAll}, KOKKOS_LAMBDA(int ICell) { LocSfcTracerForcing( - LocTracerTend, ICell, LatentHeatFlux, SensibleHeatFlux, - LongWaveHeatFluxUp, LongWaveHeatFluxDown, SeaIceHeatFlux, - ShortWaveHeatFlux, SnowFlux, IceRunoffFlux, SeaIceSaltFlux); + LocTracerTend, ICell, TracerArray, PressureMid, LatentHeatFlux, + SensibleHeatFlux, LongWaveHeatFluxUp, LongWaveHeatFluxDown, + SeaIceHeatFlux, ShortWaveHeatFlux, SnowFlux, RainFlux, + IceRunoffFlux, RiverRunoffFlux, SeaIceSaltFlux); }); Pacer::stop("Tend:sfcTracerForcing", 2); } diff --git a/components/omega/src/ocn/TendencyTerms.cpp b/components/omega/src/ocn/TendencyTerms.cpp index f2c6bdca73ad..37bfe6ee0500 100644 --- a/components/omega/src/ocn/TendencyTerms.cpp +++ b/components/omega/src/ocn/TendencyTerms.cpp @@ -11,6 +11,7 @@ #include "TendencyTerms.h" #include "AuxiliaryState.h" #include "DataTypes.h" +#include "Eos.h" #include "HorzMesh.h" #include "HorzOperators.h" #include "OceanState.h" @@ -78,9 +79,11 @@ SfcThicknessForcingOnCell::SfcThicknessForcingOnCell(const HorzMesh *Mesh, SfcTracerForcingOnCell::SfcTracerForcingOnCell(const HorzMesh *Mesh, const VertCoord *VCoord, I4 TempTracerIndex, - I4 SaltTracerIndex) + I4 SaltTracerIndex, + const Eos *EosInst) : TempIndex(TempTracerIndex), SaltIndex(SaltTracerIndex), - MinLayerCell(VCoord->MinLayerCell), MaxLayerCell(VCoord->MaxLayerCell) {} + MinLayerCell(VCoord->MinLayerCell), MaxLayerCell(VCoord->MaxLayerCell), + EosImpl(VCoord) {} TracerHorzAdvOnCell::TracerHorzAdvOnCell(const HorzMesh *Mesh, const VertCoord *VCoord) diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index 1745ee713734..77b8ce93df04 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -11,6 +11,7 @@ //===----------------------------------------------------------------------===// #include "AuxiliaryState.h" +#include "Eos.h" #include "GlobalConstants.h" #include "HorzMesh.h" #include "MachEnv.h" @@ -412,18 +413,20 @@ class SfcTracerForcingOnCell { bool Enabled = false; SfcTracerForcingOnCell(const HorzMesh *Mesh, const VertCoord *VCoord, - I4 TempTracerIndex, I4 SaltTracerIndex); - - KOKKOS_FUNCTION void operator()(const Array3DReal &Tend, I4 ICell, - const Array1DReal &LatentHeatFlux, - const Array1DReal &SensibleHeatFlux, - const Array1DReal &LongWaveHeatFluxUp, - const Array1DReal &LongWaveHeatFluxDown, - const Array1DReal &SeaIceHeatFlux, - const Array1DReal &ShortWaveHeatFlux, - const Array1DReal &SnowFlux, - const Array1DReal &IceRunoffFlux, - const Array1DReal &SeaIceSaltFlux) const { + I4 TempTracerIndex, I4 SaltTracerIndex, + const Eos *EosInst); + + KOKKOS_FUNCTION void + operator()(const Array3DReal &Tend, I4 ICell, const Array3DReal &TracerCell, + const Array2DReal &PressureMid, const Array1DReal &LatentHeatFlux, + const Array1DReal &SensibleHeatFlux, + const Array1DReal &LongWaveHeatFluxUp, + const Array1DReal &LongWaveHeatFluxDown, + const Array1DReal &SeaIceHeatFlux, + const Array1DReal &ShortWaveHeatFlux, const Array1DReal &SnowFlux, + const Array1DReal &RainFlux, const Array1DReal &IceRunoffFlux, + const Array1DReal &RiverRunoffFlux, + const Array1DReal &SeaIceSaltFlux) const { const I4 KTop = MinLayerCell(ICell); if (KTop > MaxLayerCell(ICell)) { @@ -431,31 +434,42 @@ class SfcTracerForcingOnCell { } if (TempIndex >= 0) { - const Real HeatFlux = LatentHeatFlux(ICell) + SensibleHeatFlux(ICell) + - LongWaveHeatFluxUp(ICell) + - LongWaveHeatFluxDown(ICell) + - SeaIceHeatFlux(ICell) + ShortWaveHeatFlux(ICell); - // + - // (RainFlux(ICell) + RiverRunoffFlux(ICell)) * - // Cp0Sw * TracerCell(TempIndex, ICell, KTop) + - // (SnowFlux(ICell) + IceRunoffFlux(ICell)) * - // (Cp0Sw * Eos.Ctfreez - LatIce; - - Tend(TempIndex, ICell, KTop) += HeatFlux * HFluxFactor; + const Real PTop = PressureMid(ICell, KTop); + const Real SaTop = SaltIndex >= 0 + ? TracerCell(SaltIndex, ICell, KTop) + : 0.0_Real; // not sure we want zero here? + const Real CtFrz = EosImpl.calcCtFreezing(SaTop, PTop, 0.0_Real); + const Real CtTop = TracerCell(TempIndex, ICell, KTop); + + // Heat tendencies are due to direct heat fluxes + enthalpy fluxes + // The enthalpy of liquid water is assumed to be: + // - local SST for liquid mass fluxes (rain, rivers) + // - local freezing point for solid --> liq mass fluxes (snow, frozen + // runoff) + // - solid mass fluxes are locally melted by the ocean (constant Lat + // heat of fusion) + const Real HeatFlux = + LatentHeatFlux(ICell) + SensibleHeatFlux(ICell) + + LongWaveHeatFluxUp(ICell) + LongWaveHeatFluxDown(ICell) + + SeaIceHeatFlux(ICell) + ShortWaveHeatFlux(ICell) + + (RainFlux(ICell) + RiverRunoffFlux(ICell)) * Cp0Sw * CtTop + + (SnowFlux(ICell) + IceRunoffFlux(ICell)) * + (Cp0Sw * CtFrz - LatIce); + + Tend(TempIndex, ICell, KTop) += HeatFlux * HFluxFac; } if (SaltIndex >= 0) { - Tend(SaltIndex, ICell, KTop) += SeaIceSaltFlux(ICell) * SFluxFactor; + Tend(SaltIndex, ICell, KTop) += SeaIceSaltFlux(ICell) * SFluxFac; } } private: I4 TempIndex; I4 SaltIndex; - Real HFluxFactor; - Real SFluxFactor; Array1DI4 MinLayerCell; Array1DI4 MaxLayerCell; + Teos10Eos EosImpl; }; // Tracer horizontal advection term From 3ecac9ad9ea4b1f23f01c6aac7e41da9f2b6aed7 Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Fri, 26 Jun 2026 13:19:13 -0700 Subject: [PATCH 03/36] added a test for thermo forcing tendencies --- components/omega/test/ocn/TendenciesTest.cpp | 405 +++++++++++++++++++ 1 file changed, 405 insertions(+) diff --git a/components/omega/test/ocn/TendenciesTest.cpp b/components/omega/test/ocn/TendenciesTest.cpp index 5268d0a436ea..1045701339dd 100644 --- a/components/omega/test/ocn/TendenciesTest.cpp +++ b/components/omega/test/ocn/TendenciesTest.cpp @@ -54,6 +54,9 @@ struct TestSetup { constexpr Geometry Geom = Geometry::Spherical; constexpr int NVertLayers = 60; +int testSfcTracerForcing(); +int testSfcThicknessForcing(); + int initState() { int Err = 0; @@ -305,6 +308,12 @@ int testTendencies() { DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; + // Test surface tracer forcing with enthalpy terms + Err += testSfcTracerForcing(); + + // Test surface thickness forcing with freshwater terms + Err += testSfcThicknessForcing(); + // check that everything got computed correctly int NCellsOwned = Mesh->NCellsOwned; int NEdgesOwned = Mesh->NEdgesOwned; @@ -339,6 +348,402 @@ int testTendencies() { return Err; } +int testSfcTracerForcing() { + int Err = 0; + + auto *VCoord = VertCoord::getDefault(); + auto *DefTendencies = Tendencies::getDefault(); + auto *State = OceanState::getDefault(); + auto *AuxState = AuxiliaryState::getDefault(); + auto *DefForcing = Forcing::getDefault(); + auto *EosInst = Eos::getInstance(); + + Array3DReal TracerArray = Tracers::getAll(0); + + const I4 TempIndex = Tracers::IndxTemp; + const I4 SaltIndex = Tracers::IndxSalt; + + if (TempIndex < 0 || SaltIndex < 0) { + LOG_ERROR("TendenciesTest: Invalid tracer indices for SfcTracerForcing"); + return -1; + } + + deepCopy(DefTendencies->TracerTend, 0._Real); + + // Set up single test cell at top layer + const I4 ICellTest = 0; + const I4 KTop = VCoord->MinLayerCell(ICellTest); + + if (KTop > VCoord->MaxLayerCell(ICellTest)) { + LOG_ERROR("TendenciesTest: Test cell has no layers"); + return -1; + } + + // Known tracer values for testing + const Real CtTopValue = 15.0_Real; // °C (conservative temperature) + const Real SaTopValue = 35.0_Real; // g/kg (salinity) + + // Set tracer values at test cell + OMEGA_SCOPE(LocTracerArray, TracerArray); + Kokkos::parallel_for( + "SetTestTracersForcing", 1, KOKKOS_LAMBDA(int i) { + LocTracerArray(TempIndex, ICellTest, KTop) = CtTopValue; + LocTracerArray(SaltIndex, ICellTest, KTop) = SaTopValue; + }); + + // Retrieve forcing field views + auto &SensibleHeatFlux = DefForcing->TracerForcing.SensibleHeatFluxCell; + auto &LatentHeatFlux = DefForcing->TracerForcing.LatentHeatFluxCell; + auto &LongWaveHeatFluxUp = DefForcing->TracerForcing.LongWaveHeatFluxUpCell; + auto &LongWaveHeatFluxDown = + DefForcing->TracerForcing.LongWaveHeatFluxDownCell; + auto &SeaIceHeatFlux = DefForcing->TracerForcing.SeaIceHeatFluxCell; + auto &ShortWaveHeatFlux = DefForcing->TracerForcing.ShortWaveHeatFluxCell; + auto &RainFlux = DefForcing->TracerForcing.RainFluxCell; + auto &RiverRunoffFlux = DefForcing->TracerForcing.RiverRunoffFluxCell; + auto &SnowFlux = DefForcing->TracerForcing.SnowFluxCell; + auto &IceRunoffFlux = DefForcing->TracerForcing.IceRunoffFluxCell; + auto &SeaIceSaltFlux = DefForcing->TracerForcing.SeaIceSaltFluxCell; + + // Initialize all fluxes to zero + deepCopy(SensibleHeatFlux, 0._Real); + deepCopy(LatentHeatFlux, 0._Real); + deepCopy(LongWaveHeatFluxUp, 0._Real); + deepCopy(LongWaveHeatFluxDown, 0._Real); + deepCopy(SeaIceHeatFlux, 0._Real); + deepCopy(ShortWaveHeatFlux, 0._Real); + deepCopy(RainFlux, 0._Real); + deepCopy(RiverRunoffFlux, 0._Real); + deepCopy(SnowFlux, 0._Real); + deepCopy(IceRunoffFlux, 0._Real); + deepCopy(SeaIceSaltFlux, 0._Real); + + // Set test forcing values + // Non-zero sensible heat: 100 W/m² + const Real TestSensibleHeat = 100.0_Real; + // Non-zero rain: 1e-8 kg/m²/s + const Real TestRain = 1.0e-8_Real; + // Non-zero snow: 5e-9 kg/m²/s + const Real TestSnow = 5.0e-9_Real; + // Sea ice salt flux: 1e-4 kg/m²/s + const Real TestSeaIceSaltFlux = 1.0e-4_Real; + + OMEGA_SCOPE(LocSensibleHeatFlux, SensibleHeatFlux); + OMEGA_SCOPE(LocRainFlux, RainFlux); + OMEGA_SCOPE(LocSnowFlux, SnowFlux); + OMEGA_SCOPE(LocSeaIceSaltFlux, SeaIceSaltFlux); + Kokkos::parallel_for( + "SetTestForcingTracer", 1, KOKKOS_LAMBDA(int i) { + LocSensibleHeatFlux(ICellTest) = TestSensibleHeat; + LocRainFlux(ICellTest) = TestRain; + LocSnowFlux(ICellTest) = TestSnow; + LocSeaIceSaltFlux(ICellTest) = TestSeaIceSaltFlux; + }); + + DefForcing->computeAll(); + + // Disable all tendencies except SfcTracerForcing + const bool OrigSfcStressEnabled = DefTendencies->SfcStressForcing.Enabled; + const bool OrigSfcThicknessEnabled = + DefTendencies->SfcThicknessForcing.Enabled; + const bool OrigSfcTracerEnabled = DefTendencies->SfcTracerForcing.Enabled; + const bool OrigPseudoThicknessDiv = + DefTendencies->PseudoThicknessFluxDiv.Enabled; + const bool OrigPotentialVortHAdv = DefTendencies->PotentialVortHAdv.Enabled; + const bool OrigKEGrad = DefTendencies->KEGrad.Enabled; + const bool OrigVelocityDiffusion = DefTendencies->VelocityDiffusion.Enabled; + const bool OrigVelocityHyperDiff = DefTendencies->VelocityHyperDiff.Enabled; + const bool OrigTracerHorzAdv = DefTendencies->TracerHorzAdv.Enabled; + const bool OrigTracerDiffusion = DefTendencies->TracerDiffusion.Enabled; + const bool OrigTracerHyperDiff = DefTendencies->TracerHyperDiff.Enabled; + const bool OrigSurfaceTracerRestoring = + DefTendencies->SurfaceTracerRestoring.Enabled; + + DefTendencies->SfcStressForcing.Enabled = false; + DefTendencies->SfcThicknessForcing.Enabled = false; + DefTendencies->SfcTracerForcing.Enabled = false; + DefTendencies->PseudoThicknessFluxDiv.Enabled = false; + DefTendencies->PotentialVortHAdv.Enabled = false; + DefTendencies->KEGrad.Enabled = false; + DefTendencies->VelocityDiffusion.Enabled = false; + DefTendencies->VelocityHyperDiff.Enabled = false; + DefTendencies->TracerHorzAdv.Enabled = false; + DefTendencies->TracerDiffusion.Enabled = false; + DefTendencies->TracerHyperDiff.Enabled = false; + DefTendencies->SurfaceTracerRestoring.Enabled = false; + + // Compute tendencies + int ThickTimeLevel = 0; + int VelTimeLevel = 0; + int TracerTimeLevel = 0; + TimeInstant Time; + TimeInterval Interval(1., TimeUnits::Seconds); + + // because vertical advection tendencies are always on, we need to compute a + // baseline first. the actual test is whether the total tendencies change + // with the flag toggling. + DefTendencies->computeAllTendencies(State, AuxState, TracerArray, + ThickTimeLevel, VelTimeLevel, + TracerTimeLevel, Time, Interval); + + HostArray3DReal TracerTendBaseH = + createHostMirrorCopy(DefTendencies->TracerTend); + deepCopy(TracerTendBaseH, DefTendencies->TracerTend); + const Real BaselineTempTend = TracerTendBaseH(TempIndex, ICellTest, KTop); + const Real BaselineSaltTend = TracerTendBaseH(SaltIndex, ICellTest, KTop); + // Now enable SfcTracerForcing and compute again + DefTendencies->SfcTracerForcing.Enabled = true; + + DefTendencies->computeAllTendencies(State, AuxState, TracerArray, + ThickTimeLevel, VelTimeLevel, + TracerTimeLevel, Time, Interval); + + // Build two reference expectations for temperature tendency: + // 1) fixed estimate (expected to fail under strict tolerance), + // 2) TEOS-10 freezing CT (expected to pass under strict tolerance). + const Real CtFrzEstimate = -2.0_Real; + const Real ExpectedTempTendEstimate = + (TestSensibleHeat + TestRain * Cp0Sw * CtTopValue + + TestSnow * (Cp0Sw * CtFrzEstimate - LatIce)) * + HFluxFac; + + HostArray2DReal PressureMidH = createHostMirrorCopy(VCoord->PressureMid); + deepCopy(PressureMidH, VCoord->PressureMid); + const Real PTop = PressureMidH(ICellTest, KTop); + const Real CtFrzTeos = EosInst->calcCtFreezing(SaTopValue, PTop, 0.0_Real); + const Real ExpectedTempTendTeos = + (TestSensibleHeat + TestRain * Cp0Sw * CtTopValue + + TestSnow * (Cp0Sw * CtFrzTeos - LatIce)) * + HFluxFac; + + // SaltTend = SeaIceSaltFlux * SFluxFac + const Real ExpectedSaltTend = TestSeaIceSaltFlux * SFluxFac; + + HostArray3DReal TracerTendH = + createHostMirrorCopy(DefTendencies->TracerTend); + deepCopy(TracerTendH, DefTendencies->TracerTend); + const Real ComputedTempTend = + TracerTendH(TempIndex, ICellTest, KTop) - BaselineTempTend; + const Real ComputedSaltTend = + TracerTendH(SaltIndex, ICellTest, KTop) - BaselineSaltTend; + + constexpr Real RelTol = 1.0e-10_Real; + constexpr Real AbsTol = 1.0e-12_Real; // flux precision is ~e-15 + + // Expected-fail check with fixed CtFrz estimate. + if (!isApprox(ComputedTempTend, ExpectedTempTendEstimate, RelTol, AbsTol)) { + LOG_INFO( + "TendenciesTest: expected tempTend fail because CtFrzEstimate != EOS " + "CtFrz - PASS"); + LOG_INFO("tempTend Expected: {}, Computed: {}, Diff: {}", + ExpectedTempTendEstimate, ComputedTempTend, + Kokkos::abs(ComputedTempTend - ExpectedTempTendEstimate)); + } else { + Err++; + LOG_ERROR("TendenciesTest: CtFrz estimate unexpectedly matched strict " + "reference - FAIL"); + } + + // Expected-pass check with TEOS freezing CT reference. + if (!isApprox(ComputedTempTend, ExpectedTempTendTeos, RelTol, AbsTol)) { + Err++; + LOG_ERROR("TendenciesTest: SfcTracerForcing temp tendency FAIL"); + LOG_ERROR(" with TEOS-CtFrz Expected: {}, Computed: {}, Diff: {}", + ExpectedTempTendTeos, ComputedTempTend, + Kokkos::abs(ComputedTempTend - ExpectedTempTendTeos)); + } else { + LOG_INFO("TendenciesTest: SfcTracerForcing temp tendency PASS"); + } + + // Check salinity tendency + if (!isApprox(ComputedSaltTend, ExpectedSaltTend, RelTol, AbsTol)) { + Err++; + LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL"); + LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedSaltTend, + ComputedSaltTend, + Kokkos::abs(ComputedSaltTend - ExpectedSaltTend)); + } else { + LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS"); + } + + DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; + DefTendencies->SfcThicknessForcing.Enabled = OrigSfcThicknessEnabled; + DefTendencies->SfcTracerForcing.Enabled = OrigSfcTracerEnabled; + DefTendencies->PseudoThicknessFluxDiv.Enabled = OrigPseudoThicknessDiv; + DefTendencies->PotentialVortHAdv.Enabled = OrigPotentialVortHAdv; + DefTendencies->KEGrad.Enabled = OrigKEGrad; + DefTendencies->VelocityDiffusion.Enabled = OrigVelocityDiffusion; + DefTendencies->VelocityHyperDiff.Enabled = OrigVelocityHyperDiff; + DefTendencies->TracerHorzAdv.Enabled = OrigTracerHorzAdv; + DefTendencies->TracerDiffusion.Enabled = OrigTracerDiffusion; + DefTendencies->TracerHyperDiff.Enabled = OrigTracerHyperDiff; + DefTendencies->SurfaceTracerRestoring.Enabled = OrigSurfaceTracerRestoring; + + return Err; +} + +int testSfcThicknessForcing() { + int Err = 0; + + auto *VCoord = VertCoord::getDefault(); + auto *DefTendencies = Tendencies::getDefault(); + auto *State = OceanState::getDefault(); + auto *AuxState = AuxiliaryState::getDefault(); + auto *DefForcing = Forcing::getDefault(); + + Array3DReal TracerArray = Tracers::getAll(0); + + deepCopy(DefTendencies->PseudoThicknessTend, 0._Real); + + // Set up single test cell at top layer + const I4 ICellTest = 0; + const I4 KTop = VCoord->MinLayerCell(ICellTest); + + if (KTop > VCoord->MaxLayerCell(ICellTest)) { + LOG_ERROR("TendenciesTest: Test cell has no layers for thickness test"); + return -1; + } + + // Retrieve forcing field views for thickness + auto &SnowFlux = DefForcing->TracerForcing.SnowFluxCell; + auto &RainFlux = DefForcing->TracerForcing.RainFluxCell; + auto &EvaporationFlux = DefForcing->TracerForcing.EvaporationFluxCell; + auto &SeaIceFreshWater = DefForcing->TracerForcing.SeaIceFreshWaterFluxCell; + auto &IceRunoffFlux = DefForcing->TracerForcing.IceRunoffFluxCell; + auto &RiverRunoffFlux = DefForcing->TracerForcing.RiverRunoffFluxCell; + auto &SeaIceSaltFlux = DefForcing->TracerForcing.SeaIceSaltFluxCell; + + // Initialize all fluxes to zero + deepCopy(SnowFlux, 0._Real); + deepCopy(RainFlux, 0._Real); + deepCopy(EvaporationFlux, 0._Real); + deepCopy(SeaIceFreshWater, 0._Real); + deepCopy(IceRunoffFlux, 0._Real); + deepCopy(RiverRunoffFlux, 0._Real); + deepCopy(SeaIceSaltFlux, 0._Real); + + // Set test freshwater flux values + // Rain: 1e-8 kg/m²/s + const Real TestRain = 1.0e-8_Real; + // Snow: 5e-9 kg/m²/s + const Real TestSnow = 5.0e-9_Real; + // Ice runoff: 2e-9 kg/m²/s + const Real TestIceRunoff = 2.0e-9_Real; + // River runoff: 3e-9 kg/m²/s + const Real TestRiverRunoff = 3.0e-9_Real; + // Sea ice freshwater: 1e-9 kg/m²/s + const Real TestSeaIceFreshWater = 1.0e-9_Real; + // Sea ice salt flux: 1e-4 kg/m²/s (affects thickness via salt) + const Real TestSeaIceSaltFlux = 1.0e-4_Real; + + OMEGA_SCOPE(LocSnowFlux, SnowFlux); + OMEGA_SCOPE(LocRainFlux, RainFlux); + OMEGA_SCOPE(LocIceRunoffFlux, IceRunoffFlux); + OMEGA_SCOPE(LocRiverRunoffFlux, RiverRunoffFlux); + OMEGA_SCOPE(LocSeaIceFreshWater, SeaIceFreshWater); + OMEGA_SCOPE(LocSeaIceSaltFlux, SeaIceSaltFlux); + Kokkos::parallel_for( + "SetTestForcingThickness", 1, KOKKOS_LAMBDA(int i) { + LocRainFlux(ICellTest) = TestRain; + LocSnowFlux(ICellTest) = TestSnow; + LocIceRunoffFlux(ICellTest) = TestIceRunoff; + LocRiverRunoffFlux(ICellTest) = TestRiverRunoff; + LocSeaIceFreshWater(ICellTest) = TestSeaIceFreshWater; + LocSeaIceSaltFlux(ICellTest) = TestSeaIceSaltFlux; + }); + + DefForcing->computeAll(); + + const bool OrigSfcStressEnabled = DefTendencies->SfcStressForcing.Enabled; + const bool OrigSfcThicknessEnabled = + DefTendencies->SfcThicknessForcing.Enabled; + const bool OrigSfcTracerEnabled = DefTendencies->SfcTracerForcing.Enabled; + const bool OrigPseudoThicknessDiv = + DefTendencies->PseudoThicknessFluxDiv.Enabled; + const bool OrigPotentialVortHAdv = DefTendencies->PotentialVortHAdv.Enabled; + const bool OrigKEGrad = DefTendencies->KEGrad.Enabled; + const bool OrigVelocityDiffusion = DefTendencies->VelocityDiffusion.Enabled; + const bool OrigVelocityHyperDiff = DefTendencies->VelocityHyperDiff.Enabled; + const bool OrigTracerHorzAdv = DefTendencies->TracerHorzAdv.Enabled; + const bool OrigTracerDiffusion = DefTendencies->TracerDiffusion.Enabled; + const bool OrigTracerHyperDiff = DefTendencies->TracerHyperDiff.Enabled; + const bool OrigSurfaceTracerRestoring = + DefTendencies->SurfaceTracerRestoring.Enabled; + + DefTendencies->SfcStressForcing.Enabled = false; + DefTendencies->SfcThicknessForcing.Enabled = false; + DefTendencies->SfcTracerForcing.Enabled = false; + DefTendencies->PseudoThicknessFluxDiv.Enabled = false; + DefTendencies->PotentialVortHAdv.Enabled = false; + DefTendencies->KEGrad.Enabled = false; + DefTendencies->VelocityDiffusion.Enabled = false; + DefTendencies->VelocityHyperDiff.Enabled = false; + DefTendencies->TracerHorzAdv.Enabled = false; + DefTendencies->TracerDiffusion.Enabled = false; + DefTendencies->TracerHyperDiff.Enabled = false; + DefTendencies->SurfaceTracerRestoring.Enabled = false; + + // Compute baseline tendencies (vertical advection is always on) + int ThickTimeLevel = 0; + int VelTimeLevel = 0; + TimeInstant Time; + DefTendencies->computePseudoThicknessTendenciesOnly( + State, AuxState, ThickTimeLevel, VelTimeLevel, Time); + + HostArray2DReal PseudoThicknessTendBaseH = + createHostMirrorCopy(DefTendencies->PseudoThicknessTend); + deepCopy(PseudoThicknessTendBaseH, DefTendencies->PseudoThicknessTend); + const Real BaselineThickTend = PseudoThicknessTendBaseH(ICellTest, KTop); + + // Now enable SfcThicknessForcing and compute again + DefTendencies->SfcThicknessForcing.Enabled = true; + DefTendencies->computePseudoThicknessTendenciesOnly( + State, AuxState, ThickTimeLevel, VelTimeLevel, Time); + + // Calculate expected thickness tendency + // ThickTend = (Rain + Snow + IceRunoff + RiverRunoff + SeaIceFreshWater + + // SeaIceSaltFlux) / RhoSw + const Real ExpectedThickTend = + (TestRain + TestSnow + TestIceRunoff + TestRiverRunoff + + TestSeaIceFreshWater + TestSeaIceSaltFlux) / + RhoSw; + + HostArray2DReal PseudoThicknessTendH = + createHostMirrorCopy(DefTendencies->PseudoThicknessTend); + deepCopy(PseudoThicknessTendH, DefTendencies->PseudoThicknessTend); + const Real ComputedThickTend = + PseudoThicknessTendH(ICellTest, KTop) - BaselineThickTend; + + constexpr Real RelTol = 1.0e-10_Real; + constexpr Real AbsTol = 1.0e-12_Real; + + // Check thickness tendency + if (!isApprox(ComputedThickTend, ExpectedThickTend, RelTol, AbsTol)) { + Err++; + LOG_ERROR("TendenciesTest: SfcThicknessForcing thickness tendency FAIL"); + LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedThickTend, + ComputedThickTend, + Kokkos::abs(ComputedThickTend - ExpectedThickTend)); + } else { + LOG_INFO("TendenciesTest: SfcThicknessForcing thickness tendency PASS"); + } + + DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; + DefTendencies->SfcThicknessForcing.Enabled = OrigSfcThicknessEnabled; + DefTendencies->SfcTracerForcing.Enabled = OrigSfcTracerEnabled; + DefTendencies->PseudoThicknessFluxDiv.Enabled = OrigPseudoThicknessDiv; + DefTendencies->PotentialVortHAdv.Enabled = OrigPotentialVortHAdv; + DefTendencies->KEGrad.Enabled = OrigKEGrad; + DefTendencies->VelocityDiffusion.Enabled = OrigVelocityDiffusion; + DefTendencies->VelocityHyperDiff.Enabled = OrigVelocityHyperDiff; + DefTendencies->TracerHorzAdv.Enabled = OrigTracerHorzAdv; + DefTendencies->TracerDiffusion.Enabled = OrigTracerDiffusion; + DefTendencies->TracerHyperDiff.Enabled = OrigTracerHyperDiff; + DefTendencies->SurfaceTracerRestoring.Enabled = OrigSurfaceTracerRestoring; + + return Err; +} + void finalizeTendenciesTest() { Forcing::clear(); Tracers::clear(); From 8befc5851181e32dabf79ddd087447feaad870c7 Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Fri, 26 Jun 2026 13:46:07 -0700 Subject: [PATCH 04/36] made mass enthalpy flux dependent on thickness flag - under discussion --- components/omega/src/ocn/Tendencies.cpp | 14 +- components/omega/src/ocn/TendencyTerms.h | 46 ++++--- components/omega/test/ocn/TendenciesTest.cpp | 133 +++++++++++++++---- 3 files changed, 141 insertions(+), 52 deletions(-) diff --git a/components/omega/src/ocn/Tendencies.cpp b/components/omega/src/ocn/Tendencies.cpp index 419c105292a6..3533cbc5aa0f 100644 --- a/components/omega/src/ocn/Tendencies.cpp +++ b/components/omega/src/ocn/Tendencies.cpp @@ -959,15 +959,17 @@ void Tendencies::computeTracerTendenciesOnly( ForcingState->TracerForcing.RiverRunoffFluxCell; const auto &SeaIceSaltFlux = ForcingState->TracerForcing.SeaIceSaltFluxCell; - const auto &PressureMid = VCoord->PressureMid; + const auto &PressureMid = VCoord->PressureMid; + const bool UseMassFluxHeat = SfcThicknessForcing.Enabled; parallelFor( {Mesh->NCellsAll}, KOKKOS_LAMBDA(int ICell) { - LocSfcTracerForcing( - LocTracerTend, ICell, TracerArray, PressureMid, LatentHeatFlux, - SensibleHeatFlux, LongWaveHeatFluxUp, LongWaveHeatFluxDown, - SeaIceHeatFlux, ShortWaveHeatFlux, SnowFlux, RainFlux, - IceRunoffFlux, RiverRunoffFlux, SeaIceSaltFlux); + LocSfcTracerForcing(LocTracerTend, ICell, TracerArray, PressureMid, + LatentHeatFlux, SensibleHeatFlux, + LongWaveHeatFluxUp, LongWaveHeatFluxDown, + SeaIceHeatFlux, ShortWaveHeatFlux, SnowFlux, + RainFlux, IceRunoffFlux, RiverRunoffFlux, + SeaIceSaltFlux, UseMassFluxHeat); }); Pacer::stop("Tend:sfcTracerForcing", 2); } diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index 77b8ce93df04..30ceafde45d0 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -416,17 +416,16 @@ class SfcTracerForcingOnCell { I4 TempTracerIndex, I4 SaltTracerIndex, const Eos *EosInst); - KOKKOS_FUNCTION void - operator()(const Array3DReal &Tend, I4 ICell, const Array3DReal &TracerCell, - const Array2DReal &PressureMid, const Array1DReal &LatentHeatFlux, - const Array1DReal &SensibleHeatFlux, - const Array1DReal &LongWaveHeatFluxUp, - const Array1DReal &LongWaveHeatFluxDown, - const Array1DReal &SeaIceHeatFlux, - const Array1DReal &ShortWaveHeatFlux, const Array1DReal &SnowFlux, - const Array1DReal &RainFlux, const Array1DReal &IceRunoffFlux, - const Array1DReal &RiverRunoffFlux, - const Array1DReal &SeaIceSaltFlux) const { + KOKKOS_FUNCTION void operator()( + const Array3DReal &Tend, I4 ICell, const Array3DReal &TracerCell, + const Array2DReal &PressureMid, const Array1DReal &LatentHeatFlux, + const Array1DReal &SensibleHeatFlux, + const Array1DReal &LongWaveHeatFluxUp, + const Array1DReal &LongWaveHeatFluxDown, + const Array1DReal &SeaIceHeatFlux, const Array1DReal &ShortWaveHeatFlux, + const Array1DReal &SnowFlux, const Array1DReal &RainFlux, + const Array1DReal &IceRunoffFlux, const Array1DReal &RiverRunoffFlux, + const Array1DReal &SeaIceSaltFlux, const bool UseMassFluxHeat) const { const I4 KTop = MinLayerCell(ICell); if (KTop > MaxLayerCell(ICell)) { @@ -441,20 +440,29 @@ class SfcTracerForcingOnCell { const Real CtFrz = EosImpl.calcCtFreezing(SaTop, PTop, 0.0_Real); const Real CtTop = TracerCell(TempIndex, ICell, KTop); - // Heat tendencies are due to direct heat fluxes + enthalpy fluxes - // The enthalpy of liquid water is assumed to be: + // Always include direct surface heat fluxes. + const Real DirectHeatFlux = + LatentHeatFlux(ICell) + SensibleHeatFlux(ICell) + + LongWaveHeatFluxUp(ICell) + LongWaveHeatFluxDown(ICell) + + SeaIceHeatFlux(ICell) + ShortWaveHeatFlux(ICell); + + // Apply enthalpy of mass fluxes only when thickness forcing is + // enabled. + const Real MassFluxHeat = + (RainFlux(ICell) + RiverRunoffFlux(ICell)) * Cp0Sw * CtTop + + (SnowFlux(ICell) + IceRunoffFlux(ICell)) * + (Cp0Sw * CtFrz - LatIce); + // Note: the enthalpy of liquid water above is assumed to be: // - local SST for liquid mass fluxes (rain, rivers) // - local freezing point for solid --> liq mass fluxes (snow, frozen // runoff) // - solid mass fluxes are locally melted by the ocean (constant Lat // heat of fusion) + // - meltwater enthalpy from sea ice is already included in + // SeaIceHeatFlux + const Real HeatFlux = - LatentHeatFlux(ICell) + SensibleHeatFlux(ICell) + - LongWaveHeatFluxUp(ICell) + LongWaveHeatFluxDown(ICell) + - SeaIceHeatFlux(ICell) + ShortWaveHeatFlux(ICell) + - (RainFlux(ICell) + RiverRunoffFlux(ICell)) * Cp0Sw * CtTop + - (SnowFlux(ICell) + IceRunoffFlux(ICell)) * - (Cp0Sw * CtFrz - LatIce); + DirectHeatFlux + (UseMassFluxHeat ? MassFluxHeat : 0.0_Real); Tend(TempIndex, ICell, KTop) += HeatFlux * HFluxFac; } diff --git a/components/omega/test/ocn/TendenciesTest.cpp b/components/omega/test/ocn/TendenciesTest.cpp index 1045701339dd..d30babd08797 100644 --- a/components/omega/test/ocn/TendenciesTest.cpp +++ b/components/omega/test/ocn/TendenciesTest.cpp @@ -494,11 +494,71 @@ int testSfcTracerForcing() { // Now enable SfcTracerForcing and compute again DefTendencies->SfcTracerForcing.Enabled = true; + // First pass: thickness forcing disabled, so only direct heat flux should + // contribute to temperature tendency. + DefTendencies->SfcThicknessForcing.Enabled = false; DefTendencies->computeAllTendencies(State, AuxState, TracerArray, ThickTimeLevel, VelTimeLevel, TracerTimeLevel, Time, Interval); - // Build two reference expectations for temperature tendency: + HostArray3DReal TracerTendNoMassH = + createHostMirrorCopy(DefTendencies->TracerTend); + deepCopy(TracerTendNoMassH, DefTendencies->TracerTend); + const Real ComputedTempTendNoMass = + TracerTendNoMassH(TempIndex, ICellTest, KTop) - BaselineTempTend; + const Real ComputedSaltTendNoMass = + TracerTendNoMassH(SaltIndex, ICellTest, KTop) - BaselineSaltTend; + + // With thickness forcing disabled, only direct heat flux terms are applied. + const Real ExpectedTempTendNoMass = TestSensibleHeat * HFluxFac; + + // SaltTend = SeaIceSaltFlux * SFluxFac + const Real ExpectedSaltTend = TestSeaIceSaltFlux * SFluxFac; + + constexpr Real RelTol = 1.0e-10_Real; + constexpr Real AbsTol = 1.0e-12_Real; // flux precision is ~e-15 + + if (!isApprox(ComputedTempTendNoMass, ExpectedTempTendNoMass, RelTol, + AbsTol)) { + Err++; + LOG_ERROR("TendenciesTest: SfcTracerForcing temp tendency FAIL with " + "SfcThicknessForcing disabled"); + LOG_ERROR(" Expected (direct only): {}, Computed: {}, Diff: {}", + ExpectedTempTendNoMass, ComputedTempTendNoMass, + Kokkos::abs(ComputedTempTendNoMass - ExpectedTempTendNoMass)); + } else { + LOG_INFO("TendenciesTest: SfcTracerForcing temp tendency PASS with " + "SfcThicknessForcing disabled"); + } + + if (!isApprox(ComputedSaltTendNoMass, ExpectedSaltTend, RelTol, AbsTol)) { + Err++; + LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL with " + "SfcThicknessForcing disabled"); + LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedSaltTend, + ComputedSaltTendNoMass, + Kokkos::abs(ComputedSaltTendNoMass - ExpectedSaltTend)); + } else { + LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS with " + "SfcThicknessForcing disabled"); + } + + // Second pass: thickness forcing enabled, so mass-flux enthalpy terms are + // also included in temperature tendency. + DefTendencies->SfcThicknessForcing.Enabled = true; + DefTendencies->computeAllTendencies(State, AuxState, TracerArray, + ThickTimeLevel, VelTimeLevel, + TracerTimeLevel, Time, Interval); + + HostArray3DReal TracerTendMassH = + createHostMirrorCopy(DefTendencies->TracerTend); + deepCopy(TracerTendMassH, DefTendencies->TracerTend); + const Real ComputedTempTendMass = + TracerTendMassH(TempIndex, ICellTest, KTop) - BaselineTempTend; + const Real ComputedSaltTendMass = + TracerTendMassH(SaltIndex, ICellTest, KTop) - BaselineSaltTend; + + // Build two reference expectations for the mass-on case: // 1) fixed estimate (expected to fail under strict tolerance), // 2) TEOS-10 freezing CT (expected to pass under strict tolerance). const Real CtFrzEstimate = -2.0_Real; @@ -516,28 +576,31 @@ int testSfcTracerForcing() { TestSnow * (Cp0Sw * CtFrzTeos - LatIce)) * HFluxFac; - // SaltTend = SeaIceSaltFlux * SFluxFac - const Real ExpectedSaltTend = TestSeaIceSaltFlux * SFluxFac; - - HostArray3DReal TracerTendH = - createHostMirrorCopy(DefTendencies->TracerTend); - deepCopy(TracerTendH, DefTendencies->TracerTend); - const Real ComputedTempTend = - TracerTendH(TempIndex, ICellTest, KTop) - BaselineTempTend; - const Real ComputedSaltTend = - TracerTendH(SaltIndex, ICellTest, KTop) - BaselineSaltTend; - - constexpr Real RelTol = 1.0e-10_Real; - constexpr Real AbsTol = 1.0e-12_Real; // flux precision is ~e-15 + // Expected-fail check: no-mass expectation should fail when mass-flux + // terms are enabled. + if (!isApprox(ComputedTempTendMass, ExpectedTempTendNoMass, RelTol, + AbsTol)) { + LOG_INFO( + "TendenciesTest: expected tempTend fail because mass-flux heat is " + "enabled but compared against direct-only reference - PASS"); + LOG_INFO("tempTend Expected: {}, Computed: {}, Diff: {}", + ExpectedTempTendNoMass, ComputedTempTendMass, + Kokkos::abs(ComputedTempTendMass - ExpectedTempTendNoMass)); + } else { + Err++; + LOG_ERROR("TendenciesTest: mass-flux-enabled run unexpectedly matched " + "direct-only reference - FAIL"); + } // Expected-fail check with fixed CtFrz estimate. - if (!isApprox(ComputedTempTend, ExpectedTempTendEstimate, RelTol, AbsTol)) { + if (!isApprox(ComputedTempTendMass, ExpectedTempTendEstimate, RelTol, + AbsTol)) { LOG_INFO( "TendenciesTest: expected tempTend fail because CtFrzEstimate != EOS " "CtFrz - PASS"); LOG_INFO("tempTend Expected: {}, Computed: {}, Diff: {}", - ExpectedTempTendEstimate, ComputedTempTend, - Kokkos::abs(ComputedTempTend - ExpectedTempTendEstimate)); + ExpectedTempTendEstimate, ComputedTempTendMass, + Kokkos::abs(ComputedTempTendMass - ExpectedTempTendEstimate)); } else { Err++; LOG_ERROR("TendenciesTest: CtFrz estimate unexpectedly matched strict " @@ -545,25 +608,41 @@ int testSfcTracerForcing() { } // Expected-pass check with TEOS freezing CT reference. - if (!isApprox(ComputedTempTend, ExpectedTempTendTeos, RelTol, AbsTol)) { + if (!isApprox(ComputedTempTendMass, ExpectedTempTendTeos, RelTol, AbsTol)) { Err++; LOG_ERROR("TendenciesTest: SfcTracerForcing temp tendency FAIL"); LOG_ERROR(" with TEOS-CtFrz Expected: {}, Computed: {}, Diff: {}", - ExpectedTempTendTeos, ComputedTempTend, - Kokkos::abs(ComputedTempTend - ExpectedTempTendTeos)); + ExpectedTempTendTeos, ComputedTempTendMass, + Kokkos::abs(ComputedTempTendMass - ExpectedTempTendTeos)); } else { - LOG_INFO("TendenciesTest: SfcTracerForcing temp tendency PASS"); + LOG_INFO("TendenciesTest: SfcTracerForcing temp tendency PASSwith " + "SfcThicknessForcing enabled"); } - // Check salinity tendency - if (!isApprox(ComputedSaltTend, ExpectedSaltTend, RelTol, AbsTol)) { + // Check salinity tendency for mass-on pass + if (!isApprox(ComputedSaltTendMass, ExpectedSaltTend, RelTol, AbsTol)) { Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL"); + LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL with " + "SfcThicknessForcing enabled"); LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedSaltTend, - ComputedSaltTend, - Kokkos::abs(ComputedSaltTend - ExpectedSaltTend)); + ComputedSaltTendMass, + Kokkos::abs(ComputedSaltTendMass - ExpectedSaltTend)); + } else { + LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS with " + "SfcThicknessForcing enabled"); + } + + if (!isApprox(ComputedSaltTendNoMass, ComputedSaltTendMass, RelTol, + AbsTol)) { + Err++; + LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency changed with " + "SfcThicknessForcing toggle - FAIL"); + LOG_ERROR(" Off: {}, On: {}, Diff: {}", ComputedSaltTendNoMass, + ComputedSaltTendMass, + Kokkos::abs(ComputedSaltTendNoMass - ComputedSaltTendMass)); } else { - LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS"); + LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency invariant under " + "SfcThicknessForcing toggle PASS"); } DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; From e2b0ba0aa620fe6540ef3821360c235003cfb14a Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Fri, 26 Jun 2026 14:27:46 -0700 Subject: [PATCH 05/36] draft a non-teos10 CtFrz in comments - WIP --- components/omega/src/ocn/Eos.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/omega/src/ocn/Eos.cpp b/components/omega/src/ocn/Eos.cpp index 516cb629e154..49ea504fb84c 100644 --- a/components/omega/src/ocn/Eos.cpp +++ b/components/omega/src/ocn/Eos.cpp @@ -352,7 +352,9 @@ Real Eos::calcCtFreezing(const Real Sa, const Real P, ABORT_ERROR("Eos::calcCtFreezing: CT freezing temperature is only " "implemented for TEOS-10. Support for the current EOS " "choice has not yet been developed."); - return 0; + // most likely I'd implement a polynomial here for non-teos10 e.g. + // return 0.0 - 0.0575 * Sa + 1.710523e-3 * sqrt(Sa^3) - 2.154996e-4 * Sa^2 + return 0.0; } /// Define IO fields and metadata for output From 5a9ad35b490b53d628612e2d47e33d70b7c4ce89 Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Fri, 26 Jun 2026 14:49:55 -0700 Subject: [PATCH 06/36] updated the documentation --- components/omega/doc/devGuide/Forcing.md | 19 +++++++++++++----- .../omega/doc/devGuide/TendencyTerms.md | 6 ++++-- components/omega/doc/userGuide/Forcing.md | 20 ++++++++++++++++--- .../omega/doc/userGuide/TendencyTerms.md | 2 +- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/components/omega/doc/devGuide/Forcing.md b/components/omega/doc/devGuide/Forcing.md index 18e9c2072190..700711e86761 100644 --- a/components/omega/doc/devGuide/Forcing.md +++ b/components/omega/doc/devGuide/Forcing.md @@ -58,10 +58,11 @@ the surface layer pseudo-thickness. - `LatentHeatFlux`, `SensibleHeatFlux` - `LongWaveHeatFluxUp`, `LongWaveHeatFluxDown` - `SeaIceHeatFlux`, `ShortWaveHeatFlux` - - `SeaIceSaltFlux`, `SnowFlux`, `IceRunoffFlux` + - mass fluxes which add energy changes (`SnowFlux`, `RainFlux`, `IceRunoffFlux`, `RiverRunoffFlux`) + - `SeaIceSaltFlux` 2. `Forcing` stores the flux fields in `TracerForcingVars` 3. The tendency term `SfcTracerForcingOnCell` converts the summed external heat fluxes to a conservative-temperature tendency, - and applies the external sea-ice salt flux to salinity (g/kg) in the surface layer. + and applies the external sea-ice salt flux to salinity (g/kg) in the surface layer. [under discussion: in the latest implementation, if the thickness tendencies are turned off, the temperature tendency does not include the enthalpy associated with explicit mass fluxes] ### Surface flux forcing key classes/components @@ -73,14 +74,20 @@ the surface layer pseudo-thickness. - Computes freshwater flux contribution: $\sum (\text{SnowFlux} + \text{RainFlux} + \text{EvaporationFlux} + \text{SeaIceFreshWaterFlux} + \text{IceRunoffFlux} + \text{RiverRunoffFlux} + \text{SeaIceSaltFlux}) / \rho_{sw}$ - Applied only at surface layer (top active layer) using `MinLayerCell` - `SfcTracerForcingOnCell` tendency term - - For temperature: computes the sum of the six heat-flux fields and scales it by $H_{\text{FluxFac}}$ + - For temperature: computes + $Q_{\text{direct}} = Q_{\text{latent}} + Q_{\text{sensible}} + Q_{\text{lw,up}} + Q_{\text{lw,down}} + Q_{\text{ice}} + Q_{\text{sw}}$ + and scales by $H_{\text{FluxFac}}$. + - For temperature: when `SfcThicknessForcing` is enabled, also adds + mass-flux enthalpy + $(\text{RainFlux} + \text{RiverRunoffFlux}) c^0_{p,sw} C_T^{\text{top}} + (\text{SnowFlux} + \text{IceRunoffFlux})(c^0_{p,sw} C_T^{\text{frz}} - L_{\text{ice}})$, + where $C_T^{\text{frz}}$ is from EOS at top-layer salinity and pressure. - For salinity: applies salt flux with unit conversion: $\text{SeaIceSaltFlux} \times S_{\text{FluxFac}}$ - Applied only at surface layer using `MinLayerCell` - Uses tracer index validation to apply to specific tracers only - `Forcing` - Manages `TracerForcingVars` instance - `Tendencies` - - Calls `SfcThicknessForcingOnCell` in `computeThicknessTendenciesOnly` + - Calls `SfcThicknessForcingOnCell` in `computePseudoThicknessTendenciesOnly` - Calls `SfcTracerForcingOnCell` in `computeTracerTendenciesOnly` after surface tracer restoring ### Surface flux forcing config coupling @@ -88,9 +95,11 @@ the surface layer pseudo-thickness. - `Omega.Tendencies.SfcThicknessForcingTendencyEnable` - gates execution of coupled flux thickness kernel - controls freshwater and salt flux forcing on sea surface height + - also gates whether mass-flux enthalpy terms are added in tracer + temperature forcing - `Omega.Tendencies.SfcTracerForcingTendencyEnable` - gates execution of coupled flux tracer kernel - - controls heat flux forcing on temperature and salt flux forcing on salinity + - controls direct heat flux forcing on temperature and salt flux forcing on salinity ## Surface tracer restoring design diff --git a/components/omega/doc/devGuide/TendencyTerms.md b/components/omega/doc/devGuide/TendencyTerms.md index 5fa72197132f..028ba9c02032 100644 --- a/components/omega/doc/devGuide/TendencyTerms.md +++ b/components/omega/doc/devGuide/TendencyTerms.md @@ -41,9 +41,11 @@ implemented: - `TracerHighOrderHorzAdvOnCell` - `TracerDiffOnCell` - `TracerHyperDiffOnCell` +- `SfcThicknessForcingOnCell` +- `SfcTracerForcingOnCell` - `SurfaceTracerRestoringOnCell` ## See Also -Additional information on forcing (currently wind forcing and surface tracer -restoring) is detailed in [](omega-dev-forcing). +Additional information on forcing (surface stress, surface flux forcing, and +surface tracer restoring) is detailed in [](omega-dev-forcing). diff --git a/components/omega/doc/userGuide/Forcing.md b/components/omega/doc/userGuide/Forcing.md index b49eabd0586f..34a964a61510 100644 --- a/components/omega/doc/userGuide/Forcing.md +++ b/components/omega/doc/userGuide/Forcing.md @@ -60,6 +60,11 @@ Omega: - `Tendencies.SfcThicknessForcingTendencyEnable`: enables coupled freshwater and salt flux forcing on thickness - `Tendencies.SfcTracerForcingTendencyEnable`: enables coupled heat and salt flux forcing on tracers +When `Tendencies.SfcTracerForcingTendencyEnable` is enabled, direct surface heat +flux terms are always applied to temperature. Additional mass-flux enthalpy +terms (rain/river and snow/ice runoff) are applied only when +`Tendencies.SfcThicknessForcingTendencyEnable` is also enabled. + ### Required input fields Coupled flux forcing uses 13 auxiliary fields organized by type: @@ -93,10 +98,19 @@ by the equivalent `ocn_comp_mct.F`. - Coupled fluxes are applied only at the surface layer (top active layer) for each cell. - Pseudo-thickness tendency is computed from the (six) freshwater mass fluxes and the salt mass flux `SeaIceSaltFlux`, converted to a pseudo-thickness change. -- Temperature tendency is computed from the sum of the six heat-flux fields, - converted to conservative-temperature tendency via +- Temperature tendency is computed from direct heat flux plus optional + mass-flux enthalpy terms, converted to conservative-temperature tendency via $H_{\text{FluxFac}} = 1.0 / (\rho_{sw} c^0_{p,sw})$ where $c^0_{p,sw}$ is the reference - specific heat of seawater defined by TEOS-10. [soon to be updated with latent heat and enthalpy of liquid water] + specific heat of seawater defined by TEOS-10. + The direct heat part is + $Q_{\text{direct}} = Q_{\text{latent}} + Q_{\text{sensible}} + Q_{\text{lw,up}} + Q_{\text{lw,down}} + Q_{\text{ice}} + Q_{\text{sw}}$. + The mass-flux enthalpy part is + $Q_{\text{mass}} = (\text{RainFlux} + \text{RiverRunoffFlux}) c^0_{p,sw} C_T^{\text{top}} + (\text{SnowFlux} + \text{IceRunoffFlux})(c^0_{p,sw} C_T^{\text{frz}} - L_{\text{ice}})$, + where $C_T^{\text{frz}}$ is computed from EOS at top-layer salinity and pressure. + The applied heat flux is + $Q_{\text{direct}} + Q_{\text{mass}}$ when + `Tendencies.SfcThicknessForcingTendencyEnable` is true, and + $Q_{\text{direct}}$ otherwise. - Salinity tendency from `SeaIceSaltFlux` is scaled by $S_{\text{FluxFac}} = 1.0e3 / \rho_{sw}$ to account for unit conversion from kg/(m²·s) to salinity units (g/kg). diff --git a/components/omega/doc/userGuide/TendencyTerms.md b/components/omega/doc/userGuide/TendencyTerms.md index 1259d3387054..7847f0cfb357 100644 --- a/components/omega/doc/userGuide/TendencyTerms.md +++ b/components/omega/doc/userGuide/TendencyTerms.md @@ -21,7 +21,7 @@ tendency terms are currently implemented: | SfcStressForcingOnEdge | forcing by surface stress (e.g. wind), defined on edges | BottomDragOnEdge | bottom drag, defined on edges | SfcThicknessForcingOnCell | surface pseudo-thickness forcing from coupled freshwater and salt fluxes, defined on cells -| SfcTracerForcingOnCell | surface tracer forcing from coupled heat and salt fluxes, defined on cells +| SfcTracerForcingOnCell | surface tracer forcing from coupled heat and salt fluxes, with direct heat always and mass-flux enthalpy terms gated by thickness forcing, defined on cells | SurfaceTracerRestoringOnCell | surface tracer restoring, defined on cells Among the internal data stored by each functor is a `bool` which can enable or From 5078bec86879c181c81a9ab66921ffc16896ea68 Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Mon, 6 Jul 2026 10:07:40 -0700 Subject: [PATCH 07/36] Revert "made mass enthalpy flux dependent on thickness flag - under discussion" This reverts commit 70b0ca28a7939588d536496e9084c38b0663c5e1. --- components/omega/src/ocn/Tendencies.cpp | 14 +- components/omega/src/ocn/TendencyTerms.h | 46 +++---- components/omega/test/ocn/TendenciesTest.cpp | 133 ++++--------------- 3 files changed, 52 insertions(+), 141 deletions(-) diff --git a/components/omega/src/ocn/Tendencies.cpp b/components/omega/src/ocn/Tendencies.cpp index 3533cbc5aa0f..419c105292a6 100644 --- a/components/omega/src/ocn/Tendencies.cpp +++ b/components/omega/src/ocn/Tendencies.cpp @@ -959,17 +959,15 @@ void Tendencies::computeTracerTendenciesOnly( ForcingState->TracerForcing.RiverRunoffFluxCell; const auto &SeaIceSaltFlux = ForcingState->TracerForcing.SeaIceSaltFluxCell; - const auto &PressureMid = VCoord->PressureMid; - const bool UseMassFluxHeat = SfcThicknessForcing.Enabled; + const auto &PressureMid = VCoord->PressureMid; parallelFor( {Mesh->NCellsAll}, KOKKOS_LAMBDA(int ICell) { - LocSfcTracerForcing(LocTracerTend, ICell, TracerArray, PressureMid, - LatentHeatFlux, SensibleHeatFlux, - LongWaveHeatFluxUp, LongWaveHeatFluxDown, - SeaIceHeatFlux, ShortWaveHeatFlux, SnowFlux, - RainFlux, IceRunoffFlux, RiverRunoffFlux, - SeaIceSaltFlux, UseMassFluxHeat); + LocSfcTracerForcing( + LocTracerTend, ICell, TracerArray, PressureMid, LatentHeatFlux, + SensibleHeatFlux, LongWaveHeatFluxUp, LongWaveHeatFluxDown, + SeaIceHeatFlux, ShortWaveHeatFlux, SnowFlux, RainFlux, + IceRunoffFlux, RiverRunoffFlux, SeaIceSaltFlux); }); Pacer::stop("Tend:sfcTracerForcing", 2); } diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index 30ceafde45d0..77b8ce93df04 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -416,16 +416,17 @@ class SfcTracerForcingOnCell { I4 TempTracerIndex, I4 SaltTracerIndex, const Eos *EosInst); - KOKKOS_FUNCTION void operator()( - const Array3DReal &Tend, I4 ICell, const Array3DReal &TracerCell, - const Array2DReal &PressureMid, const Array1DReal &LatentHeatFlux, - const Array1DReal &SensibleHeatFlux, - const Array1DReal &LongWaveHeatFluxUp, - const Array1DReal &LongWaveHeatFluxDown, - const Array1DReal &SeaIceHeatFlux, const Array1DReal &ShortWaveHeatFlux, - const Array1DReal &SnowFlux, const Array1DReal &RainFlux, - const Array1DReal &IceRunoffFlux, const Array1DReal &RiverRunoffFlux, - const Array1DReal &SeaIceSaltFlux, const bool UseMassFluxHeat) const { + KOKKOS_FUNCTION void + operator()(const Array3DReal &Tend, I4 ICell, const Array3DReal &TracerCell, + const Array2DReal &PressureMid, const Array1DReal &LatentHeatFlux, + const Array1DReal &SensibleHeatFlux, + const Array1DReal &LongWaveHeatFluxUp, + const Array1DReal &LongWaveHeatFluxDown, + const Array1DReal &SeaIceHeatFlux, + const Array1DReal &ShortWaveHeatFlux, const Array1DReal &SnowFlux, + const Array1DReal &RainFlux, const Array1DReal &IceRunoffFlux, + const Array1DReal &RiverRunoffFlux, + const Array1DReal &SeaIceSaltFlux) const { const I4 KTop = MinLayerCell(ICell); if (KTop > MaxLayerCell(ICell)) { @@ -440,29 +441,20 @@ class SfcTracerForcingOnCell { const Real CtFrz = EosImpl.calcCtFreezing(SaTop, PTop, 0.0_Real); const Real CtTop = TracerCell(TempIndex, ICell, KTop); - // Always include direct surface heat fluxes. - const Real DirectHeatFlux = - LatentHeatFlux(ICell) + SensibleHeatFlux(ICell) + - LongWaveHeatFluxUp(ICell) + LongWaveHeatFluxDown(ICell) + - SeaIceHeatFlux(ICell) + ShortWaveHeatFlux(ICell); - - // Apply enthalpy of mass fluxes only when thickness forcing is - // enabled. - const Real MassFluxHeat = - (RainFlux(ICell) + RiverRunoffFlux(ICell)) * Cp0Sw * CtTop + - (SnowFlux(ICell) + IceRunoffFlux(ICell)) * - (Cp0Sw * CtFrz - LatIce); - // Note: the enthalpy of liquid water above is assumed to be: + // Heat tendencies are due to direct heat fluxes + enthalpy fluxes + // The enthalpy of liquid water is assumed to be: // - local SST for liquid mass fluxes (rain, rivers) // - local freezing point for solid --> liq mass fluxes (snow, frozen // runoff) // - solid mass fluxes are locally melted by the ocean (constant Lat // heat of fusion) - // - meltwater enthalpy from sea ice is already included in - // SeaIceHeatFlux - const Real HeatFlux = - DirectHeatFlux + (UseMassFluxHeat ? MassFluxHeat : 0.0_Real); + LatentHeatFlux(ICell) + SensibleHeatFlux(ICell) + + LongWaveHeatFluxUp(ICell) + LongWaveHeatFluxDown(ICell) + + SeaIceHeatFlux(ICell) + ShortWaveHeatFlux(ICell) + + (RainFlux(ICell) + RiverRunoffFlux(ICell)) * Cp0Sw * CtTop + + (SnowFlux(ICell) + IceRunoffFlux(ICell)) * + (Cp0Sw * CtFrz - LatIce); Tend(TempIndex, ICell, KTop) += HeatFlux * HFluxFac; } diff --git a/components/omega/test/ocn/TendenciesTest.cpp b/components/omega/test/ocn/TendenciesTest.cpp index d30babd08797..1045701339dd 100644 --- a/components/omega/test/ocn/TendenciesTest.cpp +++ b/components/omega/test/ocn/TendenciesTest.cpp @@ -494,71 +494,11 @@ int testSfcTracerForcing() { // Now enable SfcTracerForcing and compute again DefTendencies->SfcTracerForcing.Enabled = true; - // First pass: thickness forcing disabled, so only direct heat flux should - // contribute to temperature tendency. - DefTendencies->SfcThicknessForcing.Enabled = false; DefTendencies->computeAllTendencies(State, AuxState, TracerArray, ThickTimeLevel, VelTimeLevel, TracerTimeLevel, Time, Interval); - HostArray3DReal TracerTendNoMassH = - createHostMirrorCopy(DefTendencies->TracerTend); - deepCopy(TracerTendNoMassH, DefTendencies->TracerTend); - const Real ComputedTempTendNoMass = - TracerTendNoMassH(TempIndex, ICellTest, KTop) - BaselineTempTend; - const Real ComputedSaltTendNoMass = - TracerTendNoMassH(SaltIndex, ICellTest, KTop) - BaselineSaltTend; - - // With thickness forcing disabled, only direct heat flux terms are applied. - const Real ExpectedTempTendNoMass = TestSensibleHeat * HFluxFac; - - // SaltTend = SeaIceSaltFlux * SFluxFac - const Real ExpectedSaltTend = TestSeaIceSaltFlux * SFluxFac; - - constexpr Real RelTol = 1.0e-10_Real; - constexpr Real AbsTol = 1.0e-12_Real; // flux precision is ~e-15 - - if (!isApprox(ComputedTempTendNoMass, ExpectedTempTendNoMass, RelTol, - AbsTol)) { - Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcing temp tendency FAIL with " - "SfcThicknessForcing disabled"); - LOG_ERROR(" Expected (direct only): {}, Computed: {}, Diff: {}", - ExpectedTempTendNoMass, ComputedTempTendNoMass, - Kokkos::abs(ComputedTempTendNoMass - ExpectedTempTendNoMass)); - } else { - LOG_INFO("TendenciesTest: SfcTracerForcing temp tendency PASS with " - "SfcThicknessForcing disabled"); - } - - if (!isApprox(ComputedSaltTendNoMass, ExpectedSaltTend, RelTol, AbsTol)) { - Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL with " - "SfcThicknessForcing disabled"); - LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedSaltTend, - ComputedSaltTendNoMass, - Kokkos::abs(ComputedSaltTendNoMass - ExpectedSaltTend)); - } else { - LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS with " - "SfcThicknessForcing disabled"); - } - - // Second pass: thickness forcing enabled, so mass-flux enthalpy terms are - // also included in temperature tendency. - DefTendencies->SfcThicknessForcing.Enabled = true; - DefTendencies->computeAllTendencies(State, AuxState, TracerArray, - ThickTimeLevel, VelTimeLevel, - TracerTimeLevel, Time, Interval); - - HostArray3DReal TracerTendMassH = - createHostMirrorCopy(DefTendencies->TracerTend); - deepCopy(TracerTendMassH, DefTendencies->TracerTend); - const Real ComputedTempTendMass = - TracerTendMassH(TempIndex, ICellTest, KTop) - BaselineTempTend; - const Real ComputedSaltTendMass = - TracerTendMassH(SaltIndex, ICellTest, KTop) - BaselineSaltTend; - - // Build two reference expectations for the mass-on case: + // Build two reference expectations for temperature tendency: // 1) fixed estimate (expected to fail under strict tolerance), // 2) TEOS-10 freezing CT (expected to pass under strict tolerance). const Real CtFrzEstimate = -2.0_Real; @@ -576,31 +516,28 @@ int testSfcTracerForcing() { TestSnow * (Cp0Sw * CtFrzTeos - LatIce)) * HFluxFac; - // Expected-fail check: no-mass expectation should fail when mass-flux - // terms are enabled. - if (!isApprox(ComputedTempTendMass, ExpectedTempTendNoMass, RelTol, - AbsTol)) { - LOG_INFO( - "TendenciesTest: expected tempTend fail because mass-flux heat is " - "enabled but compared against direct-only reference - PASS"); - LOG_INFO("tempTend Expected: {}, Computed: {}, Diff: {}", - ExpectedTempTendNoMass, ComputedTempTendMass, - Kokkos::abs(ComputedTempTendMass - ExpectedTempTendNoMass)); - } else { - Err++; - LOG_ERROR("TendenciesTest: mass-flux-enabled run unexpectedly matched " - "direct-only reference - FAIL"); - } + // SaltTend = SeaIceSaltFlux * SFluxFac + const Real ExpectedSaltTend = TestSeaIceSaltFlux * SFluxFac; + + HostArray3DReal TracerTendH = + createHostMirrorCopy(DefTendencies->TracerTend); + deepCopy(TracerTendH, DefTendencies->TracerTend); + const Real ComputedTempTend = + TracerTendH(TempIndex, ICellTest, KTop) - BaselineTempTend; + const Real ComputedSaltTend = + TracerTendH(SaltIndex, ICellTest, KTop) - BaselineSaltTend; + + constexpr Real RelTol = 1.0e-10_Real; + constexpr Real AbsTol = 1.0e-12_Real; // flux precision is ~e-15 // Expected-fail check with fixed CtFrz estimate. - if (!isApprox(ComputedTempTendMass, ExpectedTempTendEstimate, RelTol, - AbsTol)) { + if (!isApprox(ComputedTempTend, ExpectedTempTendEstimate, RelTol, AbsTol)) { LOG_INFO( "TendenciesTest: expected tempTend fail because CtFrzEstimate != EOS " "CtFrz - PASS"); LOG_INFO("tempTend Expected: {}, Computed: {}, Diff: {}", - ExpectedTempTendEstimate, ComputedTempTendMass, - Kokkos::abs(ComputedTempTendMass - ExpectedTempTendEstimate)); + ExpectedTempTendEstimate, ComputedTempTend, + Kokkos::abs(ComputedTempTend - ExpectedTempTendEstimate)); } else { Err++; LOG_ERROR("TendenciesTest: CtFrz estimate unexpectedly matched strict " @@ -608,41 +545,25 @@ int testSfcTracerForcing() { } // Expected-pass check with TEOS freezing CT reference. - if (!isApprox(ComputedTempTendMass, ExpectedTempTendTeos, RelTol, AbsTol)) { + if (!isApprox(ComputedTempTend, ExpectedTempTendTeos, RelTol, AbsTol)) { Err++; LOG_ERROR("TendenciesTest: SfcTracerForcing temp tendency FAIL"); LOG_ERROR(" with TEOS-CtFrz Expected: {}, Computed: {}, Diff: {}", - ExpectedTempTendTeos, ComputedTempTendMass, - Kokkos::abs(ComputedTempTendMass - ExpectedTempTendTeos)); + ExpectedTempTendTeos, ComputedTempTend, + Kokkos::abs(ComputedTempTend - ExpectedTempTendTeos)); } else { - LOG_INFO("TendenciesTest: SfcTracerForcing temp tendency PASSwith " - "SfcThicknessForcing enabled"); + LOG_INFO("TendenciesTest: SfcTracerForcing temp tendency PASS"); } - // Check salinity tendency for mass-on pass - if (!isApprox(ComputedSaltTendMass, ExpectedSaltTend, RelTol, AbsTol)) { + // Check salinity tendency + if (!isApprox(ComputedSaltTend, ExpectedSaltTend, RelTol, AbsTol)) { Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL with " - "SfcThicknessForcing enabled"); + LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL"); LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedSaltTend, - ComputedSaltTendMass, - Kokkos::abs(ComputedSaltTendMass - ExpectedSaltTend)); - } else { - LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS with " - "SfcThicknessForcing enabled"); - } - - if (!isApprox(ComputedSaltTendNoMass, ComputedSaltTendMass, RelTol, - AbsTol)) { - Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency changed with " - "SfcThicknessForcing toggle - FAIL"); - LOG_ERROR(" Off: {}, On: {}, Diff: {}", ComputedSaltTendNoMass, - ComputedSaltTendMass, - Kokkos::abs(ComputedSaltTendNoMass - ComputedSaltTendMass)); + ComputedSaltTend, + Kokkos::abs(ComputedSaltTend - ExpectedSaltTend)); } else { - LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency invariant under " - "SfcThicknessForcing toggle PASS"); + LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS"); } DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; From c42172471389124280a01b80d675ac4eedc6c818 Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Mon, 6 Jul 2026 10:53:52 -0700 Subject: [PATCH 08/36] updated the documentation --- components/omega/doc/devGuide/Forcing.md | 14 +++++--------- components/omega/doc/userGuide/Forcing.md | 17 ++--------------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/components/omega/doc/devGuide/Forcing.md b/components/omega/doc/devGuide/Forcing.md index 700711e86761..869019e2469e 100644 --- a/components/omega/doc/devGuide/Forcing.md +++ b/components/omega/doc/devGuide/Forcing.md @@ -62,7 +62,7 @@ the surface layer pseudo-thickness. - `SeaIceSaltFlux` 2. `Forcing` stores the flux fields in `TracerForcingVars` 3. The tendency term `SfcTracerForcingOnCell` converts the summed external heat fluxes to a conservative-temperature tendency, - and applies the external sea-ice salt flux to salinity (g/kg) in the surface layer. [under discussion: in the latest implementation, if the thickness tendencies are turned off, the temperature tendency does not include the enthalpy associated with explicit mass fluxes] + and applies the external sea-ice salt flux to salinity (g/kg) in the surface layer. ### Surface flux forcing key classes/components @@ -74,13 +74,11 @@ the surface layer pseudo-thickness. - Computes freshwater flux contribution: $\sum (\text{SnowFlux} + \text{RainFlux} + \text{EvaporationFlux} + \text{SeaIceFreshWaterFlux} + \text{IceRunoffFlux} + \text{RiverRunoffFlux} + \text{SeaIceSaltFlux}) / \rho_{sw}$ - Applied only at surface layer (top active layer) using `MinLayerCell` - `SfcTracerForcingOnCell` tendency term - - For temperature: computes - $Q_{\text{direct}} = Q_{\text{latent}} + Q_{\text{sensible}} + Q_{\text{lw,up}} + Q_{\text{lw,down}} + Q_{\text{ice}} + Q_{\text{sw}}$ + - For temperature: adds the direct heat fluxes + $Q_{\text{latent}} + Q_{\text{sensible}} + Q_{\text{lw,up}} + Q_{\text{lw,down}} + Q_{\text{ice}} + Q_{\text{sw}}$ +, the phase change and enthalpy of added mass $(\text{RainFlux} + \text{RiverRunoffFlux}) c^0_{p,sw} C_T^{\text{top}} + (\text{SnowFlux} + \text{IceRunoffFlux})(c^0_{p,sw} C_T^{\text{frz}} - L_{\text{ice}})$, + (where $C_T^{\text{frz}}$ is from EOS at top-layer salinity and pressure), and scales by $H_{\text{FluxFac}}$. - - For temperature: when `SfcThicknessForcing` is enabled, also adds - mass-flux enthalpy - $(\text{RainFlux} + \text{RiverRunoffFlux}) c^0_{p,sw} C_T^{\text{top}} + (\text{SnowFlux} + \text{IceRunoffFlux})(c^0_{p,sw} C_T^{\text{frz}} - L_{\text{ice}})$, - where $C_T^{\text{frz}}$ is from EOS at top-layer salinity and pressure. - For salinity: applies salt flux with unit conversion: $\text{SeaIceSaltFlux} \times S_{\text{FluxFac}}$ - Applied only at surface layer using `MinLayerCell` - Uses tracer index validation to apply to specific tracers only @@ -95,8 +93,6 @@ the surface layer pseudo-thickness. - `Omega.Tendencies.SfcThicknessForcingTendencyEnable` - gates execution of coupled flux thickness kernel - controls freshwater and salt flux forcing on sea surface height - - also gates whether mass-flux enthalpy terms are added in tracer - temperature forcing - `Omega.Tendencies.SfcTracerForcingTendencyEnable` - gates execution of coupled flux tracer kernel - controls direct heat flux forcing on temperature and salt flux forcing on salinity diff --git a/components/omega/doc/userGuide/Forcing.md b/components/omega/doc/userGuide/Forcing.md index 34a964a61510..e08754ee2651 100644 --- a/components/omega/doc/userGuide/Forcing.md +++ b/components/omega/doc/userGuide/Forcing.md @@ -60,10 +60,6 @@ Omega: - `Tendencies.SfcThicknessForcingTendencyEnable`: enables coupled freshwater and salt flux forcing on thickness - `Tendencies.SfcTracerForcingTendencyEnable`: enables coupled heat and salt flux forcing on tracers -When `Tendencies.SfcTracerForcingTendencyEnable` is enabled, direct surface heat -flux terms are always applied to temperature. Additional mass-flux enthalpy -terms (rain/river and snow/ice runoff) are applied only when -`Tendencies.SfcThicknessForcingTendencyEnable` is also enabled. ### Required input fields @@ -98,19 +94,10 @@ by the equivalent `ocn_comp_mct.F`. - Coupled fluxes are applied only at the surface layer (top active layer) for each cell. - Pseudo-thickness tendency is computed from the (six) freshwater mass fluxes and the salt mass flux `SeaIceSaltFlux`, converted to a pseudo-thickness change. -- Temperature tendency is computed from direct heat flux plus optional +- Temperature tendency is computed from direct heat flux plus mass-flux enthalpy terms, converted to conservative-temperature tendency via $H_{\text{FluxFac}} = 1.0 / (\rho_{sw} c^0_{p,sw})$ where $c^0_{p,sw}$ is the reference - specific heat of seawater defined by TEOS-10. - The direct heat part is - $Q_{\text{direct}} = Q_{\text{latent}} + Q_{\text{sensible}} + Q_{\text{lw,up}} + Q_{\text{lw,down}} + Q_{\text{ice}} + Q_{\text{sw}}$. - The mass-flux enthalpy part is - $Q_{\text{mass}} = (\text{RainFlux} + \text{RiverRunoffFlux}) c^0_{p,sw} C_T^{\text{top}} + (\text{SnowFlux} + \text{IceRunoffFlux})(c^0_{p,sw} C_T^{\text{frz}} - L_{\text{ice}})$, - where $C_T^{\text{frz}}$ is computed from EOS at top-layer salinity and pressure. - The applied heat flux is - $Q_{\text{direct}} + Q_{\text{mass}}$ when - `Tendencies.SfcThicknessForcingTendencyEnable` is true, and - $Q_{\text{direct}}$ otherwise. + specific heat of seawater defined by TEOS-10. The enthalpy associated with mass fluxes is currently hard-coded to SST for liquid fluxes and the freezing temperature for solid fluxes (which are melted using a constant latent heat of fusion). Note that the enthalpy of liquid meltwater from sea ice is already included in `SeaIceHeatFlux`. - Salinity tendency from `SeaIceSaltFlux` is scaled by $S_{\text{FluxFac}} = 1.0e3 / \rho_{sw}$ to account for unit conversion from kg/(m²·s) to salinity units (g/kg). From 5ae42d31cc0712cb766a78ada31d71dc2b89fb12 Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Mon, 6 Jul 2026 11:33:03 -0700 Subject: [PATCH 09/36] correction to pressure units and ctest --- components/omega/src/ocn/TendencyTerms.h | 12 ++--- components/omega/test/ocn/TendenciesTest.cpp | 49 ++++++-------------- 2 files changed, 21 insertions(+), 40 deletions(-) diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index 77b8ce93df04..b0a6591add83 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -434,12 +434,12 @@ class SfcTracerForcingOnCell { } if (TempIndex >= 0) { - const Real PTop = PressureMid(ICell, KTop); - const Real SaTop = SaltIndex >= 0 - ? TracerCell(SaltIndex, ICell, KTop) - : 0.0_Real; // not sure we want zero here? - const Real CtFrz = EosImpl.calcCtFreezing(SaTop, PTop, 0.0_Real); - const Real CtTop = TracerCell(TempIndex, ICell, KTop); + const Real PTopDb = PressureMid(ICell, KTop) * Pa2Db; + const Real SaTop = SaltIndex >= 0 + ? TracerCell(SaltIndex, ICell, KTop) + : 0.0_Real; // not sure we want zero here? + const Real CtFrz = EosImpl.calcCtFreezing(SaTop, PTopDb, 0.0_Real); + const Real CtTop = TracerCell(TempIndex, ICell, KTop); // Heat tendencies are due to direct heat fluxes + enthalpy fluxes // The enthalpy of liquid water is assumed to be: diff --git a/components/omega/test/ocn/TendenciesTest.cpp b/components/omega/test/ocn/TendenciesTest.cpp index 1045701339dd..9114d4c7a116 100644 --- a/components/omega/test/ocn/TendenciesTest.cpp +++ b/components/omega/test/ocn/TendenciesTest.cpp @@ -309,10 +309,12 @@ int testTendencies() { DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; // Test surface tracer forcing with enthalpy terms - Err += testSfcTracerForcing(); + const int TracerForcingErr = testSfcTracerForcing(); + Err += TracerForcingErr; // Test surface thickness forcing with freshwater terms - Err += testSfcThicknessForcing(); + const int ThicknessForcingErr = testSfcThicknessForcing(); + Err += ThicknessForcingErr; // check that everything got computed correctly int NCellsOwned = Mesh->NCellsOwned; @@ -344,7 +346,6 @@ int testTendencies() { } Tendencies::clear(); - return Err; } @@ -491,6 +492,7 @@ int testSfcTracerForcing() { deepCopy(TracerTendBaseH, DefTendencies->TracerTend); const Real BaselineTempTend = TracerTendBaseH(TempIndex, ICellTest, KTop); const Real BaselineSaltTend = TracerTendBaseH(SaltIndex, ICellTest, KTop); + // Now enable SfcTracerForcing and compute again DefTendencies->SfcTracerForcing.Enabled = true; @@ -498,19 +500,13 @@ int testSfcTracerForcing() { ThickTimeLevel, VelTimeLevel, TracerTimeLevel, Time, Interval); - // Build two reference expectations for temperature tendency: - // 1) fixed estimate (expected to fail under strict tolerance), - // 2) TEOS-10 freezing CT (expected to pass under strict tolerance). - const Real CtFrzEstimate = -2.0_Real; - const Real ExpectedTempTendEstimate = - (TestSensibleHeat + TestRain * Cp0Sw * CtTopValue + - TestSnow * (Cp0Sw * CtFrzEstimate - LatIce)) * - HFluxFac; + // Build a reference expectations for temperature tendency: + // using TEOS-10 freezing CT (expected to pass under strict tolerance). HostArray2DReal PressureMidH = createHostMirrorCopy(VCoord->PressureMid); deepCopy(PressureMidH, VCoord->PressureMid); - const Real PTop = PressureMidH(ICellTest, KTop); - const Real CtFrzTeos = EosInst->calcCtFreezing(SaTopValue, PTop, 0.0_Real); + const Real PTopDb = PressureMidH(ICellTest, KTop) * Pa2Db; + const Real CtFrzTeos = EosInst->calcCtFreezing(SaTopValue, PTopDb, 0.0_Real); const Real ExpectedTempTendTeos = (TestSensibleHeat + TestRain * Cp0Sw * CtTopValue + TestSnow * (Cp0Sw * CtFrzTeos - LatIce)) * @@ -530,20 +526,6 @@ int testSfcTracerForcing() { constexpr Real RelTol = 1.0e-10_Real; constexpr Real AbsTol = 1.0e-12_Real; // flux precision is ~e-15 - // Expected-fail check with fixed CtFrz estimate. - if (!isApprox(ComputedTempTend, ExpectedTempTendEstimate, RelTol, AbsTol)) { - LOG_INFO( - "TendenciesTest: expected tempTend fail because CtFrzEstimate != EOS " - "CtFrz - PASS"); - LOG_INFO("tempTend Expected: {}, Computed: {}, Diff: {}", - ExpectedTempTendEstimate, ComputedTempTend, - Kokkos::abs(ComputedTempTend - ExpectedTempTendEstimate)); - } else { - Err++; - LOG_ERROR("TendenciesTest: CtFrz estimate unexpectedly matched strict " - "reference - FAIL"); - } - // Expected-pass check with TEOS freezing CT reference. if (!isApprox(ComputedTempTend, ExpectedTempTendTeos, RelTol, AbsTol)) { Err++; @@ -559,9 +541,9 @@ int testSfcTracerForcing() { if (!isApprox(ComputedSaltTend, ExpectedSaltTend, RelTol, AbsTol)) { Err++; LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL"); - LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedSaltTend, - ComputedSaltTend, - Kokkos::abs(ComputedSaltTend - ExpectedSaltTend)); + LOG_INFO(" Expected: {}, Computed: {}, Diff: {}", ExpectedSaltTend, + ComputedSaltTend, + Kokkos::abs(ComputedSaltTend - ExpectedSaltTend)); } else { LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS"); } @@ -651,7 +633,6 @@ int testSfcThicknessForcing() { LocSeaIceFreshWater(ICellTest) = TestSeaIceFreshWater; LocSeaIceSaltFlux(ICellTest) = TestSeaIceSaltFlux; }); - DefForcing->computeAll(); const bool OrigSfcStressEnabled = DefTendencies->SfcStressForcing.Enabled; @@ -721,9 +702,9 @@ int testSfcThicknessForcing() { if (!isApprox(ComputedThickTend, ExpectedThickTend, RelTol, AbsTol)) { Err++; LOG_ERROR("TendenciesTest: SfcThicknessForcing thickness tendency FAIL"); - LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedThickTend, - ComputedThickTend, - Kokkos::abs(ComputedThickTend - ExpectedThickTend)); + LOG_INFO(" Expected: {}, Computed: {}, Diff: {}", ExpectedThickTend, + ComputedThickTend, + Kokkos::abs(ComputedThickTend - ExpectedThickTend)); } else { LOG_INFO("TendenciesTest: SfcThicknessForcing thickness tendency PASS"); } From f4ba88c93adb1bc1388513ca52bbe6fa6a4f4c1a Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Mon, 13 Jul 2026 13:00:31 -0700 Subject: [PATCH 10/36] update due to fill values and review comments --- components/omega/doc/devGuide/Forcing.md | 14 +-- components/omega/doc/userGuide/Forcing.md | 16 +-- .../omega/doc/userGuide/TendencyTerms.md | 2 +- components/omega/src/ocn/Forcing.cpp | 30 +----- .../src/ocn/forcingVars/TracerForcingVars.cpp | 101 +++++++++--------- 5 files changed, 68 insertions(+), 95 deletions(-) diff --git a/components/omega/doc/devGuide/Forcing.md b/components/omega/doc/devGuide/Forcing.md index 869019e2469e..2a05a96b4c99 100644 --- a/components/omega/doc/devGuide/Forcing.md +++ b/components/omega/doc/devGuide/Forcing.md @@ -6,7 +6,7 @@ This page describes design and implementation details for forcing-related pathways in Omega, currently this includes: - Surface stress forcing (e.g. wind stress) -- Surface flux forcing (actively coupled or data-forced) +- Surface thickness and tracer flux forcing (actively coupled or data-forced) - Surface tracer restoring (soon to be ported) ## Surface stress forcing design @@ -38,9 +38,9 @@ pathways in Omega, currently this includes: - `Omega.Tendencies.SfcStressForcingTendencyEnable` - gates execution of surface stress forcing tendency kernel -## Surface flux forcing design +## Surface thickness and tracer flux forcing design -### Surface flux forcing data flow +### Surface thickness and tracer flux forcing data flow **Thickness equation pathway:** @@ -62,16 +62,16 @@ the surface layer pseudo-thickness. - `SeaIceSaltFlux` 2. `Forcing` stores the flux fields in `TracerForcingVars` 3. The tendency term `SfcTracerForcingOnCell` converts the summed external heat fluxes to a conservative-temperature tendency, - and applies the external sea-ice salt flux to salinity (g/kg) in the surface layer. + and applies the external sea-ice salt flux to the top layer salt content thus impacting salinity. -### Surface flux forcing key classes/components +### Surface thickness and tracer flux forcing key classes/components - `TracerForcingVars` - Stores 13 coupled flux cell-centered fields: 6 freshwater fluxes, 6 heat fluxes, and 1 salt flux component - Fields initialized to zero and registered in `Forcing` field group - `SfcThicknessForcingOnCell` tendency term - - Computes freshwater flux contribution: $\sum (\text{SnowFlux} + \text{RainFlux} + \text{EvaporationFlux} + \text{SeaIceFreshWaterFlux} + \text{IceRunoffFlux} + \text{RiverRunoffFlux} + \text{SeaIceSaltFlux}) / \rho_{sw}$ + - Computes the layer mass contribution (converted to pseudo-thickness): $\sum (\text{SnowFlux} + \text{RainFlux} + \text{EvaporationFlux} + \text{SeaIceFreshWaterFlux} + \text{IceRunoffFlux} + \text{RiverRunoffFlux} + \text{SeaIceSaltFlux}) / \rho_{sw}$ - Applied only at surface layer (top active layer) using `MinLayerCell` - `SfcTracerForcingOnCell` tendency term - For temperature: adds the direct heat fluxes @@ -88,7 +88,7 @@ the surface layer pseudo-thickness. - Calls `SfcThicknessForcingOnCell` in `computePseudoThicknessTendenciesOnly` - Calls `SfcTracerForcingOnCell` in `computeTracerTendenciesOnly` after surface tracer restoring -### Surface flux forcing config coupling +### Surface thickness and tracer flux forcing config coupling - `Omega.Tendencies.SfcThicknessForcingTendencyEnable` - gates execution of coupled flux thickness kernel diff --git a/components/omega/doc/userGuide/Forcing.md b/components/omega/doc/userGuide/Forcing.md index e08754ee2651..85ecdfe1e1d8 100644 --- a/components/omega/doc/userGuide/Forcing.md +++ b/components/omega/doc/userGuide/Forcing.md @@ -5,7 +5,7 @@ This page documents the user-facing configuration and behavior for current forcing in Omega: - Surface stress forcing (e.g. wind stress) -- Coupled flux forcing +- Coupled tracer flux forcing (mass, energy and salt) - Surface tracer restoring ## Surface stress forcing @@ -40,15 +40,15 @@ Surface stress forcing uses surface stress input fields: These are stored in forcing variables and used to form edge-normal stress (`NormalStressEdge`) that enters momentum tendencies. -## Surface flux forcing +## Surface thickness and tracer flux forcing -Surface flux forcing applies ocean-atmosphere and ocean-sea ice fluxes from the other model +Surface thickness and tracer flux forcing applies ocean-atmosphere and ocean-sea ice fluxes from the other model components (atmosphere, sea ice) to the thickness and tracer equations. This enables the ocean to respond to heat, freshwater, and salt exchanges at the surface. These fluxes can be from data or (active) coupled components. -### Surface flux forcing configuration +### Surface thickness and tracer flux forcing configuration -Surface flux forcing is controlled by two configuration flags: +Surface thickness and tracer flux forcing is controlled by two configuration flags: ```yaml Omega: @@ -63,13 +63,13 @@ Omega: ### Required input fields -Coupled flux forcing uses 13 auxiliary fields organized by type: +Coupled tracer flux forcing uses 13 auxiliary fields organized by type: **Freshwater mass fluxes (kg m⁻² s⁻¹):** - `SnowFlux`: precipitation from snow - `RainFlux`: precipitation from rain - `EvaporationFlux`: evaporative water loss -- `SeaIceFreshWaterFlux`: freshwater input from sea-ice melt or formation +- `SeaIceFreshWaterFlux`: freshwater mass flux from sea-ice melt or formation - `IceRunoffFlux`: runoff from land ice - `RiverRunoffFlux`: runoff from rivers @@ -78,7 +78,7 @@ Coupled flux forcing uses 13 auxiliary fields organized by type: - `SensibleHeatFlux`: sensible heat transfer - `LongWaveHeatFluxUp`: upward longwave radiation - `LongWaveHeatFluxDown`: downward longwave radiation -- `SeaIceHeatFlux`: heat from sea-ice interaction +- `SeaIceHeatFlux`: heat/energy from sea-ice interaction (incl. enthalpy of meltwater) - `ShortWaveHeatFlux`: shortwave (solar) radiation **Salt mass flux (kg m⁻² s⁻¹):** diff --git a/components/omega/doc/userGuide/TendencyTerms.md b/components/omega/doc/userGuide/TendencyTerms.md index 7847f0cfb357..c2cd4b3b4327 100644 --- a/components/omega/doc/userGuide/TendencyTerms.md +++ b/components/omega/doc/userGuide/TendencyTerms.md @@ -147,5 +147,5 @@ Tracer higer order convergence example of a cosine bell advected on a sphere sho ## See Also Additional information on forcing, including surface stress forcing, -surface flux forcing, and surface tracer restoring, is detailed in +surface thickness and tracer flux forcing, and surface tracer restoring, is detailed in [](omega-user-forcing). diff --git a/components/omega/src/ocn/Forcing.cpp b/components/omega/src/ocn/Forcing.cpp index 2c51af33ae7d..95ba94b3f305 100644 --- a/components/omega/src/ocn/Forcing.cpp +++ b/components/omega/src/ocn/Forcing.cpp @@ -157,7 +157,8 @@ void Forcing::computeSfcStressForcingOnEdge() const { Pacer::stop("Forcing:edge1", 2); } -// Exchange halo for surface stress cell fields. +// Exchange halo for surface stress cell fields. Only needed for variables that +// need information beyond cell-centered values. I4 Forcing::exchangeHalo() const { I4 Err = 0; @@ -166,33 +167,6 @@ I4 Forcing::exchangeHalo() const { Err += MeshHalo->exchangeFullArrayHalo(SfcStressForcing.MeridStressCell, OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SnowFluxCell, OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.RainFluxCell, OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.EvaporationFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo( - TracerForcing.SeaIceFreshWaterFluxCell, OnCell); - Err += - MeshHalo->exchangeFullArrayHalo(TracerForcing.IceRunoffFluxCell, OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.RiverRunoffFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.LatentHeatFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SensibleHeatFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.LongWaveHeatFluxUpCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo( - TracerForcing.LongWaveHeatFluxDownCell, OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SeaIceHeatFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.ShortWaveHeatFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SeaIceSaltFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SurfInsituTemperature, - OnCell); - return Err; } diff --git a/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp b/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp index 38016216a410..a00b4e9ee95c 100644 --- a/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp +++ b/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp @@ -24,7 +24,7 @@ TracerForcingVars::TracerForcingVars(const std::string &Suffix, Mesh->NCellsSize), SeaIceHeatFluxCell("seaIceHeatFlux" + Suffix, Mesh->NCellsSize), ShortWaveHeatFluxCell("shortWaveHeatFlux" + Suffix, Mesh->NCellsSize), - SeaIceSaltFluxCell("seaIceSalinityFlux" + Suffix, Mesh->NCellsSize), + SeaIceSaltFluxCell("seaIceSaltFlux" + Suffix, Mesh->NCellsSize), SurfInsituTemperature("surfInsituTemperature" + Suffix, Mesh->NCellsSize) { deepCopy(SnowFluxCell, 0.0_Real); @@ -44,8 +44,7 @@ TracerForcingVars::TracerForcingVars(const std::string &Suffix, } void TracerForcingVars::registerFields(const std::string &MeshName) const { - const Real FillValue = -9.99e30; - const int NDims = 1; + const int NDims = 1; std::vector DimNames(NDims); std::string DimSuffix; @@ -57,66 +56,66 @@ void TracerForcingVars::registerFields(const std::string &MeshName) const { DimNames[0] = "NCells" + DimSuffix; - auto SnowFluxField = Field::create( - SnowFluxCell.label(), "snow freshwater flux", "kg m^-2 s^-1", "", - std::numeric_limits::lowest(), std::numeric_limits::max(), - FillValue, NDims, DimNames); - auto RainFluxField = Field::create( - RainFluxCell.label(), "rain freshwater flux", "kg m^-2 s^-1", "", - std::numeric_limits::lowest(), std::numeric_limits::max(), - FillValue, NDims, DimNames); - auto EvaporationFluxField = Field::create( - EvaporationFluxCell.label(), "evaporation freshwater flux", - "kg m^-2 s^-1", "", std::numeric_limits::lowest(), - std::numeric_limits::max(), FillValue, NDims, DimNames); + auto SnowFluxField = + Field::create(SnowFluxCell.label(), "snow freshwater flux", + "kg m^-2 s^-1", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); + auto RainFluxField = + Field::create(RainFluxCell.label(), "rain freshwater flux", + "kg m^-2 s^-1", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); + auto EvaporationFluxField = + Field::create(EvaporationFluxCell.label(), "evaporation freshwater flux", + "kg m^-2 s^-1", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); auto SeaIceFreshWaterFluxField = Field::create( SeaIceFreshWaterFluxCell.label(), "sea-ice freshwater flux", "kg m^-2 s^-1", "", std::numeric_limits::lowest(), - std::numeric_limits::max(), FillValue, NDims, DimNames); - auto IceRunoffFluxField = Field::create( - IceRunoffFluxCell.label(), "ice runoff freshwater flux", "kg m^-2 s^-1", - "", std::numeric_limits::lowest(), - std::numeric_limits::max(), FillValue, NDims, DimNames); + std::numeric_limits::max(), NDims, DimNames); + auto IceRunoffFluxField = + Field::create(IceRunoffFluxCell.label(), "ice runoff freshwater flux", + "kg m^-2 s^-1", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); auto RiverRunoffFluxField = Field::create( RiverRunoffFluxCell.label(), "river runoff freshwater flux", "kg m^-2 s^-1", "", std::numeric_limits::lowest(), - std::numeric_limits::max(), FillValue, NDims, DimNames); - - auto LatentHeatFluxField = Field::create( - LatentHeatFluxCell.label(), "latent heat flux", "W m^-2", "", - std::numeric_limits::lowest(), std::numeric_limits::max(), - FillValue, NDims, DimNames); - auto SensibleHeatFluxField = Field::create( - SensibleHeatFluxCell.label(), "sensible heat flux", "W m^-2", "", - std::numeric_limits::lowest(), std::numeric_limits::max(), - FillValue, NDims, DimNames); + std::numeric_limits::max(), NDims, DimNames); + + auto LatentHeatFluxField = + Field::create(LatentHeatFluxCell.label(), "latent heat flux", "W m^-2", + "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); + auto SensibleHeatFluxField = + Field::create(SensibleHeatFluxCell.label(), "sensible heat flux", + "W m^-2", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); auto LongWaveHeatFluxUpField = Field::create( LongWaveHeatFluxUpCell.label(), "upward longwave heat flux", "W m^-2", "", std::numeric_limits::lowest(), - std::numeric_limits::max(), FillValue, NDims, DimNames); + std::numeric_limits::max(), NDims, DimNames); auto LongWaveHeatFluxDownField = Field::create( LongWaveHeatFluxDownCell.label(), "downward longwave heat flux", "W m^-2", "", std::numeric_limits::lowest(), - std::numeric_limits::max(), FillValue, NDims, DimNames); - auto SeaIceHeatFluxField = Field::create( - SeaIceHeatFluxCell.label(), "sea-ice heat flux", "W m^-2", "", - std::numeric_limits::lowest(), std::numeric_limits::max(), - FillValue, NDims, DimNames); - auto ShortWaveHeatFluxField = Field::create( - ShortWaveHeatFluxCell.label(), "shortwave heat flux", "W m^-2", "", - std::numeric_limits::lowest(), std::numeric_limits::max(), - FillValue, NDims, DimNames); - - auto SeaIceSaltFluxField = Field::create( - SeaIceSaltFluxCell.label(), "sea-ice salt flux", "kg m^-2 s^-1", "", - std::numeric_limits::lowest(), std::numeric_limits::max(), - FillValue, NDims, DimNames); - - auto SurfInsituTemperatureField = Field::create( - SurfInsituTemperature.label(), - "insitu (potential) temperature at surface layer", "degrees Celsius", "", - std::numeric_limits::lowest(), std::numeric_limits::max(), - FillValue, NDims, DimNames); + std::numeric_limits::max(), NDims, DimNames); + auto SeaIceHeatFluxField = + Field::create(SeaIceHeatFluxCell.label(), "sea-ice heat flux", "W m^-2", + "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); + auto ShortWaveHeatFluxField = + Field::create(ShortWaveHeatFluxCell.label(), "shortwave heat flux", + "W m^-2", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); + + auto SeaIceSaltFluxField = + Field::create(SeaIceSaltFluxCell.label(), "sea-ice salt flux", + "kg m^-2 s^-1", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); + + auto SurfInsituTemperatureField = + Field::create(SurfInsituTemperature.label(), + "insitu (potential) temperature at surface layer", + "degrees Celsius", "", std::numeric_limits::lowest(), + std::numeric_limits::max(), NDims, DimNames); FieldGroup::addFieldToGroup(SnowFluxCell.label(), "Forcing"); FieldGroup::addFieldToGroup(RainFluxCell.label(), "Forcing"); From 0ca27d5021817834e2721c44ad61049e177efb71 Mon Sep 17 00:00:00 2001 From: Alice Barthel Date: Mon, 13 Jul 2026 14:41:04 -0700 Subject: [PATCH 11/36] resolve memory issue on GPUs --- components/omega/test/ocn/TendenciesTest.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/omega/test/ocn/TendenciesTest.cpp b/components/omega/test/ocn/TendenciesTest.cpp index 9114d4c7a116..fde3863c3dbb 100644 --- a/components/omega/test/ocn/TendenciesTest.cpp +++ b/components/omega/test/ocn/TendenciesTest.cpp @@ -373,9 +373,9 @@ int testSfcTracerForcing() { // Set up single test cell at top layer const I4 ICellTest = 0; - const I4 KTop = VCoord->MinLayerCell(ICellTest); + const I4 KTop = VCoord->MinLayerCellH(ICellTest); - if (KTop > VCoord->MaxLayerCell(ICellTest)) { + if (KTop > VCoord->MaxLayerCellH(ICellTest)) { LOG_ERROR("TendenciesTest: Test cell has no layers"); return -1; } @@ -579,9 +579,9 @@ int testSfcThicknessForcing() { // Set up single test cell at top layer const I4 ICellTest = 0; - const I4 KTop = VCoord->MinLayerCell(ICellTest); + const I4 KTop = VCoord->MinLayerCellH(ICellTest); - if (KTop > VCoord->MaxLayerCell(ICellTest)) { + if (KTop > VCoord->MaxLayerCellH(ICellTest)) { LOG_ERROR("TendenciesTest: Test cell has no layers for thickness test"); return -1; } From 251dc550d05b349374d6238c944540abc6410ac6 Mon Sep 17 00:00:00 2001 From: Kat Smith Date: Thu, 16 Jul 2026 13:08:27 -0700 Subject: [PATCH 12/36] inlines eos::calcPtFromCt in header with kokkos_function to fix gpu warning --- components/omega/src/ocn/Eos.cpp | 8 -------- components/omega/src/ocn/Eos.h | 7 ++++++- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/components/omega/src/ocn/Eos.cpp b/components/omega/src/ocn/Eos.cpp index 49ea504fb84c..9a3428f537f7 100644 --- a/components/omega/src/ocn/Eos.cpp +++ b/components/omega/src/ocn/Eos.cpp @@ -327,14 +327,6 @@ void Eos::computeBruntVaisalaFreqSq(const Array2DReal &ConservTemp, } } -Real Eos::calcPtFromCt(const Real &Sa, const Real &Ct) const { - if (EosChoice == EosType::Teos10Eos) { - return ComputeSpecVolTeos10.calcPtFromCt(Sa, Ct); - } - - return Ct; -} - Real Eos::calcCtFromPt(const Real &Sa, const Real &Pt) const { if (EosChoice == EosType::Teos10Eos) { return ComputeSpecVolTeos10.calcCtFromPt(Sa, Pt); diff --git a/components/omega/src/ocn/Eos.h b/components/omega/src/ocn/Eos.h index 2b3d6d78f462..0911910a79f4 100644 --- a/components/omega/src/ocn/Eos.h +++ b/components/omega/src/ocn/Eos.h @@ -756,7 +756,12 @@ class Eos { const Array2DReal &SpecVol); /// Convert Conservative Temperature to potential temperature - Real calcPtFromCt(const Real &Sa, const Real &Ct) const; + KOKKOS_FUNCTION Real calcPtFromCt(const Real &Sa, const Real &Ct) const { + if (EosChoice == EosType::Teos10Eos) { + return ComputeSpecVolTeos10.calcPtFromCt(Sa, Ct); + } + return Ct; + } /// Convert potential temperature to Conservative Temperature Real calcCtFromPt(const Real &Sa, const Real &Pt) const; From 5e0445f4af29dd509ffa6eff921b1a7d05254da8 Mon Sep 17 00:00:00 2001 From: Kat Smith Date: Mon, 20 Jul 2026 09:38:07 -0700 Subject: [PATCH 13/36] adds linear and constant eos options to thermal forcing --- components/omega/src/ocn/Eos.cpp | 22 -- components/omega/src/ocn/Eos.h | 40 +++- components/omega/src/ocn/TendencyTerms.cpp | 2 +- components/omega/src/ocn/TendencyTerms.h | 4 +- components/omega/test/ocn/TendenciesTest.cpp | 208 ++++++++++++++++++- 5 files changed, 234 insertions(+), 42 deletions(-) diff --git a/components/omega/src/ocn/Eos.cpp b/components/omega/src/ocn/Eos.cpp index 9a3428f537f7..ba36a2bb61ac 100644 --- a/components/omega/src/ocn/Eos.cpp +++ b/components/omega/src/ocn/Eos.cpp @@ -327,28 +327,6 @@ void Eos::computeBruntVaisalaFreqSq(const Array2DReal &ConservTemp, } } -Real Eos::calcCtFromPt(const Real &Sa, const Real &Pt) const { - if (EosChoice == EosType::Teos10Eos) { - return ComputeSpecVolTeos10.calcCtFromPt(Sa, Pt); - } - - return Pt; -} - -Real Eos::calcCtFreezing(const Real Sa, const Real P, - const Real SaturationFract) const { - if (EosChoice == EosType::Teos10Eos) { - return ComputeSpecVolTeos10.calcCtFreezing(Sa, P, SaturationFract); - } - - ABORT_ERROR("Eos::calcCtFreezing: CT freezing temperature is only " - "implemented for TEOS-10. Support for the current EOS " - "choice has not yet been developed."); - // most likely I'd implement a polynomial here for non-teos10 e.g. - // return 0.0 - 0.0575 * Sa + 1.710523e-3 * sqrt(Sa^3) - 2.154996e-4 * Sa^2 - return 0.0; -} - /// Define IO fields and metadata for output void Eos::defineFields() { diff --git a/components/omega/src/ocn/Eos.h b/components/omega/src/ocn/Eos.h index 0911910a79f4..61d5171a2031 100644 --- a/components/omega/src/ocn/Eos.h +++ b/components/omega/src/ocn/Eos.h @@ -756,6 +756,9 @@ class Eos { const Array2DReal &SpecVol); /// Convert Conservative Temperature to potential temperature + /// For TEOS-10, uses the TEOS-10 polynomial + /// For other EOS choices, conservative temperature is equal to potential + /// temperature KOKKOS_FUNCTION Real calcPtFromCt(const Real &Sa, const Real &Ct) const { if (EosChoice == EosType::Teos10Eos) { return ComputeSpecVolTeos10.calcPtFromCt(Sa, Ct); @@ -763,14 +766,37 @@ class Eos { return Ct; } - /// Convert potential temperature to Conservative Temperature - Real calcCtFromPt(const Real &Sa, const Real &Pt) const; + /// Convert potential temperature to Conservative Temperature. + /// For TEOS-10, uses the TEOS-10 polynomial + /// For other EOS choices, potential temperature equals conservative + /// temperature + KOKKOS_FUNCTION Real calcCtFromPt(const Real &Sa, const Real &Pt) const { + if (EosChoice == EosType::Teos10Eos) { + return ComputeSpecVolTeos10.calcCtFromPt(Sa, Pt); + } + return Pt; + } - /// Calculate freezing Conservative Temperature for TEOS-10. - /// Aborts if EOS is not TEOS-10: CT freezing is not yet implemented - /// for other equation-of-state choices. - Real calcCtFreezing(const Real Sa, const Real P, - const Real SaturationFract) const; + /// Calculate freezing Conservative Temperature. + /// For TEOS-10, uses the Roquet et al. 75-term polynomial. + /// For LinearEos, uses a simple linear salinity-dependent approximation + /// consistent with the linear EOS philosophy (Sa in g/kg converted to PSU). + /// For ConstantEos, returns a constant approximate ocean freezing point. + KOKKOS_FUNCTION Real calcCtFreezing(const Real Sa, const Real P, + const Real SaturationFract) const { + if (EosChoice == EosType::Teos10Eos) { + return ComputeSpecVolTeos10.calcCtFreezing(Sa, P, SaturationFract); + } + if (EosChoice == EosType::LinearEos) { + // Linear salinity-dependent freezing point; coefficient -0.054 + // degC/PSU with absolute-to-practical salinity conversion (g/kg -> + // PSU). + constexpr Real Coeff = -0.054_Real; + return Coeff * Sa / Psu2Gpkg; + } + // ConstantEos: constant approximate ocean freezing point (degC) + return -1.9_Real; + } /// Initialize EOS from config and mesh static void init(); diff --git a/components/omega/src/ocn/TendencyTerms.cpp b/components/omega/src/ocn/TendencyTerms.cpp index 37bfe6ee0500..65df82c4eb00 100644 --- a/components/omega/src/ocn/TendencyTerms.cpp +++ b/components/omega/src/ocn/TendencyTerms.cpp @@ -83,7 +83,7 @@ SfcTracerForcingOnCell::SfcTracerForcingOnCell(const HorzMesh *Mesh, const Eos *EosInst) : TempIndex(TempTracerIndex), SaltIndex(SaltTracerIndex), MinLayerCell(VCoord->MinLayerCell), MaxLayerCell(VCoord->MaxLayerCell), - EosImpl(VCoord) {} + EosImpl(EosInst) {} TracerHorzAdvOnCell::TracerHorzAdvOnCell(const HorzMesh *Mesh, const VertCoord *VCoord) diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index b0a6591add83..efd1e43a9698 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -438,7 +438,7 @@ class SfcTracerForcingOnCell { const Real SaTop = SaltIndex >= 0 ? TracerCell(SaltIndex, ICell, KTop) : 0.0_Real; // not sure we want zero here? - const Real CtFrz = EosImpl.calcCtFreezing(SaTop, PTopDb, 0.0_Real); + const Real CtFrz = EosImpl->calcCtFreezing(SaTop, PTopDb, 0.0_Real); const Real CtTop = TracerCell(TempIndex, ICell, KTop); // Heat tendencies are due to direct heat fluxes + enthalpy fluxes @@ -469,7 +469,7 @@ class SfcTracerForcingOnCell { I4 SaltIndex; Array1DI4 MinLayerCell; Array1DI4 MaxLayerCell; - Teos10Eos EosImpl; + const Eos *EosImpl; }; // Tracer horizontal advection term diff --git a/components/omega/test/ocn/TendenciesTest.cpp b/components/omega/test/ocn/TendenciesTest.cpp index fde3863c3dbb..01b0bff4cbdf 100644 --- a/components/omega/test/ocn/TendenciesTest.cpp +++ b/components/omega/test/ocn/TendenciesTest.cpp @@ -54,7 +54,8 @@ struct TestSetup { constexpr Geometry Geom = Geometry::Spherical; constexpr int NVertLayers = 60; -int testSfcTracerForcing(); +int testSfcTracerForcingTeos10(); +int testSfcTracerForcingLinear(); int testSfcThicknessForcing(); int initState() { @@ -308,9 +309,13 @@ int testTendencies() { DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; - // Test surface tracer forcing with enthalpy terms - const int TracerForcingErr = testSfcTracerForcing(); - Err += TracerForcingErr; + // Test surface tracer forcing with enthalpy terms (TEOS-10 CtFrz path) + const int TracerForcingTeos10Err = testSfcTracerForcingTeos10(); + Err += TracerForcingTeos10Err; + + // Test surface tracer forcing with LinearEos (linear CtFrz path) + const int TracerForcingLinearErr = testSfcTracerForcingLinear(); + Err += TracerForcingLinearErr; // Test surface thickness forcing with freshwater terms const int ThicknessForcingErr = testSfcThicknessForcing(); @@ -349,7 +354,7 @@ int testTendencies() { return Err; } -int testSfcTracerForcing() { +int testSfcTracerForcingTeos10() { int Err = 0; auto *VCoord = VertCoord::getDefault(); @@ -365,7 +370,8 @@ int testSfcTracerForcing() { const I4 SaltIndex = Tracers::IndxSalt; if (TempIndex < 0 || SaltIndex < 0) { - LOG_ERROR("TendenciesTest: Invalid tracer indices for SfcTracerForcing"); + LOG_ERROR( + "TendenciesTest: Invalid tracer indices for SfcTracerForcingTeos10"); return -1; } @@ -529,25 +535,207 @@ int testSfcTracerForcing() { // Expected-pass check with TEOS freezing CT reference. if (!isApprox(ComputedTempTend, ExpectedTempTendTeos, RelTol, AbsTol)) { Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcing temp tendency FAIL"); + LOG_ERROR("TendenciesTest: SfcTracerForcingTeos10 temp tendency FAIL"); LOG_ERROR(" with TEOS-CtFrz Expected: {}, Computed: {}, Diff: {}", ExpectedTempTendTeos, ComputedTempTend, Kokkos::abs(ComputedTempTend - ExpectedTempTendTeos)); } else { - LOG_INFO("TendenciesTest: SfcTracerForcing temp tendency PASS"); + LOG_INFO("TendenciesTest: SfcTracerForcingTeos10 temp tendency PASS"); } // Check salinity tendency if (!isApprox(ComputedSaltTend, ExpectedSaltTend, RelTol, AbsTol)) { Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL"); + LOG_ERROR("TendenciesTest: SfcTracerForcingTeos10 salt tendency FAIL"); LOG_INFO(" Expected: {}, Computed: {}, Diff: {}", ExpectedSaltTend, ComputedSaltTend, Kokkos::abs(ComputedSaltTend - ExpectedSaltTend)); } else { - LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS"); + LOG_INFO("TendenciesTest: SfcTracerForcingTeos10 salt tendency PASS"); + } + + DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; + DefTendencies->SfcThicknessForcing.Enabled = OrigSfcThicknessEnabled; + DefTendencies->SfcTracerForcing.Enabled = OrigSfcTracerEnabled; + DefTendencies->PseudoThicknessFluxDiv.Enabled = OrigPseudoThicknessDiv; + DefTendencies->PotentialVortHAdv.Enabled = OrigPotentialVortHAdv; + DefTendencies->KEGrad.Enabled = OrigKEGrad; + DefTendencies->VelocityDiffusion.Enabled = OrigVelocityDiffusion; + DefTendencies->VelocityHyperDiff.Enabled = OrigVelocityHyperDiff; + DefTendencies->TracerHorzAdv.Enabled = OrigTracerHorzAdv; + DefTendencies->TracerDiffusion.Enabled = OrigTracerDiffusion; + DefTendencies->TracerHyperDiff.Enabled = OrigTracerHyperDiff; + DefTendencies->SurfaceTracerRestoring.Enabled = OrigSurfaceTracerRestoring; + + return Err; +} + +// Tests the SfcTracerForcing path using LinearEos. The EosChoice is +// temporarily set to LinearEos so that calcCtFreezing uses the linear +// salinity-dependent approximation instead of the TEOS-10 polynomial. +// Snow flux is applied so the CtFrz term is exercised. +int testSfcTracerForcingLinear() { + int Err = 0; + + auto *VCoord = VertCoord::getDefault(); + auto *DefTendencies = Tendencies::getDefault(); + auto *State = OceanState::getDefault(); + auto *AuxState = AuxiliaryState::getDefault(); + auto *DefForcing = Forcing::getDefault(); + auto *EosInst = Eos::getInstance(); + + Array3DReal TracerArray = Tracers::getAll(0); + + const I4 TempIndex = Tracers::IndxTemp; + const I4 SaltIndex = Tracers::IndxSalt; + + if (TempIndex < 0 || SaltIndex < 0) { + LOG_ERROR("TendenciesTest: Invalid tracer indices for " + "SfcTracerForcingLinear"); + return -1; + } + + deepCopy(DefTendencies->TracerTend, 0._Real); + + const I4 ICellTest = 0; + const I4 KTop = VCoord->MinLayerCellH(ICellTest); + + if (KTop > VCoord->MaxLayerCellH(ICellTest)) { + LOG_ERROR("TendenciesTest: Test cell has no layers"); + return -1; + } + + const Real CtTopValue = 10.0_Real; // conservative temperature (degC) + const Real SaTopValue = 34.0_Real; // absolute salinity (g/kg) + + OMEGA_SCOPE(LocTracerArray, TracerArray); + Kokkos::parallel_for( + "SetTestTracersForcingNonTeos10", 1, KOKKOS_LAMBDA(int i) { + LocTracerArray(TempIndex, ICellTest, KTop) = CtTopValue; + LocTracerArray(SaltIndex, ICellTest, KTop) = SaTopValue; + }); + + auto &SensibleHeatFlux = DefForcing->TracerForcing.SensibleHeatFluxCell; + auto &LatentHeatFlux = DefForcing->TracerForcing.LatentHeatFluxCell; + auto &LongWaveHeatFluxUp = DefForcing->TracerForcing.LongWaveHeatFluxUpCell; + auto &LongWaveHeatFluxDown = + DefForcing->TracerForcing.LongWaveHeatFluxDownCell; + auto &SeaIceHeatFlux = DefForcing->TracerForcing.SeaIceHeatFluxCell; + auto &ShortWaveHeatFlux = DefForcing->TracerForcing.ShortWaveHeatFluxCell; + auto &RainFlux = DefForcing->TracerForcing.RainFluxCell; + auto &RiverRunoffFlux = DefForcing->TracerForcing.RiverRunoffFluxCell; + auto &SnowFlux = DefForcing->TracerForcing.SnowFluxCell; + auto &IceRunoffFlux = DefForcing->TracerForcing.IceRunoffFluxCell; + auto &SeaIceSaltFlux = DefForcing->TracerForcing.SeaIceSaltFluxCell; + + deepCopy(SensibleHeatFlux, 0._Real); + deepCopy(LatentHeatFlux, 0._Real); + deepCopy(LongWaveHeatFluxUp, 0._Real); + deepCopy(LongWaveHeatFluxDown, 0._Real); + deepCopy(SeaIceHeatFlux, 0._Real); + deepCopy(ShortWaveHeatFlux, 0._Real); + deepCopy(RainFlux, 0._Real); + deepCopy(RiverRunoffFlux, 0._Real); + deepCopy(SnowFlux, 0._Real); + deepCopy(IceRunoffFlux, 0._Real); + deepCopy(SeaIceSaltFlux, 0._Real); + + // Only snow flux so the expected value depends solely on CtFrz. + const Real TestSnow = 5.0e-9_Real; // kg/m2/s + + OMEGA_SCOPE(LocSnowFlux, SnowFlux); + Kokkos::parallel_for( + "SetTestForcingNonTeos10", 1, + KOKKOS_LAMBDA(int i) { LocSnowFlux(ICellTest) = TestSnow; }); + + DefForcing->computeAll(); + + // Switch EOS to LinearEos so calcCtFreezing uses the linear approximation. + const EosType OrigEosChoice = EosInst->EosChoice; + EosInst->EosChoice = EosType::LinearEos; + + const bool OrigSfcStressEnabled = DefTendencies->SfcStressForcing.Enabled; + const bool OrigSfcThicknessEnabled = + DefTendencies->SfcThicknessForcing.Enabled; + const bool OrigSfcTracerEnabled = DefTendencies->SfcTracerForcing.Enabled; + const bool OrigPseudoThicknessDiv = + DefTendencies->PseudoThicknessFluxDiv.Enabled; + const bool OrigPotentialVortHAdv = DefTendencies->PotentialVortHAdv.Enabled; + const bool OrigKEGrad = DefTendencies->KEGrad.Enabled; + const bool OrigVelocityDiffusion = DefTendencies->VelocityDiffusion.Enabled; + const bool OrigVelocityHyperDiff = DefTendencies->VelocityHyperDiff.Enabled; + const bool OrigTracerHorzAdv = DefTendencies->TracerHorzAdv.Enabled; + const bool OrigTracerDiffusion = DefTendencies->TracerDiffusion.Enabled; + const bool OrigTracerHyperDiff = DefTendencies->TracerHyperDiff.Enabled; + const bool OrigSurfaceTracerRestoring = + DefTendencies->SurfaceTracerRestoring.Enabled; + + DefTendencies->SfcStressForcing.Enabled = false; + DefTendencies->SfcThicknessForcing.Enabled = false; + DefTendencies->SfcTracerForcing.Enabled = false; + DefTendencies->PseudoThicknessFluxDiv.Enabled = false; + DefTendencies->PotentialVortHAdv.Enabled = false; + DefTendencies->KEGrad.Enabled = false; + DefTendencies->VelocityDiffusion.Enabled = false; + DefTendencies->VelocityHyperDiff.Enabled = false; + DefTendencies->TracerHorzAdv.Enabled = false; + DefTendencies->TracerDiffusion.Enabled = false; + DefTendencies->TracerHyperDiff.Enabled = false; + DefTendencies->SurfaceTracerRestoring.Enabled = false; + + int ThickTimeLevel = 0; + int VelTimeLevel = 0; + int TracerTimeLevel = 0; + TimeInstant Time; + TimeInterval Interval(1., TimeUnits::Seconds); + + // Compute baseline (vertical advection always on) + DefTendencies->computeAllTendencies(State, AuxState, TracerArray, + ThickTimeLevel, VelTimeLevel, + TracerTimeLevel, Time, Interval); + + HostArray3DReal TracerTendBaseH = + createHostMirrorCopy(DefTendencies->TracerTend); + deepCopy(TracerTendBaseH, DefTendencies->TracerTend); + const Real BaselineTempTend = TracerTendBaseH(TempIndex, ICellTest, KTop); + + // Enable SfcTracerForcing and compute again + DefTendencies->SfcTracerForcing.Enabled = true; + + DefTendencies->computeAllTendencies(State, AuxState, TracerArray, + ThickTimeLevel, VelTimeLevel, + TracerTimeLevel, Time, Interval); + + // Expected CtFrz from LinearEos path in Eos::calcCtFreezing: + // Tf = -0.054 * Sa * (35.0/35.16504) (no pressure dependence) + const Real CtFrzNonTeos = + -0.054_Real * SaTopValue * (35.0_Real / 35.16504_Real); + + // HeatFlux = Snow * (Cp0Sw * CtFrz - LatIce) + const Real ExpectedTempTend = + TestSnow * (Cp0Sw * CtFrzNonTeos - LatIce) * HFluxFac; + + HostArray3DReal TracerTendH = + createHostMirrorCopy(DefTendencies->TracerTend); + deepCopy(TracerTendH, DefTendencies->TracerTend); + const Real ComputedTempTend = + TracerTendH(TempIndex, ICellTest, KTop) - BaselineTempTend; + + constexpr Real RelTol = 1.0e-10_Real; + constexpr Real AbsTol = 1.0e-12_Real; + + if (!isApprox(ComputedTempTend, ExpectedTempTend, RelTol, AbsTol)) { + Err++; + LOG_ERROR("TendenciesTest: SfcTracerForcingLinear temp tendency FAIL"); + LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedTempTend, + ComputedTempTend, + Kokkos::abs(ComputedTempTend - ExpectedTempTend)); + } else { + LOG_INFO("TendenciesTest: SfcTracerForcingLinear temp tendency PASS"); } + // Restore EOS choice and tendency flags + EosInst->EosChoice = OrigEosChoice; DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; DefTendencies->SfcThicknessForcing.Enabled = OrigSfcThicknessEnabled; DefTendencies->SfcTracerForcing.Enabled = OrigSfcTracerEnabled; From ec02e7ea5ab0091a91c2487fb7d1fe985cfd5e92 Mon Sep 17 00:00:00 2001 From: Kat Smith Date: Mon, 20 Jul 2026 09:48:41 -0700 Subject: [PATCH 14/36] adds notes to docs and adds suggestions from review --- components/omega/doc/devGuide/Forcing.md | 4 +++ components/omega/doc/userGuide/Forcing.md | 2 +- .../src/ocn/forcingVars/TracerForcingVars.cpp | 28 +++++++++---------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/components/omega/doc/devGuide/Forcing.md b/components/omega/doc/devGuide/Forcing.md index 2a05a96b4c99..92aff1fd9c8e 100644 --- a/components/omega/doc/devGuide/Forcing.md +++ b/components/omega/doc/devGuide/Forcing.md @@ -97,6 +97,10 @@ the surface layer pseudo-thickness. - gates execution of coupled flux tracer kernel - controls direct heat flux forcing on temperature and salt flux forcing on salinity +## Notes + +- Currently all forcing is applied to the surface layer only. In the future, vertical spreading of river runoff contributions will be needed. + ## Surface tracer restoring design ### Surface tracer restoring data flow diff --git a/components/omega/doc/userGuide/Forcing.md b/components/omega/doc/userGuide/Forcing.md index 85ecdfe1e1d8..01eeff4b5e3d 100644 --- a/components/omega/doc/userGuide/Forcing.md +++ b/components/omega/doc/userGuide/Forcing.md @@ -91,7 +91,7 @@ by the equivalent `ocn_comp_mct.F`. ### Notes -- Coupled fluxes are applied only at the surface layer (top active layer) for each cell. +- Coupled fluxes are applied only at the surface layer (top active layer) for each cell. In the future, vertical spreading of contributions from river runoff will be needed. - Pseudo-thickness tendency is computed from the (six) freshwater mass fluxes and the salt mass flux `SeaIceSaltFlux`, converted to a pseudo-thickness change. - Temperature tendency is computed from direct heat flux plus diff --git a/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp b/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp index a00b4e9ee95c..3deb7dfcb5da 100644 --- a/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp +++ b/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp @@ -10,22 +10,22 @@ namespace OMEGA { TracerForcingVars::TracerForcingVars(const std::string &Suffix, const HorzMesh *Mesh) - : SnowFluxCell("snowFlux" + Suffix, Mesh->NCellsSize), - RainFluxCell("rainFlux" + Suffix, Mesh->NCellsSize), - EvaporationFluxCell("evaporationFlux" + Suffix, Mesh->NCellsSize), - SeaIceFreshWaterFluxCell("seaIceFreshWaterFlux" + Suffix, + : SnowFluxCell("SnowFlux" + Suffix, Mesh->NCellsSize), + RainFluxCell("RainFlux" + Suffix, Mesh->NCellsSize), + EvaporationFluxCell("EvaporationFlux" + Suffix, Mesh->NCellsSize), + SeaIceFreshWaterFluxCell("SeaIceFreshWaterFlux" + Suffix, Mesh->NCellsSize), - IceRunoffFluxCell("iceRunoffFlux" + Suffix, Mesh->NCellsSize), - RiverRunoffFluxCell("riverRunoffFlux" + Suffix, Mesh->NCellsSize), - LatentHeatFluxCell("latentHeatFlux" + Suffix, Mesh->NCellsSize), - SensibleHeatFluxCell("sensibleHeatFlux" + Suffix, Mesh->NCellsSize), - LongWaveHeatFluxUpCell("longWaveHeatFluxUp" + Suffix, Mesh->NCellsSize), - LongWaveHeatFluxDownCell("longWaveHeatFluxDown" + Suffix, + IceRunoffFluxCell("IceRunoffFlux" + Suffix, Mesh->NCellsSize), + RiverRunoffFluxCell("RiverRunoffFlux" + Suffix, Mesh->NCellsSize), + LatentHeatFluxCell("LatentHeatFlux" + Suffix, Mesh->NCellsSize), + SensibleHeatFluxCell("SensibleHeatFlux" + Suffix, Mesh->NCellsSize), + LongWaveHeatFluxUpCell("LongWaveHeatFluxUp" + Suffix, Mesh->NCellsSize), + LongWaveHeatFluxDownCell("LongWaveHeatFluxDown" + Suffix, Mesh->NCellsSize), - SeaIceHeatFluxCell("seaIceHeatFlux" + Suffix, Mesh->NCellsSize), - ShortWaveHeatFluxCell("shortWaveHeatFlux" + Suffix, Mesh->NCellsSize), - SeaIceSaltFluxCell("seaIceSaltFlux" + Suffix, Mesh->NCellsSize), - SurfInsituTemperature("surfInsituTemperature" + Suffix, + SeaIceHeatFluxCell("SeaIceHeatFlux" + Suffix, Mesh->NCellsSize), + ShortWaveHeatFluxCell("ShortWaveHeatFlux" + Suffix, Mesh->NCellsSize), + SeaIceSaltFluxCell("SeaIceSaltFlux" + Suffix, Mesh->NCellsSize), + SurfInsituTemperature("SurfInsituTemperature" + Suffix, Mesh->NCellsSize) { deepCopy(SnowFluxCell, 0.0_Real); deepCopy(RainFluxCell, 0.0_Real); From 2092194d01b54023b0cdb4de087d9dae5c125926 Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Mon, 20 Jul 2026 21:26:40 -0700 Subject: [PATCH 15/36] Adds reset of forcing fields if not in stream --- components/omega/src/ocn/Forcing.cpp | 56 ++++++++++++++++++++++++++-- components/omega/src/ocn/Forcing.h | 3 ++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/components/omega/src/ocn/Forcing.cpp b/components/omega/src/ocn/Forcing.cpp index 95ba94b3f305..e316456ddbd7 100644 --- a/components/omega/src/ocn/Forcing.cpp +++ b/components/omega/src/ocn/Forcing.cpp @@ -143,7 +143,32 @@ void Forcing::readConfigOptions(Config *OmegaConfig) { } // Compute all forcing variables (dispatches to specific computations). -void Forcing::computeAll() const { computeSfcStressForcingOnEdge(); } +void Forcing::computeAll() const { + exchangeHalo(); + computeSfcStressForcingOnEdge(); +} + +// Reset forcing arrays so omitted optional fields remain zero after read. +void Forcing::resetArrays() { + deepCopy(SfcStressForcing.NormalStressEdge, 0.0_Real); + deepCopy(SfcStressForcing.ZonalStressCell, 0.0_Real); + deepCopy(SfcStressForcing.MeridStressCell, 0.0_Real); + + deepCopy(TracerForcing.SnowFluxCell, 0.0_Real); + deepCopy(TracerForcing.RainFluxCell, 0.0_Real); + deepCopy(TracerForcing.EvaporationFluxCell, 0.0_Real); + deepCopy(TracerForcing.SeaIceFreshWaterFluxCell, 0.0_Real); + deepCopy(TracerForcing.IceRunoffFluxCell, 0.0_Real); + deepCopy(TracerForcing.RiverRunoffFluxCell, 0.0_Real); + deepCopy(TracerForcing.LatentHeatFluxCell, 0.0_Real); + deepCopy(TracerForcing.SensibleHeatFluxCell, 0.0_Real); + deepCopy(TracerForcing.LongWaveHeatFluxUpCell, 0.0_Real); + deepCopy(TracerForcing.LongWaveHeatFluxDownCell, 0.0_Real); + deepCopy(TracerForcing.SeaIceHeatFluxCell, 0.0_Real); + deepCopy(TracerForcing.ShortWaveHeatFluxCell, 0.0_Real); + deepCopy(TracerForcing.SeaIceSaltFluxCell, 0.0_Real); + deepCopy(TracerForcing.SurfInsituTemperature, 0.0_Real); +} // Compute edge-normal stress from cell-center zonal and meridional components. void Forcing::computeSfcStressForcingOnEdge() const { @@ -166,6 +191,30 @@ I4 Forcing::exchangeHalo() const { OnCell); Err += MeshHalo->exchangeFullArrayHalo(SfcStressForcing.MeridStressCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SnowFluxCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.RainFluxCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.EvaporationFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo( + TracerForcing.SeaIceFreshWaterFluxCell, OnCell); + Err += + MeshHalo->exchangeFullArrayHalo(TracerForcing.IceRunoffFluxCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.RiverRunoffFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.LatentHeatFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SensibleHeatFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.LongWaveHeatFluxUpCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo( + TracerForcing.LongWaveHeatFluxDownCell, OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SeaIceHeatFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.ShortWaveHeatFluxCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SeaIceSaltFluxCell, + OnCell); return Err; } @@ -177,13 +226,14 @@ void Forcing::readStreamIntoArrays() { std::string StreamName = "Forcing"; + resetArrays(); + // Attempt to read stream; if unavailable, log and fall back to zero forcing. Err = IOStream::read(StreamName); if (Err.isFail()) { LOG_INFO("Forcing: Error while reading {} stream, using zero forcing", StreamName); - deepCopy(SfcStressForcing.ZonalStressCell, 0._Real); - deepCopy(SfcStressForcing.MeridStressCell, 0._Real); + resetArrays(); } I4 HaloErr = exchangeHalo(); diff --git a/components/omega/src/ocn/Forcing.h b/components/omega/src/ocn/Forcing.h index fda7b91d414e..de9749fecf28 100644 --- a/components/omega/src/ocn/Forcing.h +++ b/components/omega/src/ocn/Forcing.h @@ -71,6 +71,9 @@ class Forcing { /// Read forcing fields from input stream at startup void readStreamIntoArrays(); + /// Reset all forcing arrays to zero before reading optional fields + void resetArrays(); + /// Compute all forcing variables void computeAll() const; From 947fb1ed9046fc3ed74f97c1b6b5e970904a005d Mon Sep 17 00:00:00 2001 From: Carolyn Begeman Date: Wed, 22 Jul 2026 17:57:57 -0600 Subject: [PATCH 16/36] Update components/omega/doc/devGuide/Forcing.md --- components/omega/doc/devGuide/Forcing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/omega/doc/devGuide/Forcing.md b/components/omega/doc/devGuide/Forcing.md index 92aff1fd9c8e..8f9c97ead56d 100644 --- a/components/omega/doc/devGuide/Forcing.md +++ b/components/omega/doc/devGuide/Forcing.md @@ -7,7 +7,7 @@ pathways in Omega, currently this includes: - Surface stress forcing (e.g. wind stress) - Surface thickness and tracer flux forcing (actively coupled or data-forced) -- Surface tracer restoring (soon to be ported) +- Surface tracer restoring (soon to be ported as a field originating from the coupler) ## Surface stress forcing design From 989760595cb0f7d3f21b4e8b1b25444956e1cbda Mon Sep 17 00:00:00 2001 From: Carolyn Begeman Date: Wed, 22 Jul 2026 18:03:52 -0600 Subject: [PATCH 17/36] Fixup documentation --- components/omega/doc/devGuide/TendencyTerms.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/omega/doc/devGuide/TendencyTerms.md b/components/omega/doc/devGuide/TendencyTerms.md index 028ba9c02032..2fd762e28b3e 100644 --- a/components/omega/doc/devGuide/TendencyTerms.md +++ b/components/omega/doc/devGuide/TendencyTerms.md @@ -47,5 +47,5 @@ implemented: ## See Also -Additional information on forcing (surface stress, surface flux forcing, and +Additional information on forcing (surface stress, surface mass and tracer flux forcing, and surface tracer restoring) is detailed in [](omega-dev-forcing). From 236ed0b7450796df3a4d805b7112110375faab20 Mon Sep 17 00:00:00 2001 From: Katherine Smith Date: Thu, 23 Jul 2026 19:26:08 -0400 Subject: [PATCH 18/36] remove SurfInsituTemp calcs --- components/omega/doc/devGuide/Forcing.md | 1 + components/omega/src/ocn/Forcing.cpp | 1 - .../src/ocn/forcingVars/TracerForcingVars.cpp | 49 +------------------ .../src/ocn/forcingVars/TracerForcingVars.h | 7 --- 4 files changed, 2 insertions(+), 56 deletions(-) diff --git a/components/omega/doc/devGuide/Forcing.md b/components/omega/doc/devGuide/Forcing.md index 8f9c97ead56d..7e69dc302bc6 100644 --- a/components/omega/doc/devGuide/Forcing.md +++ b/components/omega/doc/devGuide/Forcing.md @@ -100,6 +100,7 @@ the surface layer pseudo-thickness. ## Notes - Currently all forcing is applied to the surface layer only. In the future, vertical spreading of river runoff contributions will be needed. +- `SeaIceFreshWaterFlux` is the pure freshwater mass from sea ice. The full mass flux from sea ice is `SeaIceFreshWaterFlux + SeaIceSaltFlux` ## Surface tracer restoring design diff --git a/components/omega/src/ocn/Forcing.cpp b/components/omega/src/ocn/Forcing.cpp index e316456ddbd7..5b7fef628433 100644 --- a/components/omega/src/ocn/Forcing.cpp +++ b/components/omega/src/ocn/Forcing.cpp @@ -167,7 +167,6 @@ void Forcing::resetArrays() { deepCopy(TracerForcing.SeaIceHeatFluxCell, 0.0_Real); deepCopy(TracerForcing.ShortWaveHeatFluxCell, 0.0_Real); deepCopy(TracerForcing.SeaIceSaltFluxCell, 0.0_Real); - deepCopy(TracerForcing.SurfInsituTemperature, 0.0_Real); } // Compute edge-normal stress from cell-center zonal and meridional components. diff --git a/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp b/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp index 3deb7dfcb5da..a6478bb612c4 100644 --- a/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp +++ b/components/omega/src/ocn/forcingVars/TracerForcingVars.cpp @@ -24,9 +24,7 @@ TracerForcingVars::TracerForcingVars(const std::string &Suffix, Mesh->NCellsSize), SeaIceHeatFluxCell("SeaIceHeatFlux" + Suffix, Mesh->NCellsSize), ShortWaveHeatFluxCell("ShortWaveHeatFlux" + Suffix, Mesh->NCellsSize), - SeaIceSaltFluxCell("SeaIceSaltFlux" + Suffix, Mesh->NCellsSize), - SurfInsituTemperature("SurfInsituTemperature" + Suffix, - Mesh->NCellsSize) { + SeaIceSaltFluxCell("SeaIceSaltFlux" + Suffix, Mesh->NCellsSize) { deepCopy(SnowFluxCell, 0.0_Real); deepCopy(RainFluxCell, 0.0_Real); deepCopy(EvaporationFluxCell, 0.0_Real); @@ -40,7 +38,6 @@ TracerForcingVars::TracerForcingVars(const std::string &Suffix, deepCopy(SeaIceHeatFluxCell, 0.0_Real); deepCopy(ShortWaveHeatFluxCell, 0.0_Real); deepCopy(SeaIceSaltFluxCell, 0.0_Real); - deepCopy(SurfInsituTemperature, 0.0_Real); } void TracerForcingVars::registerFields(const std::string &MeshName) const { @@ -111,12 +108,6 @@ void TracerForcingVars::registerFields(const std::string &MeshName) const { "kg m^-2 s^-1", "", std::numeric_limits::lowest(), std::numeric_limits::max(), NDims, DimNames); - auto SurfInsituTemperatureField = - Field::create(SurfInsituTemperature.label(), - "insitu (potential) temperature at surface layer", - "degrees Celsius", "", std::numeric_limits::lowest(), - std::numeric_limits::max(), NDims, DimNames); - FieldGroup::addFieldToGroup(SnowFluxCell.label(), "Forcing"); FieldGroup::addFieldToGroup(RainFluxCell.label(), "Forcing"); FieldGroup::addFieldToGroup(EvaporationFluxCell.label(), "Forcing"); @@ -143,7 +134,6 @@ void TracerForcingVars::registerFields(const std::string &MeshName) const { LongWaveHeatFluxDownField->attachData(LongWaveHeatFluxDownCell); SeaIceHeatFluxField->attachData(SeaIceHeatFluxCell); ShortWaveHeatFluxField->attachData(ShortWaveHeatFluxCell); - SurfInsituTemperatureField->attachData(SurfInsituTemperature); SeaIceSaltFluxField->attachData(SeaIceSaltFluxCell); } @@ -161,42 +151,5 @@ void TracerForcingVars::unregisterFields() const { Field::destroy(SeaIceHeatFluxCell.label()); Field::destroy(ShortWaveHeatFluxCell.label()); Field::destroy(SeaIceSaltFluxCell.label()); - Field::destroy(SurfInsituTemperature.label()); -} - -void TracerForcingVars::computeSurfInsituTemp(const Array3DReal &TracerArray, - const VertCoord *VCoord, - const Eos *EosInst) const { - const int IndxTemp = Tracers::IndxTemp; - const int IndxSalt = Tracers::IndxSalt; - - // Skip computation if temperature or salinity tracers are not defined - if (IndxTemp < 0 || IndxSalt < 0) { - return; - } - - OMEGA_SCOPE(LocMinLayerCell, VCoord->MinLayerCell); - OMEGA_SCOPE(LocMaxLayerCell, VCoord->MaxLayerCell); - OMEGA_SCOPE(LocSurfInsituTemp, SurfInsituTemperature); - - int NCellsOwned = SurfInsituTemperature.extent_int(0); - - parallelFor( - "TracerForcing:computeSurfInsituTemp", {NCellsOwned}, - KOKKOS_LAMBDA(int ICell) { - const int KMin = LocMinLayerCell(ICell); - const int KMax = LocMaxLayerCell(ICell); - - // Only compute for valid ocean cells - if (KMin <= KMax) { - const Real ConservTemp = TracerArray(IndxTemp, ICell, KMin); - const Real AbsSalinity = TracerArray(IndxSalt, ICell, KMin); - - // Call EOS function to compute potential temperature from - // conservative temperature at surface (reference pressure = 0) - LocSurfInsituTemp(ICell) = - EosInst->calcPtFromCt(AbsSalinity, ConservTemp); - } - }); } } // namespace OMEGA diff --git a/components/omega/src/ocn/forcingVars/TracerForcingVars.h b/components/omega/src/ocn/forcingVars/TracerForcingVars.h index 1a0747121ea2..e38d9948f672 100644 --- a/components/omega/src/ocn/forcingVars/TracerForcingVars.h +++ b/components/omega/src/ocn/forcingVars/TracerForcingVars.h @@ -31,17 +31,10 @@ class TracerForcingVars { Array1DReal SeaIceSaltFluxCell; - Array1DReal SurfInsituTemperature; - TracerForcingVars(const std::string &Suffix, const HorzMesh *Mesh); void registerFields(const std::string &MeshName) const; void unregisterFields() const; - - /// Compute surface insitu temperature from conservative temperature - void computeSurfInsituTemp(const Array3DReal &TracerArray, - const VertCoord *VCoord, - const Eos *EosInst) const; }; } // namespace OMEGA From 0bd6f8b9296c8c6685e5b5dabf7b0c43b7d2703c Mon Sep 17 00:00:00 2001 From: Katherine Smith Date: Fri, 24 Jul 2026 01:18:27 -0400 Subject: [PATCH 19/36] fixes GPU failures on frontier --- components/omega/src/ocn/Eos.cpp | 23 ++++++++++++++++++++++ components/omega/src/ocn/Eos.h | 22 ++------------------- components/omega/src/ocn/TendencyTerms.cpp | 2 +- components/omega/src/ocn/TendencyTerms.h | 4 ++-- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/components/omega/src/ocn/Eos.cpp b/components/omega/src/ocn/Eos.cpp index ba36a2bb61ac..6dc9b444be31 100644 --- a/components/omega/src/ocn/Eos.cpp +++ b/components/omega/src/ocn/Eos.cpp @@ -327,6 +327,29 @@ void Eos::computeBruntVaisalaFreqSq(const Array2DReal &ConservTemp, } } +Real Eos::calcCtFreezing(const Real Sa, const Real P, + const Real SaturationFract) const { + if (EosChoice == EosType::Teos10Eos) { + return ComputeSpecVolTeos10.calcCtFreezing(Sa, P, SaturationFract); + } + if (EosChoice == EosType::LinearEos) { + // Linear salinity-dependent freezing point; coefficient -0.054 + // degC/PSU with absolute-to-practical salinity conversion (g/kg -> + // PSU). + constexpr Real Coeff = -0.054_Real; + return Coeff * Sa / Psu2Gpkg; + } + if (EosChoice == EosType::ConstantEos) { + // Constant approximate ocean freezing point (degC) + return -1.9_Real; + } + ABORT_ERROR( + "Eos::calcCtFreezing: CT freezing temperature is only " + "implemented for TEOS-10, Linear, and Constant EOS types. " + "Support for the current EOS choice has not yet been developed."); + return 0; +} + /// Define IO fields and metadata for output void Eos::defineFields() { diff --git a/components/omega/src/ocn/Eos.h b/components/omega/src/ocn/Eos.h index 61d5171a2031..b77a9b60cb89 100644 --- a/components/omega/src/ocn/Eos.h +++ b/components/omega/src/ocn/Eos.h @@ -777,26 +777,8 @@ class Eos { return Pt; } - /// Calculate freezing Conservative Temperature. - /// For TEOS-10, uses the Roquet et al. 75-term polynomial. - /// For LinearEos, uses a simple linear salinity-dependent approximation - /// consistent with the linear EOS philosophy (Sa in g/kg converted to PSU). - /// For ConstantEos, returns a constant approximate ocean freezing point. - KOKKOS_FUNCTION Real calcCtFreezing(const Real Sa, const Real P, - const Real SaturationFract) const { - if (EosChoice == EosType::Teos10Eos) { - return ComputeSpecVolTeos10.calcCtFreezing(Sa, P, SaturationFract); - } - if (EosChoice == EosType::LinearEos) { - // Linear salinity-dependent freezing point; coefficient -0.054 - // degC/PSU with absolute-to-practical salinity conversion (g/kg -> - // PSU). - constexpr Real Coeff = -0.054_Real; - return Coeff * Sa / Psu2Gpkg; - } - // ConstantEos: constant approximate ocean freezing point (degC) - return -1.9_Real; - } + Real calcCtFreezing(const Real Sa, const Real P, + const Real SaturationFract) const; /// Initialize EOS from config and mesh static void init(); diff --git a/components/omega/src/ocn/TendencyTerms.cpp b/components/omega/src/ocn/TendencyTerms.cpp index 65df82c4eb00..37bfe6ee0500 100644 --- a/components/omega/src/ocn/TendencyTerms.cpp +++ b/components/omega/src/ocn/TendencyTerms.cpp @@ -83,7 +83,7 @@ SfcTracerForcingOnCell::SfcTracerForcingOnCell(const HorzMesh *Mesh, const Eos *EosInst) : TempIndex(TempTracerIndex), SaltIndex(SaltTracerIndex), MinLayerCell(VCoord->MinLayerCell), MaxLayerCell(VCoord->MaxLayerCell), - EosImpl(EosInst) {} + EosImpl(VCoord) {} TracerHorzAdvOnCell::TracerHorzAdvOnCell(const HorzMesh *Mesh, const VertCoord *VCoord) diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index efd1e43a9698..b0a6591add83 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -438,7 +438,7 @@ class SfcTracerForcingOnCell { const Real SaTop = SaltIndex >= 0 ? TracerCell(SaltIndex, ICell, KTop) : 0.0_Real; // not sure we want zero here? - const Real CtFrz = EosImpl->calcCtFreezing(SaTop, PTopDb, 0.0_Real); + const Real CtFrz = EosImpl.calcCtFreezing(SaTop, PTopDb, 0.0_Real); const Real CtTop = TracerCell(TempIndex, ICell, KTop); // Heat tendencies are due to direct heat fluxes + enthalpy fluxes @@ -469,7 +469,7 @@ class SfcTracerForcingOnCell { I4 SaltIndex; Array1DI4 MinLayerCell; Array1DI4 MaxLayerCell; - const Eos *EosImpl; + Teos10Eos EosImpl; }; // Tracer horizontal advection term From 054dda38d8b01c609f791abe6ce10086dce86852 Mon Sep 17 00:00:00 2001 From: katsmith133 Date: Tue, 28 Jul 2026 17:47:10 -0400 Subject: [PATCH 20/36] Revert "fixes GPU failures on frontier" This reverts commit d32ee4d7352fc7fed80766ac2c7e0943161d99b3. --- components/omega/src/ocn/Eos.cpp | 23 ---------------------- components/omega/src/ocn/Eos.h | 22 +++++++++++++++++++-- components/omega/src/ocn/TendencyTerms.cpp | 2 +- components/omega/src/ocn/TendencyTerms.h | 4 ++-- 4 files changed, 23 insertions(+), 28 deletions(-) diff --git a/components/omega/src/ocn/Eos.cpp b/components/omega/src/ocn/Eos.cpp index 6dc9b444be31..ba36a2bb61ac 100644 --- a/components/omega/src/ocn/Eos.cpp +++ b/components/omega/src/ocn/Eos.cpp @@ -327,29 +327,6 @@ void Eos::computeBruntVaisalaFreqSq(const Array2DReal &ConservTemp, } } -Real Eos::calcCtFreezing(const Real Sa, const Real P, - const Real SaturationFract) const { - if (EosChoice == EosType::Teos10Eos) { - return ComputeSpecVolTeos10.calcCtFreezing(Sa, P, SaturationFract); - } - if (EosChoice == EosType::LinearEos) { - // Linear salinity-dependent freezing point; coefficient -0.054 - // degC/PSU with absolute-to-practical salinity conversion (g/kg -> - // PSU). - constexpr Real Coeff = -0.054_Real; - return Coeff * Sa / Psu2Gpkg; - } - if (EosChoice == EosType::ConstantEos) { - // Constant approximate ocean freezing point (degC) - return -1.9_Real; - } - ABORT_ERROR( - "Eos::calcCtFreezing: CT freezing temperature is only " - "implemented for TEOS-10, Linear, and Constant EOS types. " - "Support for the current EOS choice has not yet been developed."); - return 0; -} - /// Define IO fields and metadata for output void Eos::defineFields() { diff --git a/components/omega/src/ocn/Eos.h b/components/omega/src/ocn/Eos.h index b77a9b60cb89..61d5171a2031 100644 --- a/components/omega/src/ocn/Eos.h +++ b/components/omega/src/ocn/Eos.h @@ -777,8 +777,26 @@ class Eos { return Pt; } - Real calcCtFreezing(const Real Sa, const Real P, - const Real SaturationFract) const; + /// Calculate freezing Conservative Temperature. + /// For TEOS-10, uses the Roquet et al. 75-term polynomial. + /// For LinearEos, uses a simple linear salinity-dependent approximation + /// consistent with the linear EOS philosophy (Sa in g/kg converted to PSU). + /// For ConstantEos, returns a constant approximate ocean freezing point. + KOKKOS_FUNCTION Real calcCtFreezing(const Real Sa, const Real P, + const Real SaturationFract) const { + if (EosChoice == EosType::Teos10Eos) { + return ComputeSpecVolTeos10.calcCtFreezing(Sa, P, SaturationFract); + } + if (EosChoice == EosType::LinearEos) { + // Linear salinity-dependent freezing point; coefficient -0.054 + // degC/PSU with absolute-to-practical salinity conversion (g/kg -> + // PSU). + constexpr Real Coeff = -0.054_Real; + return Coeff * Sa / Psu2Gpkg; + } + // ConstantEos: constant approximate ocean freezing point (degC) + return -1.9_Real; + } /// Initialize EOS from config and mesh static void init(); diff --git a/components/omega/src/ocn/TendencyTerms.cpp b/components/omega/src/ocn/TendencyTerms.cpp index 37bfe6ee0500..65df82c4eb00 100644 --- a/components/omega/src/ocn/TendencyTerms.cpp +++ b/components/omega/src/ocn/TendencyTerms.cpp @@ -83,7 +83,7 @@ SfcTracerForcingOnCell::SfcTracerForcingOnCell(const HorzMesh *Mesh, const Eos *EosInst) : TempIndex(TempTracerIndex), SaltIndex(SaltTracerIndex), MinLayerCell(VCoord->MinLayerCell), MaxLayerCell(VCoord->MaxLayerCell), - EosImpl(VCoord) {} + EosImpl(EosInst) {} TracerHorzAdvOnCell::TracerHorzAdvOnCell(const HorzMesh *Mesh, const VertCoord *VCoord) diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index b0a6591add83..efd1e43a9698 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -438,7 +438,7 @@ class SfcTracerForcingOnCell { const Real SaTop = SaltIndex >= 0 ? TracerCell(SaltIndex, ICell, KTop) : 0.0_Real; // not sure we want zero here? - const Real CtFrz = EosImpl.calcCtFreezing(SaTop, PTopDb, 0.0_Real); + const Real CtFrz = EosImpl->calcCtFreezing(SaTop, PTopDb, 0.0_Real); const Real CtTop = TracerCell(TempIndex, ICell, KTop); // Heat tendencies are due to direct heat fluxes + enthalpy fluxes @@ -469,7 +469,7 @@ class SfcTracerForcingOnCell { I4 SaltIndex; Array1DI4 MinLayerCell; Array1DI4 MaxLayerCell; - Teos10Eos EosImpl; + const Eos *EosImpl; }; // Tracer horizontal advection term From c6a46e4ed35a4be47dca831be475ad01e5a983b5 Mon Sep 17 00:00:00 2001 From: katsmith133 Date: Thu, 30 Jul 2026 16:35:31 -0400 Subject: [PATCH 21/36] fixed GPU isssues on Frontier --- components/omega/src/ocn/Eos.h | 20 +- components/omega/src/ocn/TendencyTerms.cpp | 2 +- components/omega/src/ocn/TendencyTerms.h | 7 +- components/omega/test/ocn/EosTest.cpp | 47 +++- components/omega/test/ocn/TendenciesTest.cpp | 238 ++----------------- 5 files changed, 82 insertions(+), 232 deletions(-) diff --git a/components/omega/src/ocn/Eos.h b/components/omega/src/ocn/Eos.h index 61d5171a2031..2494d6a2d5d8 100644 --- a/components/omega/src/ocn/Eos.h +++ b/components/omega/src/ocn/Eos.h @@ -358,8 +358,8 @@ class Teos10Eos { /// (polynomial error in [-5e-4, 6e-4] K, from GSW package). /// P is relative pressure (gauge pressure in Pa, i.e., absolute pressure /// minus the standard atmosphere). - KOKKOS_FUNCTION Real calcCtFreezing(const Real Sa, const Real P, - const Real SaturationFract) const { + static KOKKOS_FUNCTION Real calcCtFreezingTeos10( + const Real Sa, const Real P, const Real SaturationFract) { constexpr Real Sso = 35.16504; constexpr Real C0 = 0.017947064327968736; constexpr Real C1 = -6.076099099929818; @@ -777,17 +777,17 @@ class Eos { return Pt; } - /// Calculate freezing Conservative Temperature. + /// Calculate freezing temperature of seawater. /// For TEOS-10, uses the Roquet et al. 75-term polynomial. - /// For LinearEos, uses a simple linear salinity-dependent approximation - /// consistent with the linear EOS philosophy (Sa in g/kg converted to PSU). + /// For LinearEos, uses a simple linear salinity-dependent approximation. /// For ConstantEos, returns a constant approximate ocean freezing point. - KOKKOS_FUNCTION Real calcCtFreezing(const Real Sa, const Real P, - const Real SaturationFract) const { - if (EosChoice == EosType::Teos10Eos) { - return ComputeSpecVolTeos10.calcCtFreezing(Sa, P, SaturationFract); + static KOKKOS_FUNCTION Real calcCtFreezing(EosType Choice, const Real Sa, + const Real P, + const Real SaturationFract) { + if (Choice == EosType::Teos10Eos) { + return Teos10Eos::calcCtFreezingTeos10(Sa, P, SaturationFract); } - if (EosChoice == EosType::LinearEos) { + if (Choice == EosType::LinearEos) { // Linear salinity-dependent freezing point; coefficient -0.054 // degC/PSU with absolute-to-practical salinity conversion (g/kg -> // PSU). diff --git a/components/omega/src/ocn/TendencyTerms.cpp b/components/omega/src/ocn/TendencyTerms.cpp index 65df82c4eb00..2353f38049bd 100644 --- a/components/omega/src/ocn/TendencyTerms.cpp +++ b/components/omega/src/ocn/TendencyTerms.cpp @@ -83,7 +83,7 @@ SfcTracerForcingOnCell::SfcTracerForcingOnCell(const HorzMesh *Mesh, const Eos *EosInst) : TempIndex(TempTracerIndex), SaltIndex(SaltTracerIndex), MinLayerCell(VCoord->MinLayerCell), MaxLayerCell(VCoord->MaxLayerCell), - EosImpl(EosInst) {} + EosChoice(EosInst->EosChoice) {} TracerHorzAdvOnCell::TracerHorzAdvOnCell(const HorzMesh *Mesh, const VertCoord *VCoord) diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index efd1e43a9698..a2a8dc09492c 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -438,8 +438,9 @@ class SfcTracerForcingOnCell { const Real SaTop = SaltIndex >= 0 ? TracerCell(SaltIndex, ICell, KTop) : 0.0_Real; // not sure we want zero here? - const Real CtFrz = EosImpl->calcCtFreezing(SaTop, PTopDb, 0.0_Real); - const Real CtTop = TracerCell(TempIndex, ICell, KTop); + const Real CtFrz = + Eos::calcCtFreezing(EosChoice, SaTop, PTopDb, 0.0_Real); + const Real CtTop = TracerCell(TempIndex, ICell, KTop); // Heat tendencies are due to direct heat fluxes + enthalpy fluxes // The enthalpy of liquid water is assumed to be: @@ -469,7 +470,7 @@ class SfcTracerForcingOnCell { I4 SaltIndex; Array1DI4 MinLayerCell; Array1DI4 MaxLayerCell; - const Eos *EosImpl; + EosType EosChoice; }; // Tracer horizontal advection term diff --git a/components/omega/test/ocn/EosTest.cpp b/components/omega/test/ocn/EosTest.cpp index 0e7eb2c5be76..f8ca084d4559 100644 --- a/components/omega/test/ocn/EosTest.cpp +++ b/components/omega/test/ocn/EosTest.cpp @@ -706,6 +706,42 @@ void testBruntVaisalaFreqSqTeos10() { return; } +/// Test all Eos::calcCtFreezing pathways (Teos10, Linear, Constant) +void testCalcCtFreezing() { + const Real RTol = 1e-10; + + constexpr Real SaturationFrac = 0.0; + constexpr Real PDb = 500.0; // pressure in dbar for GSW pathway + constexpr Real SaLocal = 32.0; + + const Real CtTeosExpected = + gsw_ct_freezing_poly(SaLocal, PDb, SaturationFrac); + const Real CtTeos = + Eos::calcCtFreezing(EosType::Teos10Eos, SaLocal, PDb, SaturationFrac); + if (!isApprox(CtTeos, CtTeosExpected, RTol)) { + ABORT_ERROR("testCalcCtFreezing: Teos10 FAIL, expected {}, got {}", + CtTeosExpected, CtTeos); + } + + const Real CtLinearExpected = -0.054_Real * SaLocal / Psu2Gpkg; + const Real CtLinear = + Eos::calcCtFreezing(EosType::LinearEos, SaLocal, PDb, SaturationFrac); + if (!isApprox(CtLinear, CtLinearExpected, RTol)) { + ABORT_ERROR("testCalcCtFreezing: Linear FAIL, expected {}, got {}", + CtLinearExpected, CtLinear); + } + + const Real CtConstExpected = -1.9_Real; + const Real CtConst = + Eos::calcCtFreezing(EosType::ConstantEos, SaLocal, PDb, SaturationFrac); + if (!isApprox(CtConst, CtConstExpected, RTol)) { + ABORT_ERROR("testCalcCtFreezing: Constant FAIL, expected {}, got {}", + CtConstExpected, CtConst); + } + + return; +} + /// Finalize and clean up all test infrastructure void finalizeEosTest() { Eos::destroyInstance(); @@ -767,22 +803,22 @@ void checkValueGswcN2() { } /// Test that the calcCtFreezing function returns the expected value -void checkValueCtFreezing() { +void checkValueGswcCtFreezing() { const Real RTol = 1e-10; - Teos10Eos TestEos(VertCoord::getDefault()); constexpr Real SaturationFrac = 0.0; constexpr Real P = 500.0 * Db2Pa; // Convert dbar to Pa constexpr Real Sa = 32.0; /// Get freezing temperature from GSW-C library double CtFreezGswc = gsw_ct_freezing_poly(Sa, P * Pa2Db, SaturationFrac); - double CtFreez = TestEos.calcCtFreezing(Sa, P * Pa2Db, SaturationFrac); + double CtFreez = + Teos10Eos::calcCtFreezingTeos10(Sa, P * Pa2Db, SaturationFrac); /// Check the value against the GSW-C value bool Check = isApprox(CtFreezGswc, CtFreez, RTol); if (!Check) { - ABORT_ERROR("checkValueCtFreezing: CtFreez FAIL, expected {}, got {}", + ABORT_ERROR("checkValueGswcCtFreezing: CtFreez FAIL, expected {}, got {}", CtFreezGswc, CtFreez); } return; @@ -837,7 +873,7 @@ void eosTest(const std::string &MeshFile = "OmegaMesh.nc") { checkValueGswcSpecVol(); checkValueGswcN2(); - checkValueCtFreezing(); + checkValueGswcCtFreezing(); checkValueGswcCtFromPt(); checkValueGswcPtFromCt(); @@ -848,6 +884,7 @@ void eosTest(const std::string &MeshFile = "OmegaMesh.nc") { testEosTeos10(); testEosTeos10Displaced(); testBruntVaisalaFreqSqTeos10(); + testCalcCtFreezing(); finalizeEosTest(); diff --git a/components/omega/test/ocn/TendenciesTest.cpp b/components/omega/test/ocn/TendenciesTest.cpp index 01b0bff4cbdf..13f8ed2ba2a4 100644 --- a/components/omega/test/ocn/TendenciesTest.cpp +++ b/components/omega/test/ocn/TendenciesTest.cpp @@ -54,8 +54,7 @@ struct TestSetup { constexpr Geometry Geom = Geometry::Spherical; constexpr int NVertLayers = 60; -int testSfcTracerForcingTeos10(); -int testSfcTracerForcingLinear(); +int testSfcTracerForcing(); int testSfcThicknessForcing(); int initState() { @@ -307,15 +306,19 @@ int testTendencies() { "NormalVelocityTend"); } + const Real NormVelTendSum = + sum(DefTendencies->NormalVelocityTend, Mesh->NEdgesOwned, + VCoord->MinLayerEdgeBot, VCoord->MaxLayerEdgeTop); + if (!Kokkos::isfinite(NormVelTendSum) || NormVelTendSum == 0) { + Err++; + LOG_ERROR("TendenciesTest: NormVelTendSum FAIL"); + } + DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; // Test surface tracer forcing with enthalpy terms (TEOS-10 CtFrz path) - const int TracerForcingTeos10Err = testSfcTracerForcingTeos10(); - Err += TracerForcingTeos10Err; - - // Test surface tracer forcing with LinearEos (linear CtFrz path) - const int TracerForcingLinearErr = testSfcTracerForcingLinear(); - Err += TracerForcingLinearErr; + const int TracerForcingErr = testSfcTracerForcing(); + Err += TracerForcingErr; // Test surface thickness forcing with freshwater terms const int ThicknessForcingErr = testSfcThicknessForcing(); @@ -323,7 +326,6 @@ int testTendencies() { // check that everything got computed correctly int NCellsOwned = Mesh->NCellsOwned; - int NEdgesOwned = Mesh->NEdgesOwned; int NTracers = Tracers::getNumTracers(); const Real PseudoThickTendSum = @@ -334,14 +336,6 @@ int testTendencies() { LOG_ERROR("TendenciesTest: PseudoThickTend FAIL"); } - const Real NormVelTendSum = - sum(DefTendencies->NormalVelocityTend, NEdgesOwned, - VCoord->MinLayerEdgeBot, VCoord->MaxLayerEdgeTop); - if (!Kokkos::isfinite(NormVelTendSum) || NormVelTendSum == 0) { - Err++; - LOG_ERROR("TendenciesTest: NormVelTendSum FAIL"); - } - const Real TraceTendSum = sum(DefTendencies->TracerTend, NTracers, NCellsOwned, VCoord->MinLayerCell, VCoord->MaxLayerCell); @@ -354,7 +348,7 @@ int testTendencies() { return Err; } -int testSfcTracerForcingTeos10() { +int testSfcTracerForcing() { int Err = 0; auto *VCoord = VertCoord::getDefault(); @@ -370,8 +364,7 @@ int testSfcTracerForcingTeos10() { const I4 SaltIndex = Tracers::IndxSalt; if (TempIndex < 0 || SaltIndex < 0) { - LOG_ERROR( - "TendenciesTest: Invalid tracer indices for SfcTracerForcingTeos10"); + LOG_ERROR("TendenciesTest: Invalid tracer indices for SfcTracerForcing"); return -1; } @@ -511,11 +504,12 @@ int testSfcTracerForcingTeos10() { HostArray2DReal PressureMidH = createHostMirrorCopy(VCoord->PressureMid); deepCopy(PressureMidH, VCoord->PressureMid); - const Real PTopDb = PressureMidH(ICellTest, KTop) * Pa2Db; - const Real CtFrzTeos = EosInst->calcCtFreezing(SaTopValue, PTopDb, 0.0_Real); - const Real ExpectedTempTendTeos = + const Real PTopDb = PressureMidH(ICellTest, KTop) * Pa2Db; + const Real CtFrz = + Eos::calcCtFreezing(EosInst->EosChoice, SaTopValue, PTopDb, 0.0_Real); + const Real ExpectedTempTend = (TestSensibleHeat + TestRain * Cp0Sw * CtTopValue + - TestSnow * (Cp0Sw * CtFrzTeos - LatIce)) * + TestSnow * (Cp0Sw * CtFrz - LatIce)) * HFluxFac; // SaltTend = SeaIceSaltFlux * SFluxFac @@ -533,209 +527,27 @@ int testSfcTracerForcingTeos10() { constexpr Real AbsTol = 1.0e-12_Real; // flux precision is ~e-15 // Expected-pass check with TEOS freezing CT reference. - if (!isApprox(ComputedTempTend, ExpectedTempTendTeos, RelTol, AbsTol)) { + if (!isApprox(ComputedTempTend, ExpectedTempTend, RelTol, AbsTol)) { Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcingTeos10 temp tendency FAIL"); + LOG_ERROR("TendenciesTest: SfcTracerForcing temp tendency FAIL"); LOG_ERROR(" with TEOS-CtFrz Expected: {}, Computed: {}, Diff: {}", - ExpectedTempTendTeos, ComputedTempTend, - Kokkos::abs(ComputedTempTend - ExpectedTempTendTeos)); + ExpectedTempTend, ComputedTempTend, + Kokkos::abs(ComputedTempTend - ExpectedTempTend)); } else { - LOG_INFO("TendenciesTest: SfcTracerForcingTeos10 temp tendency PASS"); + LOG_INFO("TendenciesTest: SfcTracerForcing temp tendency PASS"); } // Check salinity tendency if (!isApprox(ComputedSaltTend, ExpectedSaltTend, RelTol, AbsTol)) { Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcingTeos10 salt tendency FAIL"); + LOG_ERROR("TendenciesTest: SfcTracerForcing salt tendency FAIL"); LOG_INFO(" Expected: {}, Computed: {}, Diff: {}", ExpectedSaltTend, ComputedSaltTend, Kokkos::abs(ComputedSaltTend - ExpectedSaltTend)); } else { - LOG_INFO("TendenciesTest: SfcTracerForcingTeos10 salt tendency PASS"); - } - - DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; - DefTendencies->SfcThicknessForcing.Enabled = OrigSfcThicknessEnabled; - DefTendencies->SfcTracerForcing.Enabled = OrigSfcTracerEnabled; - DefTendencies->PseudoThicknessFluxDiv.Enabled = OrigPseudoThicknessDiv; - DefTendencies->PotentialVortHAdv.Enabled = OrigPotentialVortHAdv; - DefTendencies->KEGrad.Enabled = OrigKEGrad; - DefTendencies->VelocityDiffusion.Enabled = OrigVelocityDiffusion; - DefTendencies->VelocityHyperDiff.Enabled = OrigVelocityHyperDiff; - DefTendencies->TracerHorzAdv.Enabled = OrigTracerHorzAdv; - DefTendencies->TracerDiffusion.Enabled = OrigTracerDiffusion; - DefTendencies->TracerHyperDiff.Enabled = OrigTracerHyperDiff; - DefTendencies->SurfaceTracerRestoring.Enabled = OrigSurfaceTracerRestoring; - - return Err; -} - -// Tests the SfcTracerForcing path using LinearEos. The EosChoice is -// temporarily set to LinearEos so that calcCtFreezing uses the linear -// salinity-dependent approximation instead of the TEOS-10 polynomial. -// Snow flux is applied so the CtFrz term is exercised. -int testSfcTracerForcingLinear() { - int Err = 0; - - auto *VCoord = VertCoord::getDefault(); - auto *DefTendencies = Tendencies::getDefault(); - auto *State = OceanState::getDefault(); - auto *AuxState = AuxiliaryState::getDefault(); - auto *DefForcing = Forcing::getDefault(); - auto *EosInst = Eos::getInstance(); - - Array3DReal TracerArray = Tracers::getAll(0); - - const I4 TempIndex = Tracers::IndxTemp; - const I4 SaltIndex = Tracers::IndxSalt; - - if (TempIndex < 0 || SaltIndex < 0) { - LOG_ERROR("TendenciesTest: Invalid tracer indices for " - "SfcTracerForcingLinear"); - return -1; - } - - deepCopy(DefTendencies->TracerTend, 0._Real); - - const I4 ICellTest = 0; - const I4 KTop = VCoord->MinLayerCellH(ICellTest); - - if (KTop > VCoord->MaxLayerCellH(ICellTest)) { - LOG_ERROR("TendenciesTest: Test cell has no layers"); - return -1; - } - - const Real CtTopValue = 10.0_Real; // conservative temperature (degC) - const Real SaTopValue = 34.0_Real; // absolute salinity (g/kg) - - OMEGA_SCOPE(LocTracerArray, TracerArray); - Kokkos::parallel_for( - "SetTestTracersForcingNonTeos10", 1, KOKKOS_LAMBDA(int i) { - LocTracerArray(TempIndex, ICellTest, KTop) = CtTopValue; - LocTracerArray(SaltIndex, ICellTest, KTop) = SaTopValue; - }); - - auto &SensibleHeatFlux = DefForcing->TracerForcing.SensibleHeatFluxCell; - auto &LatentHeatFlux = DefForcing->TracerForcing.LatentHeatFluxCell; - auto &LongWaveHeatFluxUp = DefForcing->TracerForcing.LongWaveHeatFluxUpCell; - auto &LongWaveHeatFluxDown = - DefForcing->TracerForcing.LongWaveHeatFluxDownCell; - auto &SeaIceHeatFlux = DefForcing->TracerForcing.SeaIceHeatFluxCell; - auto &ShortWaveHeatFlux = DefForcing->TracerForcing.ShortWaveHeatFluxCell; - auto &RainFlux = DefForcing->TracerForcing.RainFluxCell; - auto &RiverRunoffFlux = DefForcing->TracerForcing.RiverRunoffFluxCell; - auto &SnowFlux = DefForcing->TracerForcing.SnowFluxCell; - auto &IceRunoffFlux = DefForcing->TracerForcing.IceRunoffFluxCell; - auto &SeaIceSaltFlux = DefForcing->TracerForcing.SeaIceSaltFluxCell; - - deepCopy(SensibleHeatFlux, 0._Real); - deepCopy(LatentHeatFlux, 0._Real); - deepCopy(LongWaveHeatFluxUp, 0._Real); - deepCopy(LongWaveHeatFluxDown, 0._Real); - deepCopy(SeaIceHeatFlux, 0._Real); - deepCopy(ShortWaveHeatFlux, 0._Real); - deepCopy(RainFlux, 0._Real); - deepCopy(RiverRunoffFlux, 0._Real); - deepCopy(SnowFlux, 0._Real); - deepCopy(IceRunoffFlux, 0._Real); - deepCopy(SeaIceSaltFlux, 0._Real); - - // Only snow flux so the expected value depends solely on CtFrz. - const Real TestSnow = 5.0e-9_Real; // kg/m2/s - - OMEGA_SCOPE(LocSnowFlux, SnowFlux); - Kokkos::parallel_for( - "SetTestForcingNonTeos10", 1, - KOKKOS_LAMBDA(int i) { LocSnowFlux(ICellTest) = TestSnow; }); - - DefForcing->computeAll(); - - // Switch EOS to LinearEos so calcCtFreezing uses the linear approximation. - const EosType OrigEosChoice = EosInst->EosChoice; - EosInst->EosChoice = EosType::LinearEos; - - const bool OrigSfcStressEnabled = DefTendencies->SfcStressForcing.Enabled; - const bool OrigSfcThicknessEnabled = - DefTendencies->SfcThicknessForcing.Enabled; - const bool OrigSfcTracerEnabled = DefTendencies->SfcTracerForcing.Enabled; - const bool OrigPseudoThicknessDiv = - DefTendencies->PseudoThicknessFluxDiv.Enabled; - const bool OrigPotentialVortHAdv = DefTendencies->PotentialVortHAdv.Enabled; - const bool OrigKEGrad = DefTendencies->KEGrad.Enabled; - const bool OrigVelocityDiffusion = DefTendencies->VelocityDiffusion.Enabled; - const bool OrigVelocityHyperDiff = DefTendencies->VelocityHyperDiff.Enabled; - const bool OrigTracerHorzAdv = DefTendencies->TracerHorzAdv.Enabled; - const bool OrigTracerDiffusion = DefTendencies->TracerDiffusion.Enabled; - const bool OrigTracerHyperDiff = DefTendencies->TracerHyperDiff.Enabled; - const bool OrigSurfaceTracerRestoring = - DefTendencies->SurfaceTracerRestoring.Enabled; - - DefTendencies->SfcStressForcing.Enabled = false; - DefTendencies->SfcThicknessForcing.Enabled = false; - DefTendencies->SfcTracerForcing.Enabled = false; - DefTendencies->PseudoThicknessFluxDiv.Enabled = false; - DefTendencies->PotentialVortHAdv.Enabled = false; - DefTendencies->KEGrad.Enabled = false; - DefTendencies->VelocityDiffusion.Enabled = false; - DefTendencies->VelocityHyperDiff.Enabled = false; - DefTendencies->TracerHorzAdv.Enabled = false; - DefTendencies->TracerDiffusion.Enabled = false; - DefTendencies->TracerHyperDiff.Enabled = false; - DefTendencies->SurfaceTracerRestoring.Enabled = false; - - int ThickTimeLevel = 0; - int VelTimeLevel = 0; - int TracerTimeLevel = 0; - TimeInstant Time; - TimeInterval Interval(1., TimeUnits::Seconds); - - // Compute baseline (vertical advection always on) - DefTendencies->computeAllTendencies(State, AuxState, TracerArray, - ThickTimeLevel, VelTimeLevel, - TracerTimeLevel, Time, Interval); - - HostArray3DReal TracerTendBaseH = - createHostMirrorCopy(DefTendencies->TracerTend); - deepCopy(TracerTendBaseH, DefTendencies->TracerTend); - const Real BaselineTempTend = TracerTendBaseH(TempIndex, ICellTest, KTop); - - // Enable SfcTracerForcing and compute again - DefTendencies->SfcTracerForcing.Enabled = true; - - DefTendencies->computeAllTendencies(State, AuxState, TracerArray, - ThickTimeLevel, VelTimeLevel, - TracerTimeLevel, Time, Interval); - - // Expected CtFrz from LinearEos path in Eos::calcCtFreezing: - // Tf = -0.054 * Sa * (35.0/35.16504) (no pressure dependence) - const Real CtFrzNonTeos = - -0.054_Real * SaTopValue * (35.0_Real / 35.16504_Real); - - // HeatFlux = Snow * (Cp0Sw * CtFrz - LatIce) - const Real ExpectedTempTend = - TestSnow * (Cp0Sw * CtFrzNonTeos - LatIce) * HFluxFac; - - HostArray3DReal TracerTendH = - createHostMirrorCopy(DefTendencies->TracerTend); - deepCopy(TracerTendH, DefTendencies->TracerTend); - const Real ComputedTempTend = - TracerTendH(TempIndex, ICellTest, KTop) - BaselineTempTend; - - constexpr Real RelTol = 1.0e-10_Real; - constexpr Real AbsTol = 1.0e-12_Real; - - if (!isApprox(ComputedTempTend, ExpectedTempTend, RelTol, AbsTol)) { - Err++; - LOG_ERROR("TendenciesTest: SfcTracerForcingLinear temp tendency FAIL"); - LOG_ERROR(" Expected: {}, Computed: {}, Diff: {}", ExpectedTempTend, - ComputedTempTend, - Kokkos::abs(ComputedTempTend - ExpectedTempTend)); - } else { - LOG_INFO("TendenciesTest: SfcTracerForcingLinear temp tendency PASS"); + LOG_INFO("TendenciesTest: SfcTracerForcing salt tendency PASS"); } - // Restore EOS choice and tendency flags - EosInst->EosChoice = OrigEosChoice; DefTendencies->SfcStressForcing.Enabled = OrigSfcStressEnabled; DefTendencies->SfcThicknessForcing.Enabled = OrigSfcThicknessEnabled; DefTendencies->SfcTracerForcing.Enabled = OrigSfcTracerEnabled; From 833aa9768f6ec560f31ab9f0d60f25c6d9b36249 Mon Sep 17 00:00:00 2001 From: katsmith133 Date: Fri, 31 Jul 2026 15:00:26 -0400 Subject: [PATCH 22/36] fixes omega_pr errors on Frontier --- components/omega/src/ocn/Forcing.cpp | 121 ++++++++++++++++----------- components/omega/src/ocn/Forcing.h | 2 + 2 files changed, 72 insertions(+), 51 deletions(-) diff --git a/components/omega/src/ocn/Forcing.cpp b/components/omega/src/ocn/Forcing.cpp index 5b7fef628433..46af26094113 100644 --- a/components/omega/src/ocn/Forcing.cpp +++ b/components/omega/src/ocn/Forcing.cpp @@ -36,14 +36,22 @@ Forcing::~Forcing() { unregisterFields(); } // Register surface stress fields with IO streams for a given mesh. void Forcing::registerFields(const std::string &MeshName) const { - SfcStressForcing.registerFields(MeshName); - TracerForcing.registerFields(MeshName); + if (SfcStressFieldsEnabled) { + SfcStressForcing.registerFields(MeshName); + } + if (TracerForcingFieldsEnabled) { + TracerForcing.registerFields(MeshName); + } } // Unregister surface stress fields from IO streams. void Forcing::unregisterFields() const { - SfcStressForcing.unregisterFields(); - TracerForcing.unregisterFields(); + if (SfcStressFieldsEnabled) { + SfcStressForcing.unregisterFields(); + } + if (TracerForcingFieldsEnabled) { + TracerForcing.unregisterFields(); + } } // Create and register a non-default forcing instance. @@ -80,10 +88,9 @@ void Forcing::init() { ABORT_ERROR("Forcing: failed to initialize default forcing state"); } - DefaultForcing->registerFields(DefMesh->MeshName); - Config *OmegaConfig = Config::getOmegaConfig(); DefaultForcing->readConfigOptions(OmegaConfig); + DefaultForcing->registerFields(DefMesh->MeshName); // for now, forcing fields are read at start-up only. // to be extended to include switch from standalone to coupled. // to be moved to a Forcing->prepareForStep(SimTime) method later. @@ -140,6 +147,30 @@ void Forcing::readConfigOptions(Config *OmegaConfig) { } else { ABORT_ERROR("Forcing: Unknown InterpType requested"); } + + Config TendConfig("Tendencies"); + Err += OmegaConfig->get(TendConfig); + CHECK_ERROR_ABORT(Err, "Forcing: Tendencies group not found in Config"); + + Err += + TendConfig.get("SfcStressForcingTendencyEnable", SfcStressFieldsEnabled); + CHECK_ERROR_ABORT(Err, "Forcing: SfcStressForcingTendencyEnable not found " + "in Tendencies config"); + + bool SfcThicknessForcingEnabled = false; + Err += TendConfig.get("SfcThicknessForcingTendencyEnable", + SfcThicknessForcingEnabled); + CHECK_ERROR_ABORT(Err, "Forcing: SfcThicknessForcingTendencyEnable not " + "found in Tendencies config"); + + bool SfcTracerForcingEnabled = false; + Err += TendConfig.get("SfcTracerForcingTendencyEnable", + SfcTracerForcingEnabled); + CHECK_ERROR_ABORT(Err, "Forcing: SfcTracerForcingTendencyEnable not found " + "in Tendencies config"); + + TracerForcingFieldsEnabled = + SfcThicknessForcingEnabled || SfcTracerForcingEnabled; } // Compute all forcing variables (dispatches to specific computations). @@ -150,23 +181,27 @@ void Forcing::computeAll() const { // Reset forcing arrays so omitted optional fields remain zero after read. void Forcing::resetArrays() { - deepCopy(SfcStressForcing.NormalStressEdge, 0.0_Real); - deepCopy(SfcStressForcing.ZonalStressCell, 0.0_Real); - deepCopy(SfcStressForcing.MeridStressCell, 0.0_Real); - - deepCopy(TracerForcing.SnowFluxCell, 0.0_Real); - deepCopy(TracerForcing.RainFluxCell, 0.0_Real); - deepCopy(TracerForcing.EvaporationFluxCell, 0.0_Real); - deepCopy(TracerForcing.SeaIceFreshWaterFluxCell, 0.0_Real); - deepCopy(TracerForcing.IceRunoffFluxCell, 0.0_Real); - deepCopy(TracerForcing.RiverRunoffFluxCell, 0.0_Real); - deepCopy(TracerForcing.LatentHeatFluxCell, 0.0_Real); - deepCopy(TracerForcing.SensibleHeatFluxCell, 0.0_Real); - deepCopy(TracerForcing.LongWaveHeatFluxUpCell, 0.0_Real); - deepCopy(TracerForcing.LongWaveHeatFluxDownCell, 0.0_Real); - deepCopy(TracerForcing.SeaIceHeatFluxCell, 0.0_Real); - deepCopy(TracerForcing.ShortWaveHeatFluxCell, 0.0_Real); - deepCopy(TracerForcing.SeaIceSaltFluxCell, 0.0_Real); + if (SfcStressFieldsEnabled) { + deepCopy(SfcStressForcing.NormalStressEdge, 0.0_Real); + deepCopy(SfcStressForcing.ZonalStressCell, 0.0_Real); + deepCopy(SfcStressForcing.MeridStressCell, 0.0_Real); + } + + if (TracerForcingFieldsEnabled) { + deepCopy(TracerForcing.SnowFluxCell, 0.0_Real); + deepCopy(TracerForcing.RainFluxCell, 0.0_Real); + deepCopy(TracerForcing.EvaporationFluxCell, 0.0_Real); + deepCopy(TracerForcing.SeaIceFreshWaterFluxCell, 0.0_Real); + deepCopy(TracerForcing.IceRunoffFluxCell, 0.0_Real); + deepCopy(TracerForcing.RiverRunoffFluxCell, 0.0_Real); + deepCopy(TracerForcing.LatentHeatFluxCell, 0.0_Real); + deepCopy(TracerForcing.SensibleHeatFluxCell, 0.0_Real); + deepCopy(TracerForcing.LongWaveHeatFluxUpCell, 0.0_Real); + deepCopy(TracerForcing.LongWaveHeatFluxDownCell, 0.0_Real); + deepCopy(TracerForcing.SeaIceHeatFluxCell, 0.0_Real); + deepCopy(TracerForcing.ShortWaveHeatFluxCell, 0.0_Real); + deepCopy(TracerForcing.SeaIceSaltFluxCell, 0.0_Real); + } } // Compute edge-normal stress from cell-center zonal and meridional components. @@ -186,34 +221,12 @@ void Forcing::computeSfcStressForcingOnEdge() const { I4 Forcing::exchangeHalo() const { I4 Err = 0; - Err += MeshHalo->exchangeFullArrayHalo(SfcStressForcing.ZonalStressCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(SfcStressForcing.MeridStressCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SnowFluxCell, OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.RainFluxCell, OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.EvaporationFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo( - TracerForcing.SeaIceFreshWaterFluxCell, OnCell); - Err += - MeshHalo->exchangeFullArrayHalo(TracerForcing.IceRunoffFluxCell, OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.RiverRunoffFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.LatentHeatFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SensibleHeatFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.LongWaveHeatFluxUpCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo( - TracerForcing.LongWaveHeatFluxDownCell, OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SeaIceHeatFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.ShortWaveHeatFluxCell, - OnCell); - Err += MeshHalo->exchangeFullArrayHalo(TracerForcing.SeaIceSaltFluxCell, - OnCell); + if (SfcStressFieldsEnabled) { + Err += MeshHalo->exchangeFullArrayHalo(SfcStressForcing.ZonalStressCell, + OnCell); + Err += MeshHalo->exchangeFullArrayHalo(SfcStressForcing.MeridStressCell, + OnCell); + } return Err; } @@ -227,6 +240,12 @@ void Forcing::readStreamIntoArrays() { resetArrays(); + // Nothing to read if neither stress nor tracer forcing tendencies are + // enabled. + if (!SfcStressFieldsEnabled && !TracerForcingFieldsEnabled) { + return; + } + // Attempt to read stream; if unavailable, log and fall back to zero forcing. Err = IOStream::read(StreamName); if (Err.isFail()) { diff --git a/components/omega/src/ocn/Forcing.h b/components/omega/src/ocn/Forcing.h index de9749fecf28..b061bdcf25d7 100644 --- a/components/omega/src/ocn/Forcing.h +++ b/components/omega/src/ocn/Forcing.h @@ -91,6 +91,8 @@ class Forcing { const HorzMesh *Mesh; Halo *MeshHalo; + bool SfcStressFieldsEnabled = false; + bool TracerForcingFieldsEnabled = false; static Forcing *DefaultForcing; static std::map> AllForcing; From ec8de9919b27e42f4ec56bfa3235ba47e8ca1108 Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Mon, 10 Aug 2026 18:05:21 -0400 Subject: [PATCH 23/36] Add KPP mixing and non-local tracer tendencies Add KPP vertical mixing support on top of thermo forcing capability --- components/omega/configs/Default.yml | 13 + components/omega/doc/design/KPPMix.md | 199 ++ components/omega/doc/devGuide/KPPMix.md | 132 ++ components/omega/doc/index.md | 3 + components/omega/doc/userGuide/KPPMix.md | 107 + components/omega/src/ocn/KPPConstants.h | 409 ++++ components/omega/src/ocn/KPPMix.cpp | 1405 ++++++++++++ components/omega/src/ocn/KPPMix.h | 227 ++ components/omega/src/ocn/KPPNonLocalFlux.h | 105 + components/omega/src/ocn/OceanInit.cpp | 2 + components/omega/src/ocn/Tendencies.cpp | 378 ++++ components/omega/src/ocn/Tendencies.h | 25 + components/omega/src/ocn/VertMix.cpp | 56 +- .../timeStepping/ForwardBackwardStepper.cpp | 8 +- .../src/timeStepping/RungeKutta2Stepper.cpp | 7 +- .../src/timeStepping/RungeKutta4Stepper.cpp | 13 +- components/omega/test/CMakeLists.txt | 17 + components/omega/test/ocn/KPPMixTest.cpp | 1996 +++++++++++++++++ 18 files changed, 5095 insertions(+), 7 deletions(-) create mode 100644 components/omega/doc/design/KPPMix.md create mode 100644 components/omega/doc/devGuide/KPPMix.md create mode 100644 components/omega/doc/userGuide/KPPMix.md create mode 100644 components/omega/src/ocn/KPPConstants.h create mode 100644 components/omega/src/ocn/KPPMix.cpp create mode 100644 components/omega/src/ocn/KPPMix.h create mode 100755 components/omega/src/ocn/KPPNonLocalFlux.h create mode 100644 components/omega/test/ocn/KPPMixTest.cpp diff --git a/components/omega/configs/Default.yml b/components/omega/configs/Default.yml index e33403c7cc91..af0b4485cb52 100644 --- a/components/omega/configs/Default.yml +++ b/components/omega/configs/Default.yml @@ -73,6 +73,7 @@ Omega: PressureGradTendencyEnable: true VelVertMixTendencyEnable: true TracerVertMixTendencyEnable: true + TracerNonLocalFluxTendencyEnable: true ManufacturedSolution: WavelengthX: 5.0e6 WavelengthY: 4.33013e6 @@ -100,6 +101,18 @@ Omega: RiCrit: 0.7 Exponent: 3.0 RiSmoothLoops: 2 + KPP: + Enable: true + UseNonLocalFlux: true + UseBLDSmoothing: true + CriticalBulkRichardsonNumber: 0.25 + MatchTechnique: SimpleShapes + InterpType2: LMD94 + UseEnhancedDiffusion: true + IceFractionThresholdForLangmuir: 0.05 + IceFractionThresholdForMinimumOBL: 0.15 + MinimumOBLUnderSeaIce: 5.0 + DebugDiagnostics: false IOStreams: HorzMeshIn: UsePointerFile: false diff --git a/components/omega/doc/design/KPPMix.md b/components/omega/doc/design/KPPMix.md new file mode 100644 index 000000000000..00ceaae11c53 --- /dev/null +++ b/components/omega/doc/design/KPPMix.md @@ -0,0 +1,199 @@ +(omega-design-kppmix)= +# KPP Boundary Layer Mixing + +**Table of Contents** +1. [Overview](#1-overview) +2. [Requirements](#2-requirements) +3. [Algorithmic Formulation](#3-algorithmic-formulation) +4. [Design](#4-design) +5. [Verification and Testing](#5-verification-and-testing) + +## 1 Overview + +This document describes the OMEGA implementation of K Profile Parameterization +(KPP) ocean boundary layer mixing. KPP computes boundary-layer depth, vertical +viscosity, vertical diffusivity, and an optional non-local tracer flux shape +used by tracer tendencies. + +The implementation is in `KPPMix` and is integrated with the OMEGA tendency and +RK4 stepping workflow. Relative to broad vertical mixing documentation, this +page focuses specifically on KPP theory, algorithm choices, and verification. + +Related pages: +- User usage/configuration: [KPP in the User Guide](../userGuide/KPPMix.md) +- Developer implementation details: [KPP in the Developer Guide](../devGuide/KPPMix.md) +- Broader vertical mixing context: [Vertical Mixing Coefficients](./VerticalMixingCoeff.md) + +## 2 Requirements + +### 2.1 Requirement: Boundary-layer depth from bulk Richardson criterion + +The OBL depth must be diagnosed from a bulk Richardson criterion so that +mixing depth responds to evolving stratification, shear, and surface forcing. + +### 2.2 Requirement: Coefficients must be computable in parallel over columns + +The KPP implementation must operate over many columns in parallel using OMEGA +array/kernels, rather than serial single-column calls, to match accelerator +performance goals. + +### 2.3 Requirement: Compatible with additive vertical-mixing framework + +KPP viscosity/diffusivity fields must be compatible with existing OMEGA vertical +mixing infrastructure so they can be merged with other configured contributions. + +### 2.4 Desired: Optional non-local flux and profile matching controls + +KPP should support optional non-local tracer flux profiles and configurable +matching/interpolation choices to support scientific tuning studies. + +### 2.5 Desired: Stable RK4 interaction + +For RK4, KPP should be computed in a way that avoids repeated stage re-evaluation +when configuration requires a single post-stage update on the fully updated +state. + +## 3 Algorithmic Formulation + +The implementation follows a two-stage KPP structure. + +### 3.1 Stage 1: OBL depth search + +For each water column, OBL depth $h$ is diagnosed by searching downward until +bulk Richardson number reaches a critical value: + +$$ +Ri_b(z) = \frac{\Delta b(z)\, z}{|\Delta \mathbf{U}(z)|^2 + V_t^2(z)} +$$ + +with threshold + +$$ +Ri_b(h) = Ri_{crit}. +$$ + +Here, $\Delta b$ is buoyancy jump relative to the near-surface reference, +$|\Delta \mathbf{U}|^2$ is shear contribution, and $V_t^2$ is unresolved shear. +The code supports interpolation/matching choices near the crossing and applies +configured constraints such as minimum OBL under sea ice and a maximum by water +column depth. + +### 3.2 Stage 2: KPP coefficients and optional non-local flux + +Given diagnosed $h$, KPP computes interface coefficients using shape functions +in normalized depth $\sigma = -z/h$: + +$$ +K_m(\sigma) = h\, w_m(\sigma)\, M_1(\sigma), +$$ + +$$ +K_s(\sigma) = h\, w_s(\sigma)\, S_1(\sigma), +$$ + +where $w_m$ and $w_s$ are turbulent velocity scales from Monin-Obukhov style +stability functions. Optional non-local tracer flux shape $G(\sigma)$ is +computed when enabled. + +Below OBL, coefficients revert to configured background values, with optional +enhanced diffusion handling near the OBL base. + +## 4 Design + +### 4.1 Data types and parameters + +#### 4.1.1 Parameters + +KPP is configured from the `VertMix: KPP` YAML group. Key parameters include: + +- `Enable` +- `UseNonLocalFlux` +- `CriticalBulkRichardsonNumber` +- `MatchTechnique` +- `InterpType2` +- `UseEnhancedDiffusion` +- `IceFractionThresholdForLangmuir` +- `IceFractionThresholdForMinimumOBL` +- `MinimumOBLUnderSeaIce` +- `BackgroundViscosity` +- `BackgroundDiffusivity` +- `DebugDiagnostics` + +Defaults and usage examples are documented in the user guide page: +[KPP in the User Guide](../userGuide/KPPMix.md). + +#### 4.1.2 Class/data structure + +`KPPMix` is a singleton that owns persistent output fields, including: + +- `BoundaryLayerDepth`, `IndexBoundaryLayerDepth` +- `VertDiff`, `VertVisc` +- `VertNonLocalFlux` +- diagnostics such as `BulkRichardsonNumber`, `BulkRichardsonShear`, + `UnresolvedShear`, `BuoyancyJump`, and `TurbulentVelocityScale` + +### 4.2 Methods + +Main interface: + +```c++ +void computeKPPMix(const Array2DReal &PotentialDensity, + const Array2DReal &NormalVelocity, + const Array2DReal &TangentialVelocity, + const Array1DReal &SurfaceFrictionVelocity, + const Array1DReal &SurfaceBuoyancyFlux, + const Array2DReal &BruntVaisalaFreqSq, + const Array1DReal &IceFraction, + const Array1DReal &WindSpeed10m = Array1DReal()); +``` + +Internal stages: +- `computeOBLDepth(...)` +- `computeMixingCoefficients(...)` + +### 4.3 RK4 coupling behavior + +In OMEGA RK4 stepping, stage-level KPP recomputation can be gated off and KPP +is recomputed once after all RK4 stages on the fully updated state before +implicit vertical mixing is applied. This behavior is part of the current +coupling design and is described in detail for developers and users in: + +- [Developer KPP workflow](../devGuide/KPPMix.md) +- [User runtime notes](../userGuide/KPPMix.md) + +## 5 Verification and Testing + +### 5.1 Unit-level checks + +Use targeted tests and diagnostics to verify: + +- OBL depth search monotonicity and threshold crossing behavior +- Positive bounded coefficients and expected background behavior below OBL +- Correct enable/disable behavior for non-local flux and enhanced diffusion + +Tests cover requirements: 2.1, 2.2, 2.3, 2.4. + +### 5.2 Coupled/regression checks + +Run regression cases and compare key diagnostics over time: + +- `BoundaryLayerDepth` +- `BulkRichardsonNumber` +- `VertDiff`, `VertVisc` +- `VertNonLocalFlux` (when enabled) + +For full OMEGA testing workflow, see the developer testing guide: +[Testing Code](../devGuide/Testing.md). + +### 5.3 Configuration sensitivity checks + +Perform short experiments varying: + +- `CriticalBulkRichardsonNumber` +- `MatchTechnique` +- `InterpType2` +- `UseEnhancedDiffusion` +- sea-ice thresholds + +to ensure expected qualitative and quantitative responses in OBL depth and +mixing intensity. diff --git a/components/omega/doc/devGuide/KPPMix.md b/components/omega/doc/devGuide/KPPMix.md new file mode 100644 index 000000000000..de5f30528c5f --- /dev/null +++ b/components/omega/doc/devGuide/KPPMix.md @@ -0,0 +1,132 @@ +(omega-dev-kppmix)= + +# KPP Boundary Layer Mixing + +This page maps OMEGA KPP implementation details to runtime behavior and code +locations. It complements the design page by focusing on concrete APIs, +call flow, and developer test strategy. + +Related pages: +- Design and theory: [Design KPP document](../design/KPPMix.md) +- User configuration and workflow: [User KPP guide](../userGuide/KPPMix.md) +- Broader vertical mixing: [Developer Vertical Mixing Coefficients](./VerticalMixingCoeff.md) + +## Implementation Overview + +OMEGA KPP is implemented in `KPPMix` as a singleton with two major compute +phases: + +1. OBL depth diagnosis (`computeOBLDepth`) +2. Coefficient/profile construction (`computeMixingCoefficients`) + +Main class/API surface is in `src/ocn/KPPMix.h` and implementation is in +`src/ocn/KPPMix.cpp`. + +## Runtime Call Flow + +### Tendency coupling + +KPP coupling into tendencies occurs through: + +- `Tendencies::computeAllTendencies(...)` +- `Tendencies::computeStageVerticalMixing(...)` + +`computeStageVerticalMixing(...)` assembles required inputs: + +- potential density from EOS specific volume +- Brunt-Vaisala frequency squared +- edge normal and reconstructed tangential velocity +- surface friction velocity from wind stress +- surface buoyancy flux from heat/freshwater forcing + +Then it calls: + +```c++ +KPPInstance->computeKPPMix(...) +``` + +### RK4 interaction + +Current RK4 behavior is: + +1. Disable stage KPP recompute while stepping RK sub-stages by setting + `StageVerticalMixingEnabled = false`. +2. After RK4 stage accumulation and time-level update, recompute auxiliary + state and call `computeStageVerticalMixing(...)` once on the fully updated + state. +3. Restore previous stage-mixing flag. +4. Apply implicit vertical mixing. + +This behavior is implemented in the RK4 stepper and is important for +consistency with current coupling expectations. + +## Configuration Mapping + +KPP reads configuration from the `VertMix: KPP` subgroup during `KPPMix::init`. +Important keys and class members: + +- `Enable` -> `Enabled` +- `CriticalBulkRichardsonNumber` -> `CriticalRichardson` +- `StopOBLSearch` -> `StopOBLSearchMult` +- `SurfaceLayerExtent` -> `SurfaceLayerExtent` +- `MatchTechnique` -> `MatchTechniqueStr` +- `InterpType2` -> `InterpType2Str` +- `UseEnhancedDiffusion` -> `UseEnhancedDiffusion` +- `UseLangmuirCirculation` -> `UseLangmuirCirculation` +- `UseNonLocalFlux` -> `UseNonLocalFlux` +- `IceFractionThresholdForLangmuir` -> `IceFractionThresholdForLangmuir` +- `IceFractionThresholdForMinimumOBL` -> `IceFractionThresholdForMinimumOBL` +- `MinimumOBLUnderSeaIce` -> `MinimumOBLUnderSeaIce` +- `BackgroundViscosity` -> `BackgroundVisc` +- `BackgroundDiffusivity` -> `BackgroundDiff` +- `DebugDiagnostics` -> `DebugDiagnostics` + +See [User KPP guide](../userGuide/KPPMix.md) for defaults and runnable examples. + +## Output and Diagnostic Fields + +`KPPMix::defineFields()` registers KPP outputs for I/O. Frequently used outputs: + +- `BoundaryLayerDepth` +- `VertNonLocalFlux` +- `BulkRichardsonNumber` +- `BulkRichardsonShear` +- `UnresolvedShear` +- `BuoyancyJump` +- `TurbulentVelocityScale` +- `PotentialDensity` +- `SurfaceFrictionVelocity` +- `SurfaceBuoyancyFlux` + +These can be enabled in output stream contents to diagnose OBL and profile +behavior in experiments. + +## Developer Notes + +- `MatchGradient` is currently treated as deprecated/unused and remapped to + `SimpleShapes` at init. +- Unsupported `MatchTechnique` values are guarded and fall back to + `SimpleShapes` with a log message. +- When `DebugDiagnostics` is enabled in debug builds, targeted diagnostic + logging is available; behavior is compile/build-mode aware. + +## Testing Strategy + +### Code-level checks + +1. Verify KPP initialization with explicit and default YAML keys. +2. Verify stage call path executes with KPP enabled and is skipped when + disabled. +3. Verify RK4 sequencing: no stage recompute during sub-stages, one recompute + before implicit vertical mixing. + +### Diagnostics-based checks + +1. Output `BoundaryLayerDepth`, `BulkRichardsonNumber`, and `VertDiff`. +2. Confirm OBL depth and coefficient evolution under changing forcing. +3. Validate optional outputs (`VertNonLocalFlux`) only when enabled. + +### Regression checks + +Run CTests and appropriate OMEGA regression workflows documented in: +[Testing Code](./Testing.md). diff --git a/components/omega/doc/index.md b/components/omega/doc/index.md index dfc81a284670..af49963c74a7 100644 --- a/components/omega/doc/index.md +++ b/components/omega/doc/index.md @@ -52,6 +52,7 @@ userGuide/VertCoord userGuide/PGrad userGuide/Timing userGuide/VerticalMixingCoeff +userGuide/KPPMix userGuide/VertAdv userGuide/Forcing userGuide/SfcCoupling @@ -100,6 +101,7 @@ devGuide/VertCoord devGuide/PGrad devGuide/Timing devGuide/VerticalMixingCoeff +devGuide/KPPMix devGuide/VertAdv devGuide/Forcing devGuide/SfcCoupling @@ -143,6 +145,7 @@ design/Timers design/TimeStepping design/Tracers design/TridiagonalSolver +design/KPPMix design/VertAdv design/VertCoord design/VerticalMixingCoeff diff --git a/components/omega/doc/userGuide/KPPMix.md b/components/omega/doc/userGuide/KPPMix.md new file mode 100644 index 000000000000..22f789f446e7 --- /dev/null +++ b/components/omega/doc/userGuide/KPPMix.md @@ -0,0 +1,107 @@ +(omega-kppmix)= + +# KPP Boundary Layer Mixing + +This page explains how to enable, configure, and use OMEGA K Profile +Parameterization (KPP) boundary layer mixing in runs. + +Related pages: +- KPP design/theory: [Design KPP document](../design/KPPMix.md) +- KPP implementation details: [Developer KPP document](../devGuide/KPPMix.md) +- Broader vertical mixing options: [Vertical Mixing Coefficients](./VerticalMixingCoeff.md) + +## What KPP Provides + +KPP computes: + +- Ocean boundary layer depth (`BoundaryLayerDepth`) +- Vertical viscosity (`VertVisc`) +- Vertical diffusivity (`VertDiff`) +- Optional non-local tracer flux profile (`VertNonLocalFlux`) + +It uses a bulk Richardson depth search followed by profile-based coefficient +construction. + +## How KPP Is Used in Time Stepping + +In the current RK4 workflow, stage-level KPP recomputation is gated off during +RK4 sub-stages and KPP is recomputed once after all four stages, on the fully +updated state, before implicit vertical mixing is applied. + +This means KPP diagnostics in output correspond to the post-stage state for +each RK4 step. + +## Configuration + +KPP settings are under `VertMix: KPP` in `omega.yml`. + +### Example + +```yaml +VertMix: + KPP: + Enable: true + UseNonLocalFlux: true + CriticalBulkRichardsonNumber: 0.25 + MatchTechnique: SimpleShapes + InterpType2: LMD94 + UseEnhancedDiffusion: true + IceFractionThresholdForLangmuir: 0.05 + IceFractionThresholdForMinimumOBL: 0.15 + MinimumOBLUnderSeaIce: 5.0 + DebugDiagnostics: false +``` + +### Key Options + +| Key | Meaning | Typical default | +|---|---|---| +| `Enable` | Enable KPP mixing | `true` | +| `UseNonLocalFlux` | Enable non-local tracer flux profile | `true` | +| `CriticalBulkRichardsonNumber` | OBL depth criterion threshold | `0.25` | +| `MatchTechnique` | KPP profile matching mode | `SimpleShapes` | +| `InterpType2` | Interpolation type used near OBL matching/base logic | `LMD94` | +| `UseEnhancedDiffusion` | Enable enhanced diffusion treatment near OBL base | `true` | +| `IceFractionThresholdForLangmuir` | Above this ice fraction, disable Langmuir enhancement | `0.05` | +| `IceFractionThresholdForMinimumOBL` | Above this ice fraction, enforce minimum OBL depth | `0.15` | +| `MinimumOBLUnderSeaIce` | Minimum OBL depth under sea ice (m) | `5.0` | +| `DebugDiagnostics` | Enable additional KPP diagnostics/logging in debug workflows | `false` | + +KPP also uses background coefficients from: + +```yaml +VertMix: + Background: + Viscosity: 1.0e-4 + Diffusivity: 1.0e-5 +``` + +## Output and Diagnostics + +To diagnose KPP, include KPP fields in output stream contents. Common fields: + +- `BoundaryLayerDepth` +- `VertNonLocalFlux` +- `BulkRichardsonNumber` +- `BulkRichardsonShear` +- `UnresolvedShear` +- `BuoyancyJump` +- `TurbulentVelocityScale` +- `PotentialDensity` +- `SurfaceFrictionVelocity` +- `SurfaceBuoyancyFlux` + +## Typical Workflow + +1. Enable KPP and set baseline options in `omega.yml`. +2. Run a short case. +3. Inspect `BoundaryLayerDepth` and coefficient fields. +4. If needed, tune `CriticalBulkRichardsonNumber`, `MatchTechnique`, and + `InterpType2`. +5. Re-run and compare diagnostics. + +## Practical Notes + +- Keep `UseNonLocalFlux` enabled when you want tracer non-local transport. +- Use `DebugDiagnostics` sparingly for troubleshooting targeted cases. +- When studying sea-ice regions, review minimum-OBL and ice-threshold options. diff --git a/components/omega/src/ocn/KPPConstants.h b/components/omega/src/ocn/KPPConstants.h new file mode 100644 index 000000000000..80de93ae0e0b --- /dev/null +++ b/components/omega/src/ocn/KPPConstants.h @@ -0,0 +1,409 @@ +#ifndef OMEGA_KPP_CONSTANTS_H +#define OMEGA_KPP_CONSTANTS_H +//===-- ocn/KPPConstants.h - KPP Constants and Profiles --------*- C++ -*-===// +// +/// \file +/// \brief KPP-specific constants, parameters, and profile functions +/// +/// This header defines constants, parameters, and inline functions for the +/// K-Profile Parameterization boundary layer mixing scheme. Includes +/// non-dimensional profile functions used in KPP coefficient calculations. +// +//===----------------------------------------------------------------------===// + +#include "GlobalConstants.h" +#include "OmegaKokkos.h" + +namespace OMEGA::KPP { + +// ========================================================================== +// Physical Parameters for KPP +// ========================================================================== + +/// Critical bulk Richardson number for defining OBL (Large et al. 1994) +constexpr Real RICRIT = 0.3; + +/// Parameter for smoothing velocity shear profiles +constexpr Real ZETA_M_SCALE = VonKar; // Momentum scale (normalized) +constexpr Real ZETA_S_SCALE = 0.16; // Tracer/salt scale +constexpr Real ZETA_T_SCALE = 0.16; // Temperature scale + +// ========================================================================== +// Monin-Obukhov stability function parameters (Large et al. 1994, App. B) +// Transition thresholds between weakly and strongly unstable regimes +// ========================================================================== + +/// Transition zeta for momentum: below this value, strongly-unstable formula +/// is used. Default: -0.2 (CVMix default). +constexpr Real ZETA_M = -0.2_Real; + +/// Transition zeta for scalars: below this value, strongly-unstable formula +/// is used. Default: -1.0 (CVMix default). +constexpr Real ZETA_S = -1.0_Real; + +/// Derived constants for phi_m^{-1} strongly-unstable branch (momentum). +/// a_m = (1-16*ZETA_M)^{-0.25} * (1 - 4*ZETA_M) +constexpr Real A_MO_M = 1.2573615702_Real; +/// c_m = (1-16*ZETA_M)^{-0.25} * 12 +constexpr Real C_MO_M = 8.3824104679_Real; + +/// Derived constants for phi_s^{-1} strongly-unstable branch (scalar). +/// a_s = sqrt(1-16*ZETA_S) * (1 + 8*ZETA_S) (can be negative) +constexpr Real A_MO_S = -28.8617393793_Real; +/// c_s = 24 * sqrt(1-16*ZETA_S) +constexpr Real C_MO_S = 98.9545350148_Real; + +/// Surface mixing coefficients +constexpr Real HUON = 0.03; // Surface momentum mixing parameter +constexpr Real BD = 1.0; // Buoyancy parameter (dimensionless) +constexpr Real C1 = 0.112; // Langmuir circulation parameter + +/// Langmuir enhancement factor parameters +constexpr Real PEC_LANGMUIR = 0.5; // Peclet number for Langmuir + +/// Minimum/maximum bounds on friction velocity values +constexpr Real MIN_USTAR = 1.0e-4; // Minimum friction velocity (m/s) +constexpr Real MAX_USTAR = 1.0; // Upper limit to clamp rare extremes + +/// Maximum vertical levels (for static allocations if needed) +constexpr I4 NLEV_MAX = 500; + +// ========================================================================== +// OBL Depth Computation Parameters +// ========================================================================== + +/// Safety multiplier for OBL search (prevents searching too deep) +/// Default: 1.0 (search to 1.0 * Ri_crit threshold) +constexpr Real STOP_OBL_SEARCH_MULT = 1.0; + +/// Minimum OBL depth (m) +constexpr Real MIN_OBL_DEPTH = 2.0; + +/// Minimum OBL under sea ice (m) when ice fraction > 0.15 +constexpr Real MIN_OBL_UNDER_ICE = 5.0; + +/// Ice fraction threshold below which OBL is fully computed +constexpr Real ICE_FRACTION_THRESHOLD = 0.05; + +/// Ice fraction for triggering minimum OBL enforcement +constexpr Real ICE_SUPPRESSION_THRESHOLD = 0.15; + +// ========================================================================== +// Surface Layer Parameters +// ========================================================================== + +/// Surface layer extent (fraction of OBL depth) +/// Used for averaging turbulent scales near surface +constexpr Real SURFACE_LAYER_EXTENT = 0.1; + +/// Number of smoothing passes for Richardson number (reduce noise) +constexpr I4 RI_SMOOTH_LOOPS = 2; + +/// Prandtl number for converting momentum viscosity to tracer diffusivity +constexpr Real PRANDTL_NUMBER = 1.0; + +// ========================================================================== +// KPP Profile Functions +// ========================================================================== + +/// @brief G(sigma) - Non-local flux profile function +/// Non-zero only within the OBL. Applied to surface tracer fluxes. +/// REFERENCES: Large et al. (1994) Eq. (12)-(13), Large et al. (1997) +/// +/// @param sigma Normalized vertical position (-z/h), 0 at surface, -1 at base +/// @return G(sigma) dimensionless profile value +KOKKOS_INLINE_FUNCTION +Real KPPProfileG(Real sigma) { + // Omega uses sigma in [-1,0]. Convert to CVMix sigma_mu in [0,1] + // where sigma_mu=0 at surface and sigma_mu=1 at OBL base. + sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); + + const Real sigma_mu = -sigma; + return sigma_mu * (1.0 - sigma_mu) * (1.0 - sigma_mu); +} + +/// @brief G_pnl(sigma) - Parabolic non-local flux profile +/// Used only for non-local tracer flux when matching option is +/// "ParabolicNonLocal". This must NOT be used for KPP viscosity/diffusivity. +/// +/// @param sigma Normalized vertical position (-z/h), 0 at surface, -1 at base +/// @return Dimensionless non-local profile value +KOKKOS_INLINE_FUNCTION +Real KPPProfileGParabolicNonLocal(Real sigma) { + sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); + + // Parabolic non-local option in CVMix is (1-sigma_mu)^2. + const Real sigma_mu = -sigma; + const Real one_minus = 1.0 - sigma_mu; + return one_minus * one_minus; +} + +/// @brief G_matchboth(sigma) - Cubic LMD-style non-local profile +/// Used for MatchBoth to distinguish it from SimpleShapes without relying on +/// external CVMix calls. In sigma_mu coordinates this is: +/// G = (1 - sigma_mu)^2 * (1 + 2*sigma_mu) +/// where sigma_mu in [0,1] is 0 at surface and 1 at OBL base. +/// +/// @param sigma Normalized vertical position (-z/h), 0 at surface, -1 at base +/// @return Dimensionless non-local profile value +KOKKOS_INLINE_FUNCTION +Real KPPProfileGMatchBoth(Real sigma) { + sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); + + const Real sigma_mu = -sigma; + const Real one_minus = 1.0 - sigma_mu; + return one_minus * one_minus * (1.0 + 2.0 * sigma_mu); +} + +/// @brief M1(sigma) - Momentum mixing profile function +/// Multiplies friction velocity and turbulent velocity scale +/// REFERENCES: Large et al. (1994) Eq. (11) +/// +/// @param sigma Normalized vertical position (-z/h) +/// @return K*w_s profile multiplier (dimensionless) +KOKKOS_INLINE_FUNCTION +Real KPPProfileM1(Real sigma) { + // CVMix simple gradient shape: sigma_mu*(1-sigma_mu)^2. + sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); + + const Real sigma_mu = -sigma; + return sigma_mu * (1.0 - sigma_mu) * (1.0 - sigma_mu); +} + +/// @brief Matched KPP gradient profile shape. +/// +/// Uses the SimpleShapes gradient profile plus a smooth correction that is +/// zero at the surface and equals ShapeAtBase at the OBL base. This lets +/// MatchBoth profiles meet pre-existing interior mixing at the BLD base while +/// preserving SimpleShapes behavior when ShapeAtBase is zero. +KOKKOS_INLINE_FUNCTION +Real KPPProfileMatched(Real sigma, Real ShapeAtBase) { + sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); + + const Real sigma_mu = -sigma; + const Real simple = sigma_mu * (1.0 - sigma_mu) * (1.0 - sigma_mu); + const Real smooth = sigma_mu * sigma_mu * (3.0 - 2.0 * sigma_mu); + return simple + ShapeAtBase * smooth; +} + +/// @brief phi_m^{-1}(zeta) - Inverse momentum Monin-Obukhov stability function +/// Multiplied by von Karman constant and friction velocity to give turbulent +/// momentum velocity scale: w_m = kappa * u* * phi_m^{-1}(zeta) +/// Three-regime formulation per Large et al. (1994) Appendix B and CVMix. +/// +/// @param zeta Monin-Obukhov stability coordinate (dimensionless) +/// @return phi_m^{-1} (dimensionless, > 0) +KOKKOS_INLINE_FUNCTION +Real KPPProfileM2(Real zeta) { + if (zeta >= 0.0_Real) { + // Stable regime + return 1.0_Real / (1.0_Real + 5.0_Real * zeta); + } else if (zeta >= ZETA_M) { + // Weakly unstable: (1 - 16*zeta)^{1/4} + return Kokkos::pow(1.0_Real - 16.0_Real * zeta, 0.25_Real); + } else { + // Strongly unstable: (a_m - c_m*zeta)^{1/3} + return Kokkos::pow(A_MO_M - C_MO_M * zeta, 1.0_Real / 3.0_Real); + } +} + +/// @brief S1(sigma) - Tracer/scalar mixing profile function +/// Similar to momentum but computed separately for tracers +/// REFERENCES: Large et al. (1994) Eq. (11) +/// +/// @param sigma Normalized vertical position +/// @return K*w_s profile multiplier for tracers (dimensionless) +KOKKOS_INLINE_FUNCTION +Real KPPProfileS1(Real sigma) { + // CVMix simple gradient shape for tracers: sigma_mu*(1-sigma_mu)^2. + sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); + + const Real sigma_mu = -sigma; + return sigma_mu * (1.0 - sigma_mu) * (1.0 - sigma_mu); +} + +/// @brief phi_s^{-1}(zeta) - Inverse scalar Monin-Obukhov stability function +/// Multiplied by von Karman constant and friction velocity to give turbulent +/// scalar velocity scale: w_s = kappa * u* * phi_s^{-1}(zeta) +/// Three-regime formulation per Large et al. (1994) Appendix B and CVMix. +/// Note: scalar and momentum exponents differ in the weakly-unstable regime. +/// +/// @param zeta Monin-Obukhov stability coordinate (dimensionless) +/// @return phi_s^{-1} (dimensionless, > 0) +KOKKOS_INLINE_FUNCTION +Real KPPProfileS2(Real zeta) { + if (zeta >= 0.0_Real) { + // Stable regime + return 1.0_Real / (1.0_Real + 5.0_Real * zeta); + } else if (zeta >= ZETA_S) { + // Weakly unstable: (1 - 16*zeta)^{1/2} (scalar uses 1/2, not 1/4) + return Kokkos::sqrt(1.0_Real - 16.0_Real * zeta); + } else { + // Strongly unstable: (a_s - c_s*zeta)^{1/3} + return Kokkos::pow(A_MO_S - C_MO_S * zeta, 1.0_Real / 3.0_Real); + } +} + +/// @brief Hu(sigma) - Momentum surface value scaling +/// Sets surface boundary condition for momentum mixing +/// REFERENCES: Large et al. (1994) +/// +/// @param sigma Normalized vertical position +/// @return Normalized profile value +KOKKOS_INLINE_FUNCTION +Real KPPHu(Real sigma) { + // At sigma=0 (surface), should return HUON=0.03 + // Simple linear decay: Hu(sigma) = HUON * (1 + sigma) + return HUON * (1.0 + sigma); +} + +// ========================================================================== +// Langmuir Enhancement Factor (Theory-based Wave Model) +// ========================================================================== + +/// @brief Estimate Stokes drift velocity scale from wind speed +/// Theory-based approach (no active wave data needed) +/// REFERENCES: Li et al. 2016, cvmix_kpp_ustokes_SL_model +/// +/// @param wind10m Wind speed at 10 m height (m/s) +/// @param h_bl Boundary layer depth (m) +/// @return Stokes drift velocity scale (m/s) +KOKKOS_INLINE_FUNCTION +Real EstokesSLModel(Real wind10m, Real h_bl) { + // u_s,BL = (u_10/362.0) * sqrt(2*alpha*cd) * lambda/h_bl + // Simplified: alpha=0.84, cd ~ 1.2e-3, lambda ~ 2pi*g/w^2 + wind10m = Kokkos::fmax(0.0, wind10m); + h_bl = Kokkos::fmax(1.0, h_bl); + + // Approximate relation from Li et al. + const Real C_drag = 1.2e-3; + const Real alpha_wave = 0.84; + + // Typical Stokes drift scale + Real u_s = 0.016 * wind10m; // Simplified; sqrt(2*alpha*C_d)*(wind/g) + + return Kokkos::fmax(0.0, u_s); +} + +/// @brief Langmuir number from friction velocity and Stokes drift +/// REFERENCES: Large et al. 2015 Eq. 6 +/// +/// @param u_star Friction velocity (m/s) +/// @param u_stokes Stokes drift at surface (m/s) +/// @return Langmuir number (dimensionless) +KOKKOS_INLINE_FUNCTION +Real ComputeLangmuirNumber(Real u_star, Real u_stokes) { + u_star = Kokkos::fmax(MIN_USTAR, u_star); + u_stokes = Kokkos::fmax(1.0e-8, u_stokes); + + // La = sqrt(u_star / u_stokes) + return Kokkos::sqrt(u_star / u_stokes); +} + +/// @brief Langmuir enhancement factor for KPP from wind +/// Theory-based approach: depends on Langmuir number +/// REFERENCES: Li et al. (2016) Eq. 1-3 +/// +/// @param wind10m Wind speed at 10 m (m/s) +/// @param u_star Friction velocity (m/s) +/// @param h_bl Boundary layer depth (m) +/// @return Enhancement factor R_L (dimensionless, > 1.0 enhances mixing) +KOKKOS_INLINE_FUNCTION +Real ComputeEnhancementFactor(Real wind10m, Real u_star, Real h_bl) { + u_star = Kokkos::fmax(MIN_USTAR, u_star); + wind10m = Kokkos::fmax(0.0, wind10m); + h_bl = Kokkos::fmax(1.0, h_bl); + + // Estimate Stokes drift from wind + Real u_stokes = EstokesSLModel(wind10m, h_bl); + + // Compute Langmuir number + Real la = ComputeLangmuirNumber(u_star, u_stokes); + + // Enhancement factor: R_L = sqrt(1 + 0.5 * (u_stokes/u_star)^2) + // Alternative form based on Langmuir number: + // R_L = sqrt(1 + 0.5 / La^2) for La > 0.5 + Real la_inv = 1.0 / Kokkos::fmax(0.5, la); + Real r_l = Kokkos::sqrt(1.0 + 0.5 * la_inv * la_inv); + + // Clamp to reasonable range [1.0, 2.0] + return Kokkos::fmin(2.0, Kokkos::fmax(1.0, r_l)); +} + +// ========================================================================== +// Utility Functions for OBL Depth Computation +// ========================================================================== + +/// @brief Check if a point should be suppressed (e.g., under ice) +/// Sets OBL to minimum if ice coverage or land ice present +/// +/// @param ice_fraction Sea ice coverage (0-1) +/// @param land_ice_mask Land ice mask (0=ocean, non-zero=ice) +/// @return True if suppression applies +KOKKOS_INLINE_FUNCTION +bool ShouldSuppressOBL(Real ice_fraction, I4 land_ice_mask) { + return (land_ice_mask != 0) || (ice_fraction > ICE_SUPPRESSION_THRESHOLD); +} + +/// @brief Apply OBL depth constraints based on column properties +/// +/// @param h_obl Current OBL depth (m) +/// @param layer_thickness Surface layer thickness (m) +/// @param water_depth Total water depth (m) +/// @param ice_fraction Sea ice coverage (0-1) +/// @return Constrained OBL depth (m) +KOKKOS_INLINE_FUNCTION +Real ConstrainOBLDepth(Real h_obl, Real layer_thickness, Real water_depth, + Real ice_fraction) { + // Lower bound: at least half the surface layer thickness + h_obl = Kokkos::fmax(h_obl, layer_thickness * 0.5); + + // Enforce minimum under ice + if (ice_fraction > ICE_SUPPRESSION_THRESHOLD) { + h_obl = Kokkos::fmax(h_obl, MIN_OBL_UNDER_ICE); + } + + // Upper bound: cannot exceed water depth + h_obl = Kokkos::fmin(h_obl, water_depth * 0.95); + + return h_obl; +} + +// ========================================================================== +// Turbulent Velocity Scale Computation +// ========================================================================== + +/// @brief Compute turbulent velocity scale (w_s) +/// Combined velocity scale for momentum and buoyancy +/// REFERENCES: Large et al. (1994) Eq. (9)-(10) +/// +/// @param u_star Friction velocity (m/s) +/// @param b0 Surface buoyancy flux (m²/s³) +/// @param h_obl Boundary layer depth (m) +/// @return Turbulent velocity scale w_s (m/s) +KOKKOS_INLINE_FUNCTION +Real ComputeTurbulentVelocityScale(Real u_star, Real b0, Real h_obl) { + u_star = Kokkos::fmax(0.0_Real, u_star); + h_obl = Kokkos::fmax(0.0_Real, h_obl); + + // w_s = (u_star^3 + 0.35 * b0 * h_obl)^(1/3) + // Note: v_t = 0.35 in KPP (empirical constant) + const Real v_t = 0.35; + + // Momentum contribution + const Real w_m = u_star * u_star * u_star; + + // Buoyancy contribution for unstable (cooling/densifying) forcing. + // In this sign convention, free convection corresponds to b0 < 0. + const Real w_b = v_t * Kokkos::fmax(0.0_Real, -b0) * h_obl; + + // Combined scale + const Real w_s = + Kokkos::pow(Kokkos::fmax(0.0_Real, w_m + w_b), 1.0_Real / 3.0_Real); + + return w_s; +} + +} // namespace OMEGA::KPP + +#endif // OMEGA_KPP_CONSTANTS_H diff --git a/components/omega/src/ocn/KPPMix.cpp b/components/omega/src/ocn/KPPMix.cpp new file mode 100644 index 000000000000..c39ad7253ba1 --- /dev/null +++ b/components/omega/src/ocn/KPPMix.cpp @@ -0,0 +1,1405 @@ +//===-- ocn/KPPMix.cpp - KPP Boundary Layer Mixing Implementation --*- C++ +//-*-===// +// +/// \file +/// \brief Implementation of KPP boundary layer mixing scheme +/// +/// This file implements the KPPMix class for computing ocean boundary layer +/// mixing following Large et al. (1994) with optional Langmuir enhancement. +// +//===----------------------------------------------------------------------===// + +#include "KPPMix.h" +#include "DataTypes.h" +#include "Error.h" +#include "FillValues.h" +#include "GlobalConstants.h" +#include "KPPConstants.h" +#include "Logging.h" +#include "OmegaKokkos.h" +#include "VertCoord.h" +#include + +namespace OMEGA { + +// Singleton instance +KPPMix *KPPMix::Instance = nullptr; + +/// Constructor for KPPMix +KPPMix::KPPMix(const std::string &Name_in, const HorzMesh *Mesh_in, + const VertCoord *VCoord_in) + : Name(Name_in), Mesh(Mesh_in), VCoord(VCoord_in) { + + // Allocate output arrays + VertDiff = Array2DReal("VertDiff", Mesh->NCellsAll, VCoord->NVertLayers + 1); + VertVisc = Array2DReal("VertVisc", Mesh->NCellsAll, VCoord->NVertLayers + 1); + BoundaryLayerDepth = Array1DReal("BoundaryLayerDepth", Mesh->NCellsAll); + IndexBoundaryLayerDepth = + Array1DI4("IndexBoundaryLayerDepth", Mesh->NCellsAll); + VertNonLocalFlux = Array2DReal("VertNonLocalFlux", Mesh->NCellsAll, + VCoord->NVertLayers + 1); + BulkRichardsonNumber = Array2DReal("BulkRichardsonNumber", Mesh->NCellsAll, + VCoord->NVertLayers + 1); + BulkRichardsonShear = Array2DReal("BulkRichardsonShear", Mesh->NCellsAll, + VCoord->NVertLayers + 1); + UnresolvedShear = + Array2DReal("UnresolvedShear", Mesh->NCellsAll, VCoord->NVertLayers + 1); + BuoyancyJump = + Array2DReal("BuoyancyJump", Mesh->NCellsAll, VCoord->NVertLayers + 1); + TurbulentVelocityScale = Array2DReal( + "TurbulentVelocityScale", Mesh->NCellsAll, VCoord->NVertLayers + 1); + PotentialDensity = + Array2DReal("PotentialDensity", Mesh->NCellsAll, VCoord->NVertLayers); + SurfaceFrictionVelocity = + Array1DReal("SurfaceFrictionVelocity", Mesh->NCellsAll); + SurfaceBuoyancyFlux = Array1DReal("SurfaceBuoyancyFlux", Mesh->NCellsAll); + + // Set field names + VertDiffFldName = "VertDiff"; + VertViscFldName = "VertVisc"; + OBLDepthFldName = "BoundaryLayerDepth"; + OBLDepthIndexFldName = "BoundaryLayerDepthIndex"; + NonLocalFluxFldName = "VertNonLocalFlux"; + BulkRichardsonFldName = "BulkRichardsonNumber"; + BulkRichardsonShearFldName = "BulkRichardsonShear"; + UnresolvedShearFldName = "UnresolvedShear"; + BuoyancyJumpFldName = "BuoyancyJump"; + TurbulentVelScaleFldName = "TurbulentVelocityScale"; + PotentialDensityFldName = "PotentialDensity"; + SurfFricVelFldName = "SurfaceFrictionVelocity"; + SurfBuoyFluxFldName = "SurfaceBuoyancyFlux"; + + if (Name != "Default") { + VertDiffFldName.append(Name); + VertViscFldName.append(Name); + OBLDepthFldName.append(Name); + OBLDepthIndexFldName.append(Name); + NonLocalFluxFldName.append(Name); + BulkRichardsonFldName.append(Name); + BulkRichardsonShearFldName.append(Name); + UnresolvedShearFldName.append(Name); + BuoyancyJumpFldName.append(Name); + TurbulentVelScaleFldName.append(Name); + PotentialDensityFldName.append(Name); + SurfFricVelFldName.append(Name); + SurfBuoyFluxFldName.append(Name); + } + + defineFields(); +} + +/// Destructor for KPPMix +KPPMix::~KPPMix() {} + +/// Get singleton instance +KPPMix *KPPMix::getInstance() { return Instance; } + +/// Destroy singleton instance +void KPPMix::destroyInstance() { + delete Instance; + Instance = nullptr; +} + +/// Initialize KPPMix from configuration +void KPPMix::init() { + if (!Instance) { + Instance = new KPPMix("Default", HorzMesh::getDefault(), + VertCoord::getDefault()); + } + + Error Err; + KPPMix *DefKPPMix = KPPMix::getInstance(); + Config *OmegaConfig = Config::getOmegaConfig(); + + // Get VertMix config group + Config VertMixConfig("VertMix"); + Err += OmegaConfig->get(VertMixConfig); + CHECK_ERROR_ABORT(Err, "KPPMix::init: VertMix group not found in Config"); + + // Get KPP config subgroup + Config KPPConfig("KPP"); + Err += VertMixConfig.get(KPPConfig); + if (Err.isFail()) { + LOG_WARN("KPPMix::init: KPP subgroup not found, using defaults"); + return; // Continue with defaults + } + + // Read KPP parameters + bool enable = true; + Err += KPPConfig.get("Enable", enable); + DefKPPMix->Enabled = enable; + + Err += KPPConfig.get("CriticalBulkRichardsonNumber", + DefKPPMix->CriticalRichardson); + Err += KPPConfig.get("StopOBLSearch", DefKPPMix->StopOBLSearchMult); + Err += KPPConfig.get("SurfaceLayerExtent", DefKPPMix->SurfaceLayerExtent); + + // KPP matching/profile semantics. + Error MatchErr = + KPPConfig.get("MatchTechnique", DefKPPMix->MatchTechniqueStr); + if (!MatchErr.isSuccess()) { + MatchErr.reset(); + } + Error InterpErr = KPPConfig.get("InterpType2", DefKPPMix->InterpType2Str); + if (!InterpErr.isSuccess()) { + InterpErr.reset(); + } + Error EnhancedErr = + KPPConfig.get("UseEnhancedDiffusion", DefKPPMix->UseEnhancedDiffusion); + if (!EnhancedErr.isSuccess()) { + EnhancedErr.reset(); + } + Error BLDSmoothErr = + KPPConfig.get("UseBLDSmoothing", DefKPPMix->UseBLDSmoothing); + if (!BLDSmoothErr.isSuccess()) { + BLDSmoothErr.reset(); + } + + // Keep active options focused on what is used in OMEGA. + if (DefKPPMix->MatchTechniqueStr == "MatchGradient") { + LOG_INFO("KPPMix::init: MatchGradient is deprecated/unused in OMEGA; " + "mapping to SimpleShapes"); + DefKPPMix->MatchTechniqueStr = "SimpleShapes"; + } + if (DefKPPMix->MatchTechniqueStr != "SimpleShapes" && + DefKPPMix->MatchTechniqueStr != "MatchBoth" && + DefKPPMix->MatchTechniqueStr != "ParabolicNonLocal") { + LOG_INFO( + "KPPMix::init: Unsupported MatchTechnique='{}', using SimpleShapes", + DefKPPMix->MatchTechniqueStr); + DefKPPMix->MatchTechniqueStr = "SimpleShapes"; + } + + // Wave and flux options + Err += KPPConfig.get("UseLangmuirCirculation", + DefKPPMix->UseLangmuirCirculation); + Err += KPPConfig.get("UseNonLocalFlux", DefKPPMix->UseNonLocalFlux); + Err += KPPConfig.get("IceFractionThresholdForLangmuir", + DefKPPMix->IceFractionThresholdForLangmuir); + Err += KPPConfig.get("IceFractionThresholdForMinimumOBL", + DefKPPMix->IceFractionThresholdForMinimumOBL); + Err += + KPPConfig.get("MinimumOBLUnderSeaIce", DefKPPMix->MinimumOBLUnderSeaIce); + Error DebugErr = + KPPConfig.get("DebugDiagnostics", DefKPPMix->DebugDiagnostics); + if (!DebugErr.isSuccess()) { + DebugErr.reset(); + DefKPPMix->DebugDiagnostics = false; + } + if (DefKPPMix->DebugDiagnostics) { + LOG_WARN("KPP debug diagnostics enabled"); + } + + // Background mixing + Err += KPPConfig.get("BackgroundViscosity", DefKPPMix->BackgroundVisc); + Err += KPPConfig.get("BackgroundDiffusivity", DefKPPMix->BackgroundDiff); + + LOG_WARN("KPPMix::init: KPP initialized enabled={} debugDiagnostics={} " + "match={}", + DefKPPMix->Enabled, DefKPPMix->DebugDiagnostics, + DefKPPMix->MatchTechniqueStr); +} + +/// Main computation routine +void KPPMix::computeKPPMix(const Array2DReal &PotentialDensity, + const Array2DReal &NormalVelocity, + const Array2DReal &TangentialVelocity, + const Array1DReal &SurfaceFrictionVelocity, + const Array1DReal &SurfaceBuoyancyFlux, + const Array2DReal &BruntVaisalaFreqSq, + const Array1DReal &IceFraction, + const Array1DReal &WindSpeed10m) { + + if (!Enabled) { + return; // Skip if disabled + } + + // Retain PotentialDensity for diagnostics/stream output. + deepCopy(this->PotentialDensity, PotentialDensity); + + // ======================================================================= + // Stage 1: Compute OBL Depth + // ======================================================================= + computeOBLDepth(PotentialDensity, NormalVelocity, TangentialVelocity, + SurfaceFrictionVelocity, SurfaceBuoyancyFlux, + BruntVaisalaFreqSq, IceFraction, WindSpeed10m); + + // ======================================================================= + // Stage 2: Compute Mixing Coefficients + // ======================================================================= + computeMixingCoefficients(PotentialDensity, SurfaceFrictionVelocity, + SurfaceBuoyancyFlux); + + if (DebugDiagnostics) { + logDiagnostics(PotentialDensity, NormalVelocity, TangentialVelocity, + SurfaceFrictionVelocity, SurfaceBuoyancyFlux, + WindSpeed10m); + } +} + +void KPPMix::logDiagnostics(const Array2DReal &PotentialDensity, + const Array2DReal &NormalVelocity, + const Array2DReal &TangentialVelocity, + const Array1DReal &SurfaceFrictionVelocity, + const Array1DReal &SurfaceBuoyancyFlux, + const Array1DReal &WindSpeed10m) { + + using namespace KPP; + + const auto MinLayerCellH = createHostMirrorCopy(VCoord->MinLayerCell); + const auto MaxLayerCellH = createHostMirrorCopy(VCoord->MaxLayerCell); + const auto ZInterfaceH = createHostMirrorCopy(VCoord->GeomZInterface); + const auto DensityH = createHostMirrorCopy(PotentialDensity); + const auto UStarH = createHostMirrorCopy(SurfaceFrictionVelocity); + const auto B0H = createHostMirrorCopy(SurfaceBuoyancyFlux); + const auto OBLDepthH = createHostMirrorCopy(BoundaryLayerDepth); + const auto OBLIndexH = createHostMirrorCopy(IndexBoundaryLayerDepth); + const auto VertDiffH = createHostMirrorCopy(VertDiff); + const auto VertViscH = createHostMirrorCopy(VertVisc); + + // NormalVelocity and TangentialVelocity are edge-based; not accessed here + (void)NormalVelocity; + (void)TangentialVelocity; + + const int NCellsAll = Mesh->NCellsAll; + if (NCellsAll <= 0) { + return; + } + + // Domain-wide diagnostic to avoid misleading single-cell checks. + Real maxAbsB0 = 0.0_Real; + Real maxAbsUStar = 0.0_Real; + Real maxVertDiff = 0.0_Real; + Real maxVertVisc = 0.0_Real; + int maxAbsB0Cell = -1; + int maxAbsUStarCell = -1; + int maxVertDiffCell = -1; + int maxVertDiffK = -1; + int maxVertViscCell = -1; + int maxVertViscK = -1; + for (int C = 0; C < NCellsAll; ++C) { + const Real b0c = B0H(C); + const Real usc = UStarH(C); + const Real ab0 = Kokkos::abs(b0c); + const Real aus = Kokkos::abs(usc); + if (ab0 > maxAbsB0) { + maxAbsB0 = ab0; + maxAbsB0Cell = C; + } + if (aus > maxAbsUStar) { + maxAbsUStar = aus; + maxAbsUStarCell = C; + } + const int KCMin = MinLayerCellH(C); + const int KCMax = MaxLayerCellH(C) + 1; + for (int K = KCMin; K <= KCMax; ++K) { + const Real diff = VertDiffH(C, K); + const Real visc = VertViscH(C, K); + if (diff > maxVertDiff) { + maxVertDiff = diff; + maxVertDiffCell = C; + maxVertDiffK = K; + } + if (visc > maxVertVisc) { + maxVertVisc = visc; + maxVertViscCell = C; + maxVertViscK = K; + } + } + } + LOG_WARN("KPP debug domain post-coeff: max|b0|={} at cell={} max|u*|={} " + "at cell={} maxKPPDiff={} at cell={},k={} maxKPPVisc={} at " + "cell={},k={}", + maxAbsB0, maxAbsB0Cell, maxAbsUStar, maxAbsUStarCell, maxVertDiff, + maxVertDiffCell, maxVertDiffK, maxVertVisc, maxVertViscCell, + maxVertViscK); + + const int ICell = 0; + const int KMin = MinLayerCellH(ICell); + const int KMax = MaxLayerCellH(ICell); + const int NVertLayers = VCoord->NVertLayers; + + if (KMin > KMax) { + return; + } + + const int KSurf = Kokkos::min(KMin, NVertLayers - 1); + const Real rho_surf = DensityH(ICell, KSurf); + const Real u_star = UStarH(ICell); + const Real u_star_eff = Kokkos::fmax(KPP::MIN_USTAR, u_star); + const Real b0 = B0H(ICell); + Real u10 = 0.0_Real; + if (WindSpeed10m.extent(0) > 0) { + const auto Wind10mH = createHostMirrorCopy(WindSpeed10m); + u10 = Wind10mH(ICell); + } + const Real langmuir_factor = + UseLangmuirCirculation ? ComputeEnhancementFactor(u10, u_star_eff, 50.0) + : 1.0_Real; + const Real b0_eff = b0 * langmuir_factor; + + LOG_WARN("KPP debug: cell={} h_obl={} m k_obl={} u*={} b0={} b0_eff={} " + "langmuir={}", + ICell, OBLDepthH(ICell), OBLIndexH(ICell), u_star, b0, b0_eff, + langmuir_factor); + + const int KOblIface = Kokkos::min( + NVertLayers, Kokkos::max(KMin, static_cast(OBLIndexH(ICell)) + 1)); + LOG_WARN("KPP debug coeff target: cell={} k_obl={} iface={} diff={} " + "visc={}", + ICell, OBLIndexH(ICell), KOblIface, VertDiffH(ICell, KOblIface), + VertViscH(ICell, KOblIface)); + + const int KTop = Kokkos::min(KMax, KMin + 3); + const int k_obl = OBLIndexH(ICell); + const Real h_obl = OBLDepthH(ICell); + + for (int K = KMin; K <= KTop; ++K) { + const int kCell = Kokkos::min(K, NVertLayers - 1); + const int kInt = Kokkos::min(K + 1, NVertLayers); + const Real z_depth = Kokkos::abs(ZInterfaceH(ICell, kInt)); + + const Real rho_k = DensityH(ICell, kCell); + const Real delta_rho = rho_k - rho_surf; + const Real delta_b = Gravity * delta_rho / RhoSw; + const Real w_turb = + ComputeTurbulentVelocityScale(u_star_eff, b0_eff, z_depth); + const Real ri_b = delta_b * z_depth / (w_turb * w_turb + 1.0e-12_Real); + + Real sigma = 0.0_Real; + if (K <= k_obl) { + sigma = -1.0_Real * static_cast(K - KMin) / + static_cast(k_obl - KMin + 1); + sigma = Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, sigma)); + } + + const Real z_local = -sigma * h_obl; + Real zeta = 0.0_Real; + const Real denom = VonKar * b0; + if (Kokkos::abs(denom) > 1.0e-16_Real) { + const Real l_mo = (u_star_eff * u_star_eff * u_star_eff) / denom; + if (Kokkos::abs(l_mo) > 1.0e-16_Real) { + zeta = z_local / l_mo; + } + } + + const Real phi_m = KPP::KPPProfileM2(zeta); + const Real phi_s = KPP::KPPProfileS2(zeta); + + LOG_WARN( + "KPP debug top: cell={} k={} z={} ri_b={} zeta={} phi_m={} phi_s={}", + ICell, K, z_depth, ri_b, zeta, phi_m, phi_s); + } +} + +/// Stage 1: Compute OBL depth using bulk Richardson search with edge-based +/// velocity shear following the MPAS CVMix reference implementation +/// (mpas_ocn_vmix_cvmix.F) +void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, + const Array2DReal &NormalVelocity, + const Array2DReal &TangentialVelocity, + const Array1DReal &SurfaceFrictionVelocity, + const Array1DReal &SurfaceBuoyancyFlux, + const Array2DReal &BruntVaisalaFreqSq, + const Array1DReal &IceFraction, + const Array1DReal &WindSpeed10m) { + + using namespace KPP; + + I4 NVertLayers = VCoord->NVertLayers; + + // ======================================================================= + // Compute Langmuir enhancement factors if wind speed is available + // ======================================================================= + Array1DReal LangmuirFactor("LangmuirFactor", Mesh->NCellsAll); + const bool LocUseLangmuirCirculation = UseLangmuirCirculation; + const Real LocSurfaceLayerExtent = SurfaceLayerExtent; + const Real LocCriticalRichardson = CriticalRichardson; + const Real LocIceFracThresholdForLangmuir = IceFractionThresholdForLangmuir; + parallelFor( + "KPP-Langmuir", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { + const Real iceFrac = IceFraction(ICell); + if (LocUseLangmuirCirculation && + iceFrac < LocIceFracThresholdForLangmuir) { + const Real uStar = SurfaceFrictionVelocity(ICell); + const Real u10 = + (WindSpeed10m.extent(0) > 0) ? WindSpeed10m(ICell) : 0.0_Real; + LangmuirFactor(ICell) = ComputeEnhancementFactor(u10, uStar, 50.0); + } else { + LangmuirFactor(ICell) = 1.0; + } + }); + + // ======================================================================= + // Stage 1: Compute OBL depth using edge-based velocity shear + // ======================================================================= + + OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); + OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); + OMEGA_SCOPE(MinLayerEdgeBot, VCoord->MinLayerEdgeBot); + OMEGA_SCOPE(MaxLayerEdgeTop, VCoord->MaxLayerEdgeTop); + OMEGA_SCOPE(ZInterface, VCoord->GeomZInterface); + OMEGA_SCOPE(ZMid, VCoord->GeomZMid); + OMEGA_SCOPE(NEdgesOnCell, Mesh->NEdgesOnCell); + OMEGA_SCOPE(EdgesOnCell, Mesh->EdgesOnCell); + OMEGA_SCOPE(CellsOnCell, Mesh->CellsOnCell); + OMEGA_SCOPE(AreaCell, Mesh->AreaCell); + OMEGA_SCOPE(DcEdge, Mesh->DcEdge); + OMEGA_SCOPE(DvEdge, Mesh->DvEdge); + OMEGA_SCOPE(LocPotentialDensity, PotentialDensity); + OMEGA_SCOPE(LocNormalVelocity, NormalVelocity); + OMEGA_SCOPE(LocTangentialVelocity, TangentialVelocity); + OMEGA_SCOPE(LocBruntVaisalaFreqSq, BruntVaisalaFreqSq); + OMEGA_SCOPE(LocIceFraction, IceFraction); + OMEGA_SCOPE(LocLangmuirFactor, LangmuirFactor); + OMEGA_SCOPE(LocBoundaryLayerDepth, BoundaryLayerDepth); + OMEGA_SCOPE(LocIndexBoundaryLayerDepth, IndexBoundaryLayerDepth); + OMEGA_SCOPE(LocBulkRichardson, BulkRichardsonNumber); + OMEGA_SCOPE(LocBulkRichardsonShear, BulkRichardsonShear); + OMEGA_SCOPE(LocUnresolvedShear, UnresolvedShear); + OMEGA_SCOPE(LocBuoyancyJump, BuoyancyJump); + const bool LocUseBLDSmoothing = UseBLDSmoothing; + const Real LocIceFracThresholdForMinOBL = IceFractionThresholdForMinimumOBL; + const Real LocMinimumOBLUnderSeaIce = MinimumOBLUnderSeaIce; + const Real LocStopOBLSearchMult = StopOBLSearchMult; + + deepCopy(BulkRichardsonNumber, 0.0_Real); + deepCopy(BulkRichardsonShear, 0.0_Real); + deepCopy(UnresolvedShear, 0.0_Real); + deepCopy(BuoyancyJump, 0.0_Real); + + // Maximum edges around a cell (matches MaxMaxEdges in HorzOperators.h) + constexpr I4 MAX_EDGES_ON_CELL = 10; + + parallelFor( + "KPP-OBLDepth", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { + using namespace KPP; + + const Real u_star = + Kokkos::fmax(0.0_Real, SurfaceFrictionVelocity(ICell)); + const Real b0 = SurfaceBuoyancyFlux(ICell); + + const I4 KMin = MinLayerCell(ICell); + const I4 KMax = MaxLayerCell(ICell); + const I4 KIntTop = Kokkos::min(KMin + 1, NVertLayers); + const I4 KIntDeep = Kokkos::min(KMax + 1, NVertLayers); + + const Real iceFrac = LocIceFraction(ICell); + + Real obl_depth = Kokkos::abs(ZInterface(ICell, KIntDeep)); + I4 k_cross = -1; + const Real ri_crit = LocCriticalRichardson; + const Real ri_stop_crit = + Kokkos::max(1.0e-6_Real, LocStopOBLSearchMult) * ri_crit; + const Real ri_scaling = 1.0_Real - 0.5_Real * LocSurfaceLayerExtent; + const Real b0_eff = b0 * LocLangmuirFactor(ICell); + + // CVMix default unresolved-shear constants. + const Real c_s_unres = 24.0_Real * Kokkos::sqrt(17.0_Real); + const Real vtc = + Kokkos::sqrt(0.2_Real / + Kokkos::max(1.0e-12_Real, + c_s_unres * LocSurfaceLayerExtent)) / + (VonKar * VonKar); + + // ------------------------------------------------------------------- + // Initialize per-edge running sums for surface-layer velocity + // averages + // ------------------------------------------------------------------- + const I4 nEdges = NEdgesOnCell(ICell); + // MPAS-style area fractions for edge averaging. + // Use edge kite area divided by cell area. + const I4 nEdgesEff = Kokkos::min(nEdges, MAX_EDGES_ON_CELL); + bool edge_valid[MAX_EDGES_ON_CELL] = {}; + Real edge_weights[MAX_EDGES_ON_CELL] = {}; + const Real inv_area_cell = + 1.0_Real / Kokkos::max(AreaCell(ICell), 1.0e-20_Real); + for (I4 J = 0; J < nEdgesEff; ++J) { + const I4 IEdge = EdgesOnCell(ICell, J); + const I4 KEMin = MinLayerEdgeBot(IEdge); + const I4 KEMax = MaxLayerEdgeTop(IEdge); + edge_valid[J] = + (KEMax >= KEMin && KEMin >= 0 && KEMin < NVertLayers); + if (edge_valid[J]) { + edge_weights[J] = + 0.25_Real * DcEdge(IEdge) * DvEdge(IEdge) * inv_area_cell; + } + } + if (nEdgesEff > 0) { + Real sum_w = 0.0_Real; + for (I4 J = 0; J < nEdgesEff; ++J) { + if (edge_valid[J]) { + sum_w += edge_weights[J]; + } + } + if (sum_w < 1.0e-20_Real) { + I4 n_edges_valid = 0; + for (I4 J = 0; J < nEdgesEff; ++J) { + if (edge_valid[J]) { + ++n_edges_valid; + } + } + if (n_edges_valid > 0) { + const Real equal_w = + 1.0_Real / static_cast(n_edges_valid); + for (I4 J = 0; J < nEdgesEff; ++J) { + edge_weights[J] = edge_valid[J] ? equal_w : 0.0_Real; + } + } + } else { + const Real inv_sum_w = 1.0_Real / sum_w; + for (I4 J = 0; J < nEdgesEff; ++J) { + if (edge_valid[J]) { + edge_weights[J] *= inv_sum_w; + } + } + } + } + + // ------------------------------------------------------------------- + // Cell surface-layer running sums for density + // MOVED INSIDE K-LOOP TO RESET EACH ITERATION (FIX FOR PROGRESSIVE + // ACCUMULATION BUG) + // ------------------------------------------------------------------- + + for (I4 k = KMin; k <= KMax; ++k) { + // Initialize fresh surface layer averages for this candidate OBL + // depth + I4 k_surface_avg = KMin; + const Real thick_top = Kokkos::abs(ZInterface(ICell, KMin + 1) - + ZInterface(ICell, KMin)); + Real sum_thickness = Kokkos::max(thick_top, 1.0e-12_Real); + Real sum_rho = LocPotentialDensity(ICell, KMin) * sum_thickness; + + // Initialize fresh per-edge surface layer averages for this + // candidate OBL depth + I4 k_surf_e[MAX_EDGES_ON_CELL] = {}; + Real sum_thick_e[MAX_EDGES_ON_CELL] = {}; + Real sum_un_e[MAX_EDGES_ON_CELL] = {}; + Real sum_vt_e[MAX_EDGES_ON_CELL] = {}; + + for (I4 J = 0; J < nEdgesEff; ++J) { + if (!edge_valid[J]) { + continue; + } + const I4 IEdge = EdgesOnCell(ICell, J); + const I4 KEMin = MinLayerEdgeBot(IEdge); + k_surf_e[J] = KEMin; + const I4 kInt0 = Kokkos::min(KEMin + 1, NVertLayers); + const Real thick0 = Kokkos::abs(ZInterface(ICell, kInt0) - + ZInterface(ICell, KEMin)); + sum_thick_e[J] = Kokkos::max(thick0, 1.0e-12_Real); + const I4 ke0 = Kokkos::min(KEMin, NVertLayers - 1); + sum_un_e[J] = LocNormalVelocity(IEdge, ke0) * sum_thick_e[J]; + sum_vt_e[J] = + LocTangentialVelocity(IEdge, ke0) * sum_thick_e[J]; + } + const I4 kCell = Kokkos::min(k, NVertLayers - 1); + const I4 kInt = Kokkos::min(k + 1, NVertLayers); + const Real z_depth = Kokkos::abs(ZInterface(ICell, kInt)); + const Real z_center = Kokkos::abs(ZMid(ICell, kCell)); + if (z_depth < 1.0e-12) + continue; + + const Real surf_layer_depth = LocSurfaceLayerExtent * z_depth; + + // Advance cell surface average for density + while (k_surface_avg < k && + Kokkos::abs(ZInterface(ICell, k_surface_avg + 1)) < + surf_layer_depth) { + ++k_surface_avg; + const I4 ksa = Kokkos::min(k_surface_avg, NVertLayers - 1); + const Real dk = + Kokkos::abs(ZInterface(ICell, k_surface_avg + 1) - + ZInterface(ICell, k_surface_avg)); + const Real thick_k = Kokkos::max(dk, 1.0e-12_Real); + sum_thickness += thick_k; + sum_rho += LocPotentialDensity(ICell, ksa) * thick_k; + } + + // Advance per-edge surface averages for velocity + for (I4 J = 0; J < nEdgesEff; ++J) { + if (!edge_valid[J]) { + continue; + } + const I4 IEdge = EdgesOnCell(ICell, J); + const I4 KEMax = MaxLayerEdgeTop(IEdge); + while (k_surf_e[J] < k && + Kokkos::abs(ZInterface(ICell, k_surf_e[J] + 1)) < + surf_layer_depth) { + ++k_surf_e[J]; + const I4 ke = Kokkos::min( + Kokkos::max(k_surf_e[J], MinLayerEdgeBot(IEdge)), KEMax); + const Real dk = + Kokkos::abs(ZInterface(ICell, k_surf_e[J] + 1) - + ZInterface(ICell, k_surf_e[J])); + const Real thick_k = Kokkos::max(dk, 1.0e-12_Real); + sum_thick_e[J] += thick_k; + sum_un_e[J] += LocNormalVelocity(IEdge, ke) * thick_k; + sum_vt_e[J] += LocTangentialVelocity(IEdge, ke) * thick_k; + } + } + + const Real inv_sum_thickness = + 1.0_Real / Kokkos::max(sum_thickness, 1.0e-12_Real); + const Real rho_avg_surf = sum_rho * inv_sum_thickness; + + const Real rho_k = LocPotentialDensity(ICell, kCell); + const Real delta_rho = rho_k - rho_avg_surf; + const Real delta_b = Gravity * delta_rho / RhoSw; + LocBuoyancyJump(ICell, kInt) = delta_b; + + // Edge-based velocity shear: average deltaV^2 over cell edges + Real deltaVsq = 0.0_Real; + if (nEdges > 0) { + for (I4 J = 0; J < nEdgesEff; ++J) { + if (!edge_valid[J]) { + continue; + } + const I4 IEdge = EdgesOnCell(ICell, J); + const I4 KEMin = MinLayerEdgeBot(IEdge); + const I4 KEMax = MaxLayerEdgeTop(IEdge); + const I4 k_e = Kokkos::min(Kokkos::max(k, KEMin), KEMax); + const Real inv_thick_e = + 1.0_Real / Kokkos::max(sum_thick_e[J], 1.0e-12_Real); + const Real un_avg = sum_un_e[J] * inv_thick_e; + const Real vt_avg = sum_vt_e[J] * inv_thick_e; + const Real un_k = LocNormalVelocity(IEdge, k_e); + const Real vt_k = LocTangentialVelocity(IEdge, k_e); + const Real dun = un_k - un_avg; + const Real dvt = vt_k - vt_avg; + deltaVsq += edge_weights[J] * (dun * dun + dvt * dvt); + } + } + LocBulkRichardsonShear(ICell, kInt) = + Kokkos::max(deltaVsq, 1.0e-15_Real); + + const Real sigma_loc = Kokkos::fmin( + 1.0_Real, Kokkos::fmax(0.0_Real, LocSurfaceLayerExtent)); + + Real w_turb = 0.0_Real; + if (u_star > 1.0e-12_Real) { + const Real u3 = u_star * u_star * u_star; + const Real zeta = sigma_loc * z_depth * VonKar * b0_eff / + Kokkos::max(u3, 1.0e-20_Real); + const Real phi_inv_s = KPP::KPPProfileS2(zeta); + w_turb = VonKar * u_star * Kokkos::max(phi_inv_s, 0.0_Real); + } else if (b0_eff < 0.0_Real) { + const Real c_s = KPP::C_MO_S; + const Real ws3 = -c_s * sigma_loc * z_depth * VonKar * b0_eff; + w_turb = VonKar * Kokkos::pow(Kokkos::max(ws3, 0.0_Real), + 1.0_Real / 3.0_Real); + } + const Real n_cntr = Kokkos::sqrt( + Kokkos::max(0.0_Real, LocBruntVaisalaFreqSq(ICell, kInt))); + const Real cv = (n_cntr < 0.002_Real) + ? (2.1_Real - 200.0_Real * n_cntr) + : 1.7_Real; + const Real vt2 = Kokkos::max( + 1.0e-10_Real, cv * vtc * z_center * n_cntr * w_turb / + Kokkos::max(ri_crit, 1.0e-12_Real)); + LocUnresolvedShear(ICell, kInt) = vt2; + + const Real vel_scale2 = deltaVsq + vt2; + + const Real ri_b = ri_scaling * delta_b * z_center / + Kokkos::max(vel_scale2, 1.0e-12_Real); + LocBulkRichardson(ICell, kInt) = ri_b; + + if (k_cross < 0 && ri_b > ri_stop_crit) { + k_cross = k; + } + } + + if (k_cross >= KMin) { + if (k_cross > KMin) { + // Ri values are defined at cell centers, so interpolate on + // center depths to keep the abscissa consistent. + const I4 kAbove = Kokkos::max(KMin, k_cross - 1); + const I4 kBelow = Kokkos::min(k_cross, NVertLayers - 1); + const I4 kAboveRi = Kokkos::min(kAbove + 1, NVertLayers); + const I4 kBelowRi = Kokkos::min(kBelow + 1, NVertLayers); + const Real z_above = Kokkos::abs(ZMid(ICell, kAbove)); + const Real z_below = Kokkos::abs(ZMid(ICell, kBelow)); + const Real ri_above = LocBulkRichardson(ICell, kAboveRi); + const Real ri_below = LocBulkRichardson(ICell, kBelowRi); + + const Real h = z_below - z_above; + if (h > 1.0e-12_Real) { + // CVMix-style QUAD interpolation for OBL crossing: + // - first interior crossing uses zero slope at top point + // - deeper crossings use upstream slope + Real slope_above = 0.0_Real; + if (k_cross > KMin + 1) { + const I4 kPrev = Kokkos::max(KMin, kAbove - 1); + const I4 kPrevRi = Kokkos::min(kPrev + 1, NVertLayers); + const Real z_prev = Kokkos::abs(ZMid(ICell, kPrev)); + const Real ri_prev = LocBulkRichardson(ICell, kPrevRi); + const Real dz_prev = z_above - z_prev; + if (Kokkos::abs(dz_prev) > 1.0e-12_Real) { + slope_above = (ri_above - ri_prev) / dz_prev; + } + } + + // In local coordinate t = z - z_above: + // Ri(t) = A t^2 + slope_above t + ri_above + const Real A = + (ri_below - ri_above - slope_above * h) / (h * h); + const Real C = ri_above - ri_stop_crit; + + Real t_cross = h; + if (Kokkos::abs(A) < 1.0e-14_Real) { + // Degenerate quadratic -> linear fallback. + const Real d_ri = ri_below - ri_above; + if (Kokkos::abs(d_ri) > 1.0e-12_Real) { + const Real frac = Kokkos::fmax( + 0.0_Real, + Kokkos::fmin(1.0_Real, + (ri_stop_crit - ri_above) / d_ri)); + t_cross = frac * h; + } + } else { + const Real disc = + slope_above * slope_above - 4.0_Real * A * C; + if (disc >= 0.0_Real) { + const Real sqrt_disc = Kokkos::sqrt(disc); + const Real t1 = + (-slope_above + sqrt_disc) / (2.0_Real * A); + const Real t2 = + (-slope_above - sqrt_disc) / (2.0_Real * A); + + const bool t1_ok = (t1 >= 0.0_Real && t1 <= h); + const bool t2_ok = (t2 >= 0.0_Real && t2 <= h); + if (t1_ok && t2_ok) { + const Real mid = 0.5_Real * h; + t_cross = + (Kokkos::abs(t1 - mid) <= Kokkos::abs(t2 - mid)) + ? t1 + : t2; + } else if (t1_ok) { + t_cross = t1; + } else if (t2_ok) { + t_cross = t2; + } else { + t_cross = h; + } + } + } + + t_cross = Kokkos::fmax(0.0_Real, Kokkos::fmin(h, t_cross)); + obl_depth = z_above + t_cross; + } else { + obl_depth = z_below; + } + } else { + // Match center-based OBL convention when crossing occurs in + // the top interval. + obl_depth = Kokkos::abs(ZMid(ICell, KMin)); + } + } else { + obl_depth = Kokkos::abs(ZInterface(ICell, KIntDeep)); + } + + const Real top_layer_thickness = + Kokkos::abs(ZInterface(ICell, KIntTop) - ZInterface(ICell, KMin)); + const Real min_obl_depth = 0.5_Real * top_layer_thickness; + const Real max_obl_depth = Kokkos::abs(ZMid(ICell, KMax)); + obl_depth = Kokkos::fmax(obl_depth, min_obl_depth); + if (iceFrac > LocIceFracThresholdForMinOBL) { + obl_depth = Kokkos::fmax(obl_depth, LocMinimumOBLUnderSeaIce); + } + obl_depth = Kokkos::fmin(obl_depth, max_obl_depth); + + I4 k_final = KMax; + for (I4 k = KMin; k < KMax; ++k) { + const Real z_above = Kokkos::abs(ZInterface(ICell, k)); + const Real z_below = Kokkos::abs(ZInterface(ICell, k + 1)); + if (obl_depth >= z_above && obl_depth <= z_below) { + k_final = k; + break; + } + } + + LocBoundaryLayerDepth(ICell) = obl_depth; + LocIndexBoundaryLayerDepth(ICell) = k_final; + }); + + if (LocUseBLDSmoothing) { + Array1DReal BoundaryLayerDepthSmooth("BoundaryLayerDepthSmooth", + Mesh->NCellsAll); + OMEGA_SCOPE(LocBoundaryLayerDepthSmooth, BoundaryLayerDepthSmooth); + OMEGA_SCOPE(LocNCellsAll, Mesh->NCellsAll); + + parallelFor( + "KPP-OBLDepth-Smooth", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { + const I4 KMin = MinLayerCell(ICell); + if (KMin < 0 || KMin >= NVertLayers) { + LocBoundaryLayerDepthSmooth(ICell) = + LocBoundaryLayerDepth(ICell); + return; + } + + const I4 nEdges = NEdgesOnCell(ICell); + Real area_sum = 0.0_Real; + Real bld_sum = 0.0_Real; + I4 edge_count = 0; + + for (I4 J = 0; J < nEdges; ++J) { + const I4 INeighbor = CellsOnCell(ICell, J); + if (INeighbor == LocNCellsAll) { + continue; + } + + const I4 KMinNbr = MinLayerCell(INeighbor); + if (KMinNbr < 0 || KMinNbr >= NVertLayers) { + continue; + } + + const Real nbr_area = AreaCell(INeighbor); + bld_sum += + 2.0_Real * nbr_area * LocBoundaryLayerDepth(INeighbor); + area_sum += 2.0_Real * nbr_area; + ++edge_count; + } + + if (edge_count > 0) { + const Real self_area = AreaCell(ICell); + bld_sum += LocBoundaryLayerDepth(ICell) * + static_cast(edge_count) * self_area; + area_sum += static_cast(edge_count) * self_area; + } + + if (area_sum > 0.0_Real) { + LocBoundaryLayerDepthSmooth(ICell) = bld_sum / area_sum; + } else { + LocBoundaryLayerDepthSmooth(ICell) = + LocBoundaryLayerDepth(ICell); + } + }); + + parallelFor( + "KPP-OBLDepth-CommitSmooth", {Mesh->NCellsAll}, + KOKKOS_LAMBDA(I4 ICell) { + const I4 KMin = MinLayerCell(ICell); + const I4 KMax = MaxLayerCell(ICell); + if (KMin < 0 || KMax < KMin || KMin >= NVertLayers) { + return; + } + + const I4 KIntTop = Kokkos::min(KMin + 1, NVertLayers); + const Real top_layer_thickness = Kokkos::abs( + ZInterface(ICell, KIntTop) - ZInterface(ICell, KMin)); + const Real min_obl_depth = 0.5_Real * top_layer_thickness; + const Real max_obl_depth = Kokkos::abs(ZMid(ICell, KMax)); + + Real obl_depth = LocBoundaryLayerDepthSmooth(ICell); + obl_depth = Kokkos::fmax(obl_depth, min_obl_depth); + obl_depth = Kokkos::fmin(obl_depth, max_obl_depth); + + I4 k_final = KMax; + for (I4 k = KMin; k < KMax; ++k) { + const Real z_above = Kokkos::abs(ZInterface(ICell, k)); + const Real z_below = Kokkos::abs(ZInterface(ICell, k + 1)); + if (obl_depth >= z_above && obl_depth <= z_below) { + k_final = k; + break; + } + } + + LocBoundaryLayerDepth(ICell) = obl_depth; + LocIndexBoundaryLayerDepth(ICell) = k_final; + }); + } + + LOG_INFO("KPPMix::computeOBLDepth: OBL depth computed"); +} + +/// Stage 2: Compute KPP mixing contribution or matched coefficients +void KPPMix::computeMixingCoefficients( + const Array2DReal &PotentialDensity, + const Array1DReal &SurfaceFrictionVelocity, + const Array1DReal &SurfaceBuoyancyFlux, const Array2DReal &InteriorVertDiff, + const Array2DReal &InteriorVertVisc) { + + using namespace KPP; + + (void)PotentialDensity; + + I4 NVertLayers = VCoord->NVertLayers; + + // ======================================================================= + // Capture data for use in lambda + // ======================================================================= + OMEGA_SCOPE(LocBoundaryLayerDepth, BoundaryLayerDepth); + OMEGA_SCOPE(LocIndexBoundaryLayerDepth, IndexBoundaryLayerDepth); + OMEGA_SCOPE(LocVertDiff, VertDiff); + OMEGA_SCOPE(LocVertVisc, VertVisc); + OMEGA_SCOPE(LocVertNonLocalFlux, VertNonLocalFlux); + OMEGA_SCOPE(LocTurbulentVelocityScale, TurbulentVelocityScale); + OMEGA_SCOPE(LocSurfaceFrictionVelocity, SurfaceFrictionVelocity); + OMEGA_SCOPE(LocSurfaceBuoyancyFlux, SurfaceBuoyancyFlux); + OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); + OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); + OMEGA_SCOPE(ZInterface, VCoord->GeomZInterface); + OMEGA_SCOPE(ZMid, VCoord->GeomZMid); + OMEGA_SCOPE(LocInteriorVertDiff, InteriorVertDiff); + OMEGA_SCOPE(LocInteriorVertVisc, InteriorVertVisc); + + // Capture member variables for use in lambda + bool LocUseNonLocalFlux = UseNonLocalFlux; + const Real LocSurfaceLayerExtent = SurfaceLayerExtent; + I4 LocMatchTechnique = 0; // 0=SimpleShapes, 1=MatchBoth, 2=ParabolicNonLocal + if (MatchTechniqueStr == "MatchBoth") { + LocMatchTechnique = 1; + } else if (MatchTechniqueStr == "ParabolicNonLocal") { + LocMatchTechnique = 2; + } + // Non-local flux normalization constant from Large et al. (1994) eq. 20: + // C_s = C* * kappa * (c_s * kappa * epsilon)^(1/3) + // where C* = 10, c_s = C_MO_S = 98.9545, kappa = VonKar, epsilon = + // SurfaceLayerExtent + const Real LocNonLocalCs = + 10.0_Real * VonKar * + Kokkos::pow(KPP::C_MO_S * VonKar * LocSurfaceLayerExtent, + 1.0_Real / 3.0_Real); + bool LocUseEnhancedDiffusion = UseEnhancedDiffusion; + const Real LocKappa = VonKar; + const bool LocUseInteriorMix = + InteriorVertDiff.data() != nullptr && InteriorVertVisc.data() != nullptr; + + // ======================================================================= + // Initialize with zero KPP contribution, or precomputed interior mixing for + // matched-coefficient construction. + // ======================================================================= + parallelFor( + "KPP-Coeffs-Init", {Mesh->NCellsAll, NVertLayers + 1}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + LocVertDiff(ICell, K) = + LocUseInteriorMix ? LocInteriorVertDiff(ICell, K) : 0.0_Real; + LocVertVisc(ICell, K) = + LocUseInteriorMix ? LocInteriorVertVisc(ICell, K) : 0.0_Real; + LocVertNonLocalFlux(ICell, K) = 0.0; + LocTurbulentVelocityScale(ICell, K) = 0.0; + }); + + // ======================================================================= + // Stage 2: Compute KPP profile-based mixing coefficients + // ======================================================================= + + parallelFor( + "KPP-MixingCoeffs", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { + // Get OBL information for this cell + Real h_obl = LocBoundaryLayerDepth(ICell); + + const I4 KMin = MinLayerCell(ICell); + const I4 KMax = MaxLayerCell(ICell); + const I4 KMatch = + Kokkos::min(KMax + 1, LocIndexBoundaryLayerDepth(ICell) + 1); + + // ============================================================= + // Compute turbulent velocity scales + // ============================================================= + Real u_star = LocSurfaceFrictionVelocity(ICell); + Real b0 = LocSurfaceBuoyancyFlux(ICell); + + // ============================================================= + // Compute mixing coefficients at each interface + // ============================================================= + for (I4 k = KMin; k <= KMax + 1; ++k) { + const I4 k_iface = Kokkos::min(Kokkos::max(k, 0), NVertLayers); + const Real z_depth = Kokkos::abs(ZInterface(ICell, k_iface)); + + // Check if within OBL using geometric depth. + if (z_depth <= h_obl && h_obl > 0.0_Real) { + // Normalized depth in Omega sign convention: sigma in [-1,0]. + Real sigma = -z_depth / h_obl; + sigma = Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, sigma)); + + // CVMix-style turbulent scales: w = kappa*u*/phi in general, + // with explicit free-convection limits when u*=0. + const Real sigma_coord = -sigma; // [0,1] + const Real sigma_loc = Kokkos::fmin( + LocSurfaceLayerExtent, Kokkos::fmax(0.0_Real, sigma_coord)); + + Real zeta = 0.0_Real; + Real w_m_turb = 0.0_Real; + Real w_s_turb = 0.0_Real; + + if (u_star > 0.0_Real) { + const Real u3 = u_star * u_star * u_star; + zeta = sigma_loc * h_obl * b0 * LocKappa / + Kokkos::max(u3, 1.0e-20_Real); + + // KPPProfileM2/S2 return phi^{-1}; do not invert again. + const Real phi_inv_m = KPP::KPPProfileM2(zeta); + const Real phi_inv_s = KPP::KPPProfileS2(zeta); + + w_m_turb = + LocKappa * u_star * Kokkos::max(phi_inv_m, 0.0_Real); + w_s_turb = + LocKappa * u_star * Kokkos::max(phi_inv_s, 0.0_Real); + } else if (b0 < 0.0_Real) { + // Free-convection edge case (u*=0, unstable forcing). + const Real c_m = KPP::C_MO_M; + const Real c_s = KPP::C_MO_S; + const Real wm3 = -c_m * sigma_loc * h_obl * LocKappa * b0; + const Real ws3 = -c_s * sigma_loc * h_obl * LocKappa * b0; + w_m_turb = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, wm3), + 1.0_Real / 3.0_Real); + w_s_turb = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, ws3), + 1.0_Real / 3.0_Real); + } + + const Real match_visc_shape = + (LocUseInteriorMix && LocMatchTechnique == 1 && + h_obl > 0.0_Real && w_m_turb > 0.0_Real) + ? LocInteriorVertVisc(ICell, KMatch) / + Kokkos::max(h_obl * w_m_turb, 1.0e-20_Real) + : 0.0_Real; + const Real match_diff_shape = + (LocUseInteriorMix && LocMatchTechnique == 1 && + h_obl > 0.0_Real && w_s_turb > 0.0_Real) + ? LocInteriorVertDiff(ICell, KMatch) / + Kokkos::max(h_obl * w_s_turb, 1.0e-20_Real) + : 0.0_Real; + + // ======================================================== + // Momentum mixing contribution. + // ======================================================== + Real m1 = (LocUseInteriorMix && LocMatchTechnique == 1) + ? KPP::KPPProfileMatched(sigma, match_visc_shape) + : KPP::KPPProfileM1(sigma); + LocVertVisc(ICell, k) = h_obl * w_m_turb * m1; + + // ======================================================== + // Tracer mixing contribution. + // ======================================================== + Real s1 = (LocUseInteriorMix && LocMatchTechnique == 1) + ? KPP::KPPProfileMatched(sigma, match_diff_shape) + : KPP::KPPProfileS1(sigma); + LocVertDiff(ICell, k) = h_obl * w_s_turb * s1; + LocTurbulentVelocityScale(ICell, k) = w_s_turb; + + // ======================================================== + // Non-local flux: C_s * G(σ) + // C_s = C* * kappa * (c_s * kappa * epsilon)^(1/3) + // per Large et al. (1994) eq. 20 (~6.33 with default constants) + // ======================================================== + // Match CVMix behavior: apply non-local term only when + // surface buoyancy forcing is unstable/neutral. + if (LocUseNonLocalFlux && b0 <= 0.0_Real) { + Real g_sigma = 0.0_Real; + if (LocMatchTechnique == 2) { + g_sigma = KPP::KPPProfileGParabolicNonLocal(sigma); + } else if (LocMatchTechnique == 1) { + g_sigma = KPP::KPPProfileGMatchBoth(sigma); + } else { + g_sigma = KPP::KPPProfileG(sigma); + } + LocVertNonLocalFlux(ICell, k) = LocNonLocalCs * g_sigma; + } else { + LocVertNonLocalFlux(ICell, k) = 0.0; + } + + } else { + // Below OBL: preserve interior values for MatchBoth, otherwise + // no KPP contribution. + LocVertDiff(ICell, k) = LocUseInteriorMix + ? LocInteriorVertDiff(ICell, k) + : 0.0_Real; + LocVertVisc(ICell, k) = LocUseInteriorMix + ? LocInteriorVertVisc(ICell, k) + : 0.0_Real; + LocVertNonLocalFlux(ICell, k) = 0.0; + LocTurbulentVelocityScale(ICell, k) = 0.0; + } + } + + // Optional enhanced diffusion/viscosity treatment at OBL base. + // Match CVMix Appendix D weighting at the interface nearest h_obl. + if (LocUseEnhancedDiffusion && h_obl > 0.0_Real) { + const I4 k_obl = Kokkos::max( + KMin, Kokkos::min(LocIndexBoundaryLayerDepth(ICell), KMax)); + const Real z_mid_obl = Kokkos::abs(ZMid(ICell, k_obl)); + + const bool target_outside_obl = h_obl >= z_mid_obl; + const I4 k_ktup = + target_outside_obl ? k_obl : Kokkos::max(KMin, k_obl - 1); + const I4 k_target = target_outside_obl + ? Kokkos::min(k_obl + 1, KMax + 1) + : Kokkos::max(KMin + 1, k_obl); + + const Real z_ktup = Kokkos::abs(ZMid(ICell, k_ktup)); + const Real z_next = + (k_ktup < KMax) ? Kokkos::abs(ZMid(ICell, k_ktup + 1)) + : Kokkos::abs(ZInterface(ICell, k_ktup + 1)); + const Real delta = Kokkos::fmax( + 0.0_Real, + Kokkos::fmin(1.0_Real, + (h_obl - z_ktup) / + Kokkos::max(z_next - z_ktup, 1.0e-12_Real))); + const Real one_minus_delta = 1.0_Real - delta; + + Real sigma_ktup = -z_ktup / h_obl; + sigma_ktup = + Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, sigma_ktup)); + const Real sigma_coord = -sigma_ktup; + const Real sigma_loc = Kokkos::fmin( + LocSurfaceLayerExtent, Kokkos::fmax(0.0_Real, sigma_coord)); + + Real w_m_ktup = 0.0_Real; + Real w_s_ktup = 0.0_Real; + if (u_star > 0.0_Real) { + const Real u3 = u_star * u_star * u_star; + const Real zeta = sigma_loc * h_obl * b0 * LocKappa / + Kokkos::max(u3, 1.0e-20_Real); + w_m_ktup = LocKappa * u_star * + Kokkos::max(KPP::KPPProfileM2(zeta), 0.0_Real); + w_s_ktup = LocKappa * u_star * + Kokkos::max(KPP::KPPProfileS2(zeta), 0.0_Real); + } else if (b0 < 0.0_Real) { + const Real wm3 = + -KPP::C_MO_M * sigma_loc * h_obl * LocKappa * b0; + const Real ws3 = + -KPP::C_MO_S * sigma_loc * h_obl * LocKappa * b0; + w_m_ktup = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, wm3), + 1.0_Real / 3.0_Real); + w_s_ktup = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, ws3), + 1.0_Real / 3.0_Real); + } + + const Real match_visc_shape = + (LocUseInteriorMix && LocMatchTechnique == 1 && + h_obl > 0.0_Real && w_m_ktup > 0.0_Real) + ? LocInteriorVertVisc(ICell, KMatch) / + Kokkos::max(h_obl * w_m_ktup, 1.0e-20_Real) + : 0.0_Real; + const Real match_diff_shape = + (LocUseInteriorMix && LocMatchTechnique == 1 && + h_obl > 0.0_Real && w_s_ktup > 0.0_Real) + ? LocInteriorVertDiff(ICell, KMatch) / + Kokkos::max(h_obl * w_s_ktup, 1.0e-20_Real) + : 0.0_Real; + + const Real visc_ktup = + h_obl * w_m_ktup * + ((LocUseInteriorMix && LocMatchTechnique == 1) + ? KPP::KPPProfileMatched(sigma_ktup, match_visc_shape) + : KPP::KPPProfileM1(sigma_ktup)); + const Real diff_ktup = + h_obl * w_s_ktup * + ((LocUseInteriorMix && LocMatchTechnique == 1) + ? KPP::KPPProfileMatched(sigma_ktup, match_diff_shape) + : KPP::KPPProfileS1(sigma_ktup)); + + const Real visc_profile = LocVertVisc(ICell, k_target); + const Real diff_profile = LocVertDiff(ICell, k_target); + + const Real enh_visc = + one_minus_delta * one_minus_delta * visc_ktup + + delta * delta * visc_profile; + const Real enh_diff = + one_minus_delta * one_minus_delta * diff_ktup + + delta * delta * diff_profile; + + const Real old_visc = LocUseInteriorMix + ? LocInteriorVertVisc(ICell, k_target) + : 0.0_Real; + const Real old_diff = LocUseInteriorMix + ? LocInteriorVertDiff(ICell, k_target) + : 0.0_Real; + const Real new_visc = + one_minus_delta * old_visc + delta * enh_visc; + const Real new_diff = + one_minus_delta * old_diff + delta * enh_diff; + + LocVertVisc(ICell, k_target) = new_visc; + LocVertDiff(ICell, k_target) = new_diff; + + if (!target_outside_obl && diff_profile != 0.0_Real) { + LocVertNonLocalFlux(ICell, k_target) = + LocVertNonLocalFlux(ICell, k_target) * new_diff / + diff_profile; + } else if (!target_outside_obl) { + LocVertNonLocalFlux(ICell, k_target) = 0.0_Real; + } + } + }); + + LOG_INFO("KPPMix::computeMixingCoefficients: Phase 2 mixing coefficients " + "computed"); +} + +/// Register fields with I/O system +void KPPMix::defineFields() { + // BoundaryLayerDepth on cells + std::vector CellDims(1); + CellDims[0] = "NCells"; + auto OBLDepthField = + Field::create(OBLDepthFldName, // field name + "ocean boundary layer depth", // long name + "m", // units + "", // CF standard name + 0.0, // min valid value + std::numeric_limits::max(), // max valid value + 1, // number of dims + CellDims); + + auto OBLDepthIndexField = + Field::create(OBLDepthIndexFldName, // field name + "ocean boundary layer depth index", // long name + "", // units + "", // CF standard name + -1, // min valid value + std::numeric_limits::max(), // max valid value + 1, // number of dims + CellDims); + + // KPP non-local tracer flux profile on cell-layer interfaces + std::vector FluxDims(2); + FluxDims[0] = "NCells"; + FluxDims[1] = "NVertLayersP1"; + auto NonLocalFluxField = + Field::create(NonLocalFluxFldName, // field name + "KPP non-local tracer flux profile", // long name + "1", // units + "", // CF standard name + std::numeric_limits::lowest(), // min valid value + std::numeric_limits::max(), // max valid value + 2, // number of dims + FluxDims); + + auto BulkRichardsonField = + Field::create(BulkRichardsonFldName, // field name + "bulk Richardson number", // long name + "1", // units + "", // CF standard name + std::numeric_limits::lowest(), // min valid value + std::numeric_limits::max(), // max valid value + 2, // number of dims + FluxDims); + + auto BulkRichardsonShearField = + Field::create(BulkRichardsonShearFldName, // field name + "bulk Richardson shear term", // long name + "m2 s-2", // units + "", // CF standard name + 0.0, // min valid value + std::numeric_limits::max(), // max valid value + 2, // number of dims + FluxDims); + + auto UnresolvedShearField = + Field::create(UnresolvedShearFldName, // field name + "KPP unresolved shear term Vt2", // long name + "m2 s-2", // units + "", // CF standard name + 0.0, // min valid value + std::numeric_limits::max(), // max valid value + 2, // number of dims + FluxDims); + + auto BuoyancyJumpField = + Field::create(BuoyancyJumpFldName, // field name + "KPP buoyancy jump (density anomaly)", // long name + "m s-2", // units + "", // CF standard name + std::numeric_limits::lowest(), // min valid value + std::numeric_limits::max(), // max valid value + 2, // number of dims + FluxDims); + + auto TurbulentVelScaleField = + Field::create(TurbulentVelScaleFldName, // field name + "KPP turbulent velocity scale", // long name + "m s-1", // units + "", // CF standard name + 0.0, // min valid value + std::numeric_limits::max(), // max valid value + 2, // number of dims + FluxDims); + + std::vector LayerDims(2); + LayerDims[0] = "NCells"; + LayerDims[1] = "NVertLayers"; + auto PotentialDensityField = + Field::create(PotentialDensityFldName, // field name + "KPP potential density", // long name + "kg m-3", // units + "", // CF standard name + std::numeric_limits::lowest(), // min valid value + std::numeric_limits::max(), // max valid value + 2, // number of dims + LayerDims); + + // Group KPP-specific outputs for convenient stream selection. + auto KPPGroup = FieldGroup::create("KPPMix"); + KPPGroup->addField(OBLDepthFldName); + KPPGroup->addField(OBLDepthIndexFldName); + KPPGroup->addField(NonLocalFluxFldName); + KPPGroup->addField(BulkRichardsonFldName); + KPPGroup->addField(BulkRichardsonShearFldName); + KPPGroup->addField(UnresolvedShearFldName); + KPPGroup->addField(BuoyancyJumpFldName); + KPPGroup->addField(TurbulentVelScaleFldName); + KPPGroup->addField(PotentialDensityFldName); + KPPGroup->addField(SurfFricVelFldName); + KPPGroup->addField(SurfBuoyFluxFldName); + + OBLDepthField->attachData(BoundaryLayerDepth, false); + OBLDepthIndexField->attachData(IndexBoundaryLayerDepth, false); + NonLocalFluxField->attachData(VertNonLocalFlux, false); + BulkRichardsonField->attachData(BulkRichardsonNumber, false); + BulkRichardsonShearField->attachData(BulkRichardsonShear, + false); + UnresolvedShearField->attachData(UnresolvedShear, false); + BuoyancyJumpField->attachData(BuoyancyJump, false); + TurbulentVelScaleField->attachData(TurbulentVelocityScale, + false); + PotentialDensityField->attachData(PotentialDensity, false); + + // Surface forcing fields on cells + auto SurfFricVelField = + Field::create(SurfFricVelFldName, // field name + "KPP surface friction velocity u*", // long name + "m s-1", // units + "", // CF standard name + 0.0, // min valid value + std::numeric_limits::max(), // max valid value + 1, // number of dims + CellDims); + SurfFricVelField->attachData(SurfaceFrictionVelocity, false); + + auto SurfBuoyFluxField = + Field::create(SurfBuoyFluxFldName, // field name + "KPP surface buoyancy flux", // long name + "m2 s-3", // units + "", // CF standard name + std::numeric_limits::lowest(), // min valid value + std::numeric_limits::max(), // max valid value + 1, // number of dims + CellDims); + SurfBuoyFluxField->attachData(SurfaceBuoyancyFlux, false); + + OBLDepthField->addMetadata("_FillValue", FillValueReal); + OBLDepthIndexField->addMetadata("_FillValue", FillValueI4); + NonLocalFluxField->addMetadata("_FillValue", FillValueReal); + BulkRichardsonField->addMetadata("_FillValue", FillValueReal); + BulkRichardsonShearField->addMetadata("_FillValue", FillValueReal); + UnresolvedShearField->addMetadata("_FillValue", FillValueReal); + BuoyancyJumpField->addMetadata("_FillValue", FillValueReal); + TurbulentVelScaleField->addMetadata("_FillValue", FillValueReal); + PotentialDensityField->addMetadata("_FillValue", FillValueReal); + SurfFricVelField->addMetadata("_FillValue", FillValueReal); + SurfBuoyFluxField->addMetadata("_FillValue", FillValueReal); + + LOG_INFO("KPPMix::defineFields: registered {}, {}, {}, {}, {}, {}, {}, {}, " + "{}, {}, {}", + OBLDepthFldName, OBLDepthIndexFldName, NonLocalFluxFldName, + BulkRichardsonFldName, BulkRichardsonShearFldName, + UnresolvedShearFldName, BuoyancyJumpFldName, + TurbulentVelScaleFldName, PotentialDensityFldName, + SurfFricVelFldName, SurfBuoyFluxFldName); +} + +} // namespace OMEGA diff --git a/components/omega/src/ocn/KPPMix.h b/components/omega/src/ocn/KPPMix.h new file mode 100644 index 000000000000..6729a993c9fd --- /dev/null +++ b/components/omega/src/ocn/KPPMix.h @@ -0,0 +1,227 @@ +#ifndef OMEGA_KPP_MIX_H +#define OMEGA_KPP_MIX_H +//===-- ocn/KPPMix.h - K-Profile Parameterization --------*- C++ -*-===// +// +/// \file +/// \brief K-Profile Parameterization (KPP) boundary layer mixing scheme +/// +/// This header defines the KPPMix class for computing ocean boundary layer +/// mixing coefficients using the K-Profile Parameterization scheme. +/// Follows Large et al. (1994) formulation with optional Langmuir circulation +/// enhancement. +// +//===----------------------------------------------------------------------===// + +#include "AuxiliaryState.h" +#include "Config.h" +#include "DataTypes.h" +#include "HorzMesh.h" +#include "HorzOperators.h" +#include "KPPConstants.h" +#include "MachEnv.h" +#include "OmegaKokkos.h" +#include "TimeMgr.h" +#include "VertCoord.h" +#include + +namespace OMEGA { + +/// @brief KPP Boundary Layer Mixing Scheme +/// +/// Implements the K-Profile Parameterization following Large et al. (1994) +/// with optional Langmuir circulation enhancement. Computes vertical +/// diffusivity, viscosity, and non-local flux coefficients for the OBL. +/// +/// Two-stage computation: +/// 1. Stage 1: Compute OBL depth from bulk Richardson criterion +/// 2. Stage 2: Compute mixing coefficients within and below OBL +/// +class KPPMix { + + public: + /// @brief Singleton instance management + static KPPMix *getInstance(); + static void init(); + static void destroyInstance(); + + /// @brief Main computation routine + /// Calls Stage 1 and Stage 2 computation in sequence + /// + /// Input arrays should be pre-populated with current state. + /// NormalVelocity and TangentialVelocity are edge-based quantities + /// (C-grid convention): dimensions [NEdges × NVertLayers]. + /// Output arrays are computed in-place. + void computeKPPMix( + const Array2DReal + &PotentialDensity, ///< Density (kg/m³) [NCells×NLevels] + const Array2DReal &NormalVelocity, ///< Normal vel on edges (m/s) + const Array2DReal &TangentialVelocity, ///< Tangential vel on edges (m/s) + const Array1DReal &SurfaceFrictionVelocity, ///< u* (m/s) + const Array1DReal &SurfaceBuoyancyFlux, ///< B_0 (m²/s³) + const Array2DReal &BruntVaisalaFreqSq, ///< N² (s⁻²) + const Array1DReal &IceFraction, ///< Sea ice cover (0-1) + const Array1DReal &WindSpeed10m = + Array1DReal() ///< Wind for Langmuir (m/s) + ); + + // ======================================================================= + // Output Fields + // ======================================================================= + + /// @brief Vertical diffusivity at layer interfaces (m²/s) + /// Size: [nCells][nLevels+1] + Array2DReal VertDiff; + + /// @brief Vertical viscosity at layer interfaces (m²/s) + /// Size: [nCells][nLevels+1] + Array2DReal VertVisc; + + /// @brief Boundary layer depth (m) + /// Size: [nCells] + Array1DReal BoundaryLayerDepth; + + /// @brief OBL depth as layer index + /// Size: [nCells] + Array1DI4 IndexBoundaryLayerDepth; + + /// @brief Non-local flux coefficient profile G(σ) (dimensionless) + /// Size: [nCells][nLevels+1] + /// Applied to surface tracer fluxes to compute vertical transport + Array2DReal VertNonLocalFlux; + + /// @brief Bulk Richardson number profile used in OBL search (dimensionless) + /// Size: [nCells][nLevels+1] + Array2DReal BulkRichardsonNumber; + + /// @brief Shear contribution to bulk Richardson denominator (m^2/s^2) + /// Size: [nCells][nLevels+1] + Array2DReal BulkRichardsonShear; + + /// @brief Unresolved shear contribution Vt^2 (m^2/s^2) + /// Size: [nCells][nLevels+1] + Array2DReal UnresolvedShear; + + /// @brief Buoyancy jump (density anomaly converted to buoyancy) (m/s²) + /// Size: [nCells][nLevels+1] + /// Captures delta_b = g * delta_rho / rho_sw at each layer during OBL search + Array2DReal BuoyancyJump; + + /// @brief Turbulent velocity scale profile (m/s), tracer branch + /// Size: [nCells][nLevels+1] + Array2DReal TurbulentVelocityScale; + + /// @brief Potential density used by KPP OBL search (kg/m^3) + /// Size: [nCells][nLevels] + Array2DReal PotentialDensity; + + /// @brief Surface friction velocity u* (m/s) + /// Size: [nCells] + Array1DReal SurfaceFrictionVelocity; + + /// @brief Surface buoyancy flux B_0 (m²/s³) + /// Size: [nCells] + Array1DReal SurfaceBuoyancyFlux; + + // ======================================================================= + // Configuration Parameters + // ======================================================================= + + bool Enabled = true; ///< Enable/disable KPP mixing + + Real CriticalRichardson = 0.3; ///< Ri_crit for OBL criterion + Real StopOBLSearchMult = 1.0; ///< Safety multiplier for search + Real SurfaceLayerExtent = 0.1; ///< Surface layer fraction of OBL + + bool UseLangmuirCirculation = true; ///< Apply wave enhancement + bool UseNonLocalFlux = true; ///< Apply non-local tracer flux + bool DebugDiagnostics = false; ///< Print per-step KPP diagnostics + + // Ice/Langmuir controls (kept configurable to match reference semantics) + Real IceFractionThresholdForLangmuir = 0.05; ///< Disable Langmuir above this + Real IceFractionThresholdForMinimumOBL = 0.15; ///< Apply min OBL above this + Real MinimumOBLUnderSeaIce = 5.0; ///< Min OBL depth under sea ice (m) + + Real BackgroundVisc = 1.0e-4; ///< Background viscosity below OBL (m²/s) + Real BackgroundDiff = 1.0e-5; ///< Background diffusivity below OBL (m²/s) + + // KPP matching/profile controls (CVMix-style semantics) + std::string MatchTechniqueStr = + "SimpleShapes"; ///< SimpleShapes, MatchGradient, MatchBoth, + ///< ParabolicNonLocal + std::string InterpType2Str = "LMD94"; ///< Linear, Quadratic, Cubic, LMD94 + bool UseEnhancedDiffusion = true; ///< Apply enhanced mixing at OBL base + bool UseBLDSmoothing = true; ///< Apply MPAS-style BLD horizontal smoothing + + // Field names for I/O + std::string BuoyancyJumpFldName; + std::string VertDiffFldName; + std::string VertViscFldName; + std::string OBLDepthFldName; + std::string OBLDepthIndexFldName; + std::string NonLocalFluxFldName; + std::string BulkRichardsonFldName; + std::string BulkRichardsonShearFldName; + std::string UnresolvedShearFldName; + std::string TurbulentVelScaleFldName; + std::string PotentialDensityFldName; + std::string SurfFricVelFldName; + std::string SurfBuoyFluxFldName; + std::string Name; + + private: + /// @brief Private constructor for singleton pattern + KPPMix(const std::string &Name_in, const HorzMesh *Mesh_in, + const VertCoord *VCoord_in); + + /// @brief Private destructor + ~KPPMix(); + + /// @brief Static singleton instance + static KPPMix *Instance; + + /// @brief Mesh and coordinate references + const HorzMesh *Mesh; + const VertCoord *VCoord; + + public: + /// @brief Stage 1: Compute OBL depth using edge-based velocity shear + void computeOBLDepth(const Array2DReal &PotentialDensity, + const Array2DReal &NormalVelocity, + const Array2DReal &TangentialVelocity, + const Array1DReal &SurfaceFrictionVelocity, + const Array1DReal &SurfaceBuoyancyFlux, + const Array2DReal &BruntVaisalaFreqSq, + const Array1DReal &IceFraction, + const Array1DReal &WindSpeed10m); + + /// @brief Stage 2: Compute KPP mixing contribution or matched coefficients + void computeMixingCoefficients( + const Array2DReal &PotentialDensity, + const Array1DReal &SurfaceFrictionVelocity, + const Array1DReal &SurfaceBuoyancyFlux, + const Array2DReal &InteriorVertDiff = Array2DReal(), + const Array2DReal &InteriorVertVisc = Array2DReal()); + + private: + /// @brief Print targeted diagnostics for KPP troubleshooting + void logDiagnostics(const Array2DReal &PotentialDensity, + const Array2DReal &NormalVelocity, + const Array2DReal &TangentialVelocity, + const Array1DReal &SurfaceFrictionVelocity, + const Array1DReal &SurfaceBuoyancyFlux, + const Array1DReal &WindSpeed10m); + + /// @brief Register fields with I/O system + void defineFields(); + + // Delete copy and move constructors/assignment + KPPMix(const KPPMix &) = delete; + KPPMix &operator=(const KPPMix &) = delete; + KPPMix(KPPMix &&) = delete; + KPPMix &operator=(KPPMix &&) = delete; + +}; // class KPPMix + +} // namespace OMEGA + +#endif // OMEGA_KPP_MIX_H diff --git a/components/omega/src/ocn/KPPNonLocalFlux.h b/components/omega/src/ocn/KPPNonLocalFlux.h new file mode 100755 index 000000000000..50146649bebf --- /dev/null +++ b/components/omega/src/ocn/KPPNonLocalFlux.h @@ -0,0 +1,105 @@ +#ifndef OMEGA_KPP_NONLOCAL_FLUX_H +#define OMEGA_KPP_NONLOCAL_FLUX_H +//===-- ocn/KPPNonLocalFlux.h - Non-local Flux Computation -----*- C++ -*-===// +// +/// \file +/// \brief Compute KPP non-local flux profiles for tracers +/// +/// This header defines functors for computing the non-local flux coefficient +/// G(σ) which is applied to surface tracer fluxes to produce vertical mixing +/// of tracers. The non-local flux represents transport by coherent plumes +/// within the OBL. +// +//===----------------------------------------------------------------------===// + +#include "KPPConstants.h" +#include "OmegaKokkos.h" + +namespace OMEGA::KPP { + +/// @brief Non-local flux profile functor +/// Computes G(σ) applied to surface tracer fluxes +/// +/// The non-local flux produces vertical transport: +/// flux(z) = G(σ) × Q_surf +/// where σ = -z/h_OBL (normalized depth) +/// +/// REFERENCES: Large et al. (1994) Eq. (12)-(13), Large et al. (1997) +class KPPComputeNonLocalFlux { + + public: + Array1DReal ZInterface; ///< Depth at interfaces (m, negative down) + Array1DReal ZCenter; ///< Depth at cell centers (m) + Array1DI4 MinLayerCell; ///< Min layer index per cell + Array1DI4 MaxLayerCell; ///< Max layer index per cell + + // OBL depth information + Real OBLDepth; ///< Current OBL depth (m) + I4 OBLIndex; ///< Layer index of OBL base + + // Reference profiles for shear stability correction + Array1DReal GradientRichardsonNum; ///< Ri_g for stability correction + + // Output + Array1DReal NonLocalFluxProfile; ///< G(σ) values at interfaces + + /// @brief Constructor + KPPComputeNonLocalFlux(const Array1DReal &ZInterface_in, + const Array1DReal &ZCenter_in, + const Array1DI4 &MinLayerCell_in, + const Array1DI4 &MaxLayerCell_in, Real obl_depth, + I4 obl_index, const Array1DReal &RiGrad_in, + const Array1DReal &G_profile_out) + : ZInterface(ZInterface_in), ZCenter(ZCenter_in), + MinLayerCell(MinLayerCell_in), MaxLayerCell(MaxLayerCell_in), + OBLDepth(obl_depth), OBLIndex(obl_index), + GradientRichardsonNum(RiGrad_in), NonLocalFluxProfile(G_profile_out) {} + + /// @brief Compute non-local flux profile G(σ) + /// + /// Algorithm: + /// 1. For each layer k from surface to OBL base: + /// a. Compute normalized depth σ = -z/h_OBL + /// b. Evaluate G(σ) profile function + /// c. Apply stability correction if needed + /// 2. Set G(σ) = 0 below OBL base + /// + KOKKOS_FUNCTION + void computeNonLocalFlux(I4 ICell) const { + + const I4 KMin = MinLayerCell(ICell); + const I4 KMax = MaxLayerCell(ICell); + + // Clamp OBL depth to reasonable bounds + Real h_obl = Kokkos::fmax(1.0, OBLDepth); + + // ======================================================================= + // Compute G(σ) at each interface + // ======================================================================= + for (I4 k = KMin; k <= KMax + 1; ++k) { + + Real z_interface = Kokkos::abs(ZInterface(k)); + + // Check if point is within OBL + if (z_interface <= h_obl) { + + // Normalized depth: σ = -z/h (negative in ocean convention) + Real sigma = -(z_interface / h_obl); // -1 <= sigma <= 0 + + // Evaluate G(σ) profile + Real g_sigma = KPPProfileG(sigma); + + NonLocalFluxProfile(k) = g_sigma; + + } else { + // Below OBL base: no non-local flux + NonLocalFluxProfile(k) = 0.0; + } + } + } + +}; // class KPPComputeNonLocalFlux + +} // namespace OMEGA::KPP + +#endif // OMEGA_KPP_NONLOCAL_FLUX_H diff --git a/components/omega/src/ocn/OceanInit.cpp b/components/omega/src/ocn/OceanInit.cpp index 6a50cb2c0b2b..b2b89e769de7 100644 --- a/components/omega/src/ocn/OceanInit.cpp +++ b/components/omega/src/ocn/OceanInit.cpp @@ -20,6 +20,7 @@ #include "HorzMesh.h" #include "IO.h" #include "IOStream.h" +#include "KPPMix.h" #include "Logging.h" #include "MachEnv.h" #include "OceanDriver.h" @@ -289,6 +290,7 @@ static int initOmegaModulesImpl(MPI_Comm Comm) { Eos::init(); PressureGrad::init(); VertMix::init(); + KPPMix::init(); Tendencies::init(); // Validate SurfaceTracerRestoring configuration diff --git a/components/omega/src/ocn/Tendencies.cpp b/components/omega/src/ocn/Tendencies.cpp index 419c105292a6..b927aa4c54d9 100644 --- a/components/omega/src/ocn/Tendencies.cpp +++ b/components/omega/src/ocn/Tendencies.cpp @@ -14,6 +14,8 @@ #include "Error.h" #include "Field.h" #include "Forcing.h" +#include "HorzOperators.h" +#include "KPPMix.h" #include "OceanState.h" #include "PGrad.h" #include "Pacer.h" @@ -398,6 +400,21 @@ void Tendencies::readConfig(Config *OmegaConfig ///< [in] Omega config ABORT_ERROR("Tendencies: VertMix must be initialized when" "vertical mixing tendencies are enabled"); } + // Optional KPP non-local tracer tendency: no abort if missing + Error TracerNonLocalErr = TendConfig.get( + "TracerNonLocalFluxTendencyEnable", this->TracerNonLocalFluxEnabled); + if (!TracerNonLocalErr.isSuccess()) { + TracerNonLocalErr.reset(); + this->TracerNonLocalFluxEnabled = false; + } + + Error TracerDiagErr = + TendConfig.get("TracerNonLocalDiagnosticsEnable", + this->TracerNonLocalDiagnosticsEnable); + if (!TracerDiagErr.isSuccess()) { + TracerDiagErr.reset(); + this->TracerNonLocalDiagnosticsEnable = true; + } } } @@ -407,10 +424,16 @@ void Tendencies::defineFields() { std::string PseudoThicknessTendFieldName = "PseudoThicknessTend"; std::string NormalVelocityTendFieldName = "NormalVelocityTend"; std::string TracerTendFieldName = "TracerTend"; + std::string SurfaceTracerFluxFieldName = "SurfaceTracerFlux"; + std::string TempNonLocalDiagFieldName = "TempNonLocalTendDiag"; + std::string TempNonLocalColSumFieldName = "TempNonLocalColumnSumDiag"; if (Name != "Default") { PseudoThicknessTendFieldName.append(Name); NormalVelocityTendFieldName.append(Name); TracerTendFieldName.append(Name); + SurfaceTracerFluxFieldName.append(Name); + TempNonLocalDiagFieldName.append(Name); + TempNonLocalColSumFieldName.append(Name); } int NDims = 2; @@ -438,6 +461,31 @@ void Tendencies::defineFields() { "m/s^2", "sea_water_velocity_tendency", -9.99E+10, 9.99E+10, NDims, DimNamesVelocity); + NDims = 2; + std::vector DimNamesSurfaceFlux(NDims); + DimNamesSurfaceFlux[0] = "NTracers"; + DimNamesSurfaceFlux[1] = "NCells"; + auto SurfaceTracerFluxField = + Field::create(SurfaceTracerFluxFieldName, "Surface tracer flux", "1", "", + -9.99E+10, 9.99E+10, NDims, DimNamesSurfaceFlux); + + NDims = 2; + std::vector DimNamesTempDiag(NDims); + DimNamesTempDiag[0] = "NCells"; + DimNamesTempDiag[1] = "NVertLayers"; + auto TempNonLocalDiagField = + Field::create(TempNonLocalDiagFieldName, + "Temperature non-local KPP tendency diagnostic", "1", "", + -9.99E+10, 9.99E+10, NDims, DimNamesTempDiag); + + NDims = 1; + std::vector DimNamesCellOnly(NDims); + DimNamesCellOnly[0] = "NCells"; + auto TempNonLocalColSumField = + Field::create(TempNonLocalColSumFieldName, + "Temperature non-local tendency column-sum diagnostic", + "1", "", -9.99E+10, 9.99E+10, NDims, DimNamesCellOnly); + std::string TendGroupName = "Tendencies"; if (Name != "Default") { TendGroupName.append(Name); @@ -447,10 +495,17 @@ void Tendencies::defineFields() { TendGroup->addField(PseudoThicknessTendFieldName); TendGroup->addField(NormalVelocityTendFieldName); TendGroup->addField(TracerTendFieldName); + TendGroup->addField(SurfaceTracerFluxFieldName); + TendGroup->addField(TempNonLocalDiagFieldName); + TendGroup->addField(TempNonLocalColSumFieldName); PseudoThicknessTendField->attachData(PseudoThicknessTend); NormalVelocityTendField->attachData(NormalVelocityTend); TracerTendField->attachData(TracerTend); + SurfaceTracerFluxField->attachData(SurfaceTracerFlux, false); + TempNonLocalDiagField->attachData(TempNonLocalTendDiag, false); + TempNonLocalColSumField->attachData(TempNonLocalColumnSumDiag, + false); } // end defineFields @@ -489,6 +544,15 @@ Tendencies::Tendencies(const std::string &Name_, ///< [in] Name for tendencies Array2DReal("NormalVelocityTend", Mesh->NEdgesSize, VCoord->NVertLayers); TracerTend = Array3DReal("TracerTend", NTracersIn, Mesh->NCellsSize, VCoord->NVertLayers); + SurfaceTracerFlux = + Array2DReal("SurfaceTracerFlux", NTracersIn, Mesh->NCellsAll); + TempNonLocalTendDiag = Array2DReal("TempNonLocalTendDiag", Mesh->NCellsSize, + VCoord->NVertLayers); + TempNonLocalColumnSumDiag = + Array1DReal("TempNonLocalColumnSumDiag", Mesh->NCellsSize); + deepCopy(SurfaceTracerFlux, 0.0_Real); + deepCopy(TempNonLocalTendDiag, 0.0_Real); + deepCopy(TempNonLocalColumnSumDiag, 0.0_Real); Name = Name_; @@ -814,11 +878,20 @@ void Tendencies::computeTracerTendenciesOnly( OMEGA_SCOPE(LocTracerDiffusion, TracerDiffusion); OMEGA_SCOPE(LocTracerHyperDiff, TracerHyperDiff); OMEGA_SCOPE(LocSurfaceTracerRestoring, SurfaceTracerRestoring); + OMEGA_SCOPE(LocSurfaceTracerFlux, SurfaceTracerFlux); OMEGA_SCOPE(LocSfcTracerForcing, SfcTracerForcing); OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); OMEGA_SCOPE(MinLayerEdgeBot, VCoord->MinLayerEdgeBot); OMEGA_SCOPE(MaxLayerEdgeTop, VCoord->MaxLayerEdgeTop); + OMEGA_SCOPE(LocTempNonLocalTendDiag, TempNonLocalTendDiag); + OMEGA_SCOPE(LocTempNonLocalColumnSumDiag, TempNonLocalColumnSumDiag); + const bool LocTracerNonLocalDiagnosticsEnable = + TracerNonLocalDiagnosticsEnable; + I4 TempTracerIndex = -1; + const bool LocHasTempTracer = + (Tracers::getIndex(TempTracerIndex, "Temperature") == 0); + const I4 LocTempTracerIndex = TempTracerIndex; Pacer::start("Tend:computeTracerTendenciesOnly", 1); @@ -832,6 +905,21 @@ void Tendencies::computeTracerTendenciesOnly( INNER_LAMBDA(int K) { LocTracerTend(L, ICell, K) = 0; }); }); + if (LocTracerNonLocalDiagnosticsEnable) { + parallelForOuter( + {Mesh->NCellsAll}, KOKKOS_LAMBDA(int ICell, const TeamMember &Team) { + const int KMin = MinLayerCell(ICell); + const int KMax = MaxLayerCell(ICell); + parallelForInner( + Team, Range{KMin, KMax}, INNER_LAMBDA(int K) { + LocTempNonLocalTendDiag(ICell, K) = 0.0_Real; + }); + Kokkos::single(Kokkos::PerTeam(Team), [&]() { + LocTempNonLocalColumnSumDiag(ICell) = 0.0_Real; + }); + }); + } + // compute tracer horizotal advection Array2DReal NormalVelEdge = State->getNormalVelocity(VelTimeLevel); const Array2DReal &FluxPseudoThickEdge = @@ -936,6 +1024,65 @@ void Tendencies::computeTracerTendenciesOnly( Pacer::stop("Tend:surfaceTracerRestoring", 2); } + // Compute KPP non-local tracer tendency + if (TracerNonLocalFluxEnabled) { + KPPMix *KPPInstance = KPPMix::getInstance(); + if (KPPInstance && KPPInstance->Enabled) { + Pacer::start("Tend:tracerNonLocalFlux", 2); + OMEGA_SCOPE(LocNonLocalFlux, KPPInstance->VertNonLocalFlux); + parallelForOuter( + {NTracers, Mesh->NCellsAll}, + KOKKOS_LAMBDA(int L, int ICell, const TeamMember &Team) { + const int KMin = MinLayerCell(ICell); + const int KMax = MaxLayerCell(ICell); + const int KRange = vertRangeChunked(KMin, KMax); + parallelForInner( + Team, KRange, INNER_LAMBDA(int KChunk) { + const I4 KStart = chunkStart(KChunk, KMin); + const I4 KLen = chunkLength(KChunk, KStart, KMax); + for (int KVec = 0; KVec < KLen; ++KVec) { + const I4 K = KStart + KVec; + const Real NonLocalTend = + LocSurfaceTracerFlux(L, ICell) * + (LocNonLocalFlux(ICell, K) - + LocNonLocalFlux(ICell, K + 1)); + LocTracerTend(L, ICell, K) += NonLocalTend; + + if (LocTracerNonLocalDiagnosticsEnable && + LocHasTempTracer && L == LocTempTracerIndex) { + LocTempNonLocalTendDiag(ICell, K) += NonLocalTend; + } + } + }); + }); + + if (LocTracerNonLocalDiagnosticsEnable && LocHasTempTracer) { + parallelForOuter( + {Mesh->NCellsAll}, + KOKKOS_LAMBDA(int ICell, const TeamMember &Team) { + const int KMin = MinLayerCell(ICell); + const int KMax = MaxLayerCell(ICell); + Real Sum = 0.0_Real; + parallelReduceInner( + Team, Range{KMin, KMax}, + [&](int K, Real &LocalSum) { + const Real NonLocalTend = + LocSurfaceTracerFlux(LocTempTracerIndex, ICell) * + (LocNonLocalFlux(ICell, K) - + LocNonLocalFlux(ICell, K + 1)); + LocalSum += NonLocalTend; + }, + Sum); + Kokkos::single(Kokkos::PerTeam(Team), [&]() { + LocTempNonLocalColumnSumDiag(ICell) = Sum; + }); + }); + } + + Pacer::stop("Tend:tracerNonLocalFlux", 2); + } + } + // compute tracer forcing tendency if (LocSfcTracerForcing.Enabled) { Pacer::start("Tend:sfcTracerForcing", 2); @@ -1028,6 +1175,10 @@ void Tendencies::computeVelocityTendencies( ) { Pacer::start("Tend:computeVelocityTendencies", 1); + if (StageVerticalMixingEnabled) { + computeStageVerticalMixing(State, AuxState, TracerArray, ThickTimeLevel, + VelTimeLevel); + } AuxState->computeMomAux(State, TracerArray, ThickTimeLevel, VelTimeLevel, ProjDt); computeVelocityTendenciesOnly(State, AuxState, TracerArray, ThickTimeLevel, @@ -1054,6 +1205,10 @@ void Tendencies::computeTracerTendencies( Pacer::start("Tend:computeTracerTendencies", 1); + if (StageVerticalMixingEnabled) { + computeStageVerticalMixing(State, AuxState, TracerArray, ThickTimeLevel, + VelTimeLevel); + } const auto &MeanPseudoThickEdge = AuxState->PseudoThicknessAux.MeanPseudoThickEdge; Pacer::start("Tend:computeTracerAuxCell", 2); @@ -1094,6 +1249,10 @@ void Tendencies::computeAllTendencies( AuxState->computeAll(State, TracerArray, ThickTimeLevel, VelTimeLevel, ProjDt); + if (StageVerticalMixingEnabled) { + computeStageVerticalMixing(State, AuxState, TracerArray, ThickTimeLevel, + VelTimeLevel); + } computePseudoThicknessTendenciesOnly(State, AuxState, ThickTimeLevel, VelTimeLevel, Time); computeVelocityTendenciesOnly(State, AuxState, TracerArray, ThickTimeLevel, @@ -1102,6 +1261,225 @@ void Tendencies::computeAllTendencies( VelTimeLevel, Time); } // end all tendency compute +//------------------------------------------------------------------------------ +// Set surface tracer flux for use by KPP non-local tracer tendency +void Tendencies::setSurfaceTracerFlux(const Array2DReal &Flux) { + OMEGA_REQUIRE(Flux.extent(0) == SurfaceTracerFlux.extent(0), + "Tendencies::setSurfaceTracerFlux: tracer dimension mismatch"); + OMEGA_REQUIRE(Flux.extent(1) == SurfaceTracerFlux.extent(1), + "Tendencies::setSurfaceTracerFlux: cell dimension mismatch"); + Kokkos::deep_copy(SurfaceTracerFlux, Flux); +} + +//------------------------------------------------------------------------------ +// Prepare KPP state for the current stage. Final VertDiff/VertVisc coefficient +// assembly is owned by VertMix::computeVertMix. +void Tendencies::computeStageVerticalMixing(const OceanState *State, + const AuxiliaryState *AuxState, + const Array3DReal &TracerArray, + int ThickTimeLevel, + int VelTimeLevel) { + (void)AuxState; + KPPMix *KPPInstance = KPPMix::getInstance(); + + if (!EqState || !KPPInstance || !KPPInstance->Enabled) + return; + + I4 TempIdx = -1; + I4 SaltIdx = -1; + if (Tracers::getIndex(TempIdx, "Temperature") != 0 || + Tracers::getIndex(SaltIdx, "Salinity") != 0) { + LOG_WARN("Tendencies::computeStageVerticalMixing: Temperature/Salinity " + "tracers not found, skipping KPP stage update"); + return; + } + + const I4 NCellsAll = Mesh->NCellsAll; + const I4 NVertLayers = VCoord->NVertLayers; + + Array2DReal ConservTemp("KPP-ConservTemp", NCellsAll, NVertLayers); + Array2DReal AbsSalinity("KPP-AbsSalinity", NCellsAll, NVertLayers); + parallelFor( + "KPP-ExtractTS", {NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + ConservTemp(ICell, K) = TracerArray(TempIdx, ICell, K); + AbsSalinity(ICell, K) = TracerArray(SaltIdx, ICell, K); + }); + + Array2DReal LayerThickCell = State->getPseudoThickness(ThickTimeLevel); + Array2DReal NormalVelEdge = State->getNormalVelocity(VelTimeLevel); + + Array1DReal SurfacePressure("KPP-SurfacePressure", NCellsAll); + deepCopy(SurfacePressure, 1.0e5_Real); + const_cast(VCoord)->computePressure(LayerThickCell, + SurfacePressure); + + OMEGA_SCOPE(PressureMid, VCoord->PressureMid); + + EqState->computeSpecVol(ConservTemp, AbsSalinity, PressureMid); + EqState->computeBruntVaisalaFreqSq(ConservTemp, AbsSalinity, PressureMid, + EqState->SpecVol); + + OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); + Array2DReal PotentialDensity("KPP-PotentialDensity", NCellsAll, NVertLayers); + Array2DReal PotentialDensityPressure("KPP-PotentialDensityPressure", + NCellsAll, NVertLayers); + parallelFor( + "KPP-PotentialDensityPressure", {NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + const I4 KSurf = MinLayerCell(ICell); + PotentialDensityPressure(ICell, K) = PressureMid(ICell, KSurf); + }); + EqState->computeSpecVolDisp(ConservTemp, AbsSalinity, + PotentialDensityPressure, 0); + OMEGA_SCOPE(SpecVolPotential, EqState->SpecVolDisplaced); + parallelFor( + "KPP-PotentialDensity", {NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + PotentialDensity(ICell, K) = + 1.0_Real / Kokkos::max(1.0e-12_Real, SpecVolPotential(ICell, K)); + }); + + Array2DReal TangentialVelEdge("KPP-TangentialVelEdge", Mesh->NEdgesSize, + NVertLayers); + { + TangentialReconOnEdge TanReconEdge(Mesh); + OMEGA_SCOPE(LocTangentialVelEdge, TangentialVelEdge); + OMEGA_SCOPE(MinLayerEdgeTop, VCoord->MinLayerEdgeTop); + OMEGA_SCOPE(MaxLayerEdgeBot, VCoord->MaxLayerEdgeBot); + parallelForOuter( + {Mesh->NEdgesAll}, KOKKOS_LAMBDA(int IEdge, const TeamMember &Team) { + const int KMin = MinLayerEdgeTop(IEdge); + const int KMax = MaxLayerEdgeBot(IEdge); + const int KRange = vertRangeChunked(KMin, KMax); + parallelForInner( + Team, KRange, INNER_LAMBDA(int KChunk) { + TanReconEdge(LocTangentialVelEdge, IEdge, KChunk, + NormalVelEdge); + }); + }); + } + + Array1DReal IceFraction("KPP-IceFraction", NCellsAll); + + OMEGA_SCOPE(LocSurfaceFrictionVelocity, + KPPInstance->SurfaceFrictionVelocity); + OMEGA_SCOPE(LocSurfaceBuoyancyFlux, KPPInstance->SurfaceBuoyancyFlux); + + const EosType LocEosChoice = EqState->EosChoice; + const Real LocLinearDRhodT = EqState->getLinearDRhodT(); + const Real LocLinearDRhodS = EqState->getLinearDRhodS(); + const Real HeatFluxToTracerFluxFactor = + 1._Real / (RhoSw * (LocEosChoice == EosType::Teos10Eos ? Cp0Sw : CpSw)); + + const auto *ForcingState = Forcing::getDefault(); + if (!ForcingState) { + LOG_WARN("Tendencies::computeStageVerticalMixing: Forcing has not " + "been initialized, skipping KPP stage update"); + return; + } + + const auto &SfcStressForcing = ForcingState->SfcStressForcing; + const auto &TracerForcing = ForcingState->TracerForcing; + OMEGA_SCOPE(ZonalStressCell, SfcStressForcing.ZonalStressCell); + OMEGA_SCOPE(MeridStressCell, SfcStressForcing.MeridStressCell); + OMEGA_SCOPE(LocLatentHeatFlux, TracerForcing.LatentHeatFluxCell); + OMEGA_SCOPE(LocSensibleHeatFlux, TracerForcing.SensibleHeatFluxCell); + OMEGA_SCOPE(LocLongWaveHeatFluxUp, TracerForcing.LongWaveHeatFluxUpCell); + OMEGA_SCOPE(LocLongWaveHeatFluxDown, TracerForcing.LongWaveHeatFluxDownCell); + OMEGA_SCOPE(LocSeaIceHeatFlux, TracerForcing.SeaIceHeatFluxCell); + OMEGA_SCOPE(LocShortWaveHeatFlux, TracerForcing.ShortWaveHeatFluxCell); + OMEGA_SCOPE(LocSnowFlux, TracerForcing.SnowFluxCell); + OMEGA_SCOPE(LocRainFlux, TracerForcing.RainFluxCell); + OMEGA_SCOPE(LocEvaporationFlux, TracerForcing.EvaporationFluxCell); + OMEGA_SCOPE(LocSeaIceFreshWaterFlux, TracerForcing.SeaIceFreshWaterFluxCell); + OMEGA_SCOPE(LocIceRunoffFlux, TracerForcing.IceRunoffFluxCell); + OMEGA_SCOPE(LocRiverRunoffFlux, TracerForcing.RiverRunoffFluxCell); + OMEGA_SCOPE(LocSeaIceSaltFlux, TracerForcing.SeaIceSaltFluxCell); + OMEGA_SCOPE(LocSurfaceTracerFlux, SurfaceTracerFlux); + OMEGA_SCOPE(LocSpecVol, EqState->SpecVol); + Teos10BruntVaisalaFreqSq Teos10Coeff(VCoord); + Teos10Eos Teos10EosImpl(VCoord); + + const bool LocUpdateSurfaceTracerFlux = TracerNonLocalFluxEnabled; + const bool LocUseTracerForcing = SfcTracerForcing.Enabled; + const I4 TempTracerIndex = TempIdx; + const I4 SaltTracerIndex = SaltIdx; + + if (LocUpdateSurfaceTracerFlux) { + deepCopy(SurfaceTracerFlux, 0.0_Real); + } + deepCopy(KPPInstance->SurfaceBuoyancyFlux, 0.0_Real); + + parallelFor( + "KPP-SurfaceForcing", {NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { + const Real tau_x = ZonalStressCell(ICell); + const Real tau_y = MeridStressCell(ICell); + const Real tau_mag = Kokkos::sqrt(tau_x * tau_x + tau_y * tau_y); + LocSurfaceFrictionVelocity(ICell) = + Kokkos::sqrt(Kokkos::max(0.0_Real, tau_mag / RhoSw)); + LocSurfaceBuoyancyFlux(ICell) = 0.0_Real; + if (LocUpdateSurfaceTracerFlux) { + LocSurfaceTracerFlux(TempTracerIndex, ICell) = 0.0_Real; + LocSurfaceTracerFlux(SaltTracerIndex, ICell) = 0.0_Real; + } + IceFraction(ICell) = 0.0_Real; + + if (!LocUseTracerForcing) { + return; + } + + const I4 KSurf = MinLayerCell(ICell); + const Real surface_salinity = AbsSalinity(ICell, KSurf); + const Real surface_temp = ConservTemp(ICell, KSurf); + const Real ct_freezing = Teos10EosImpl.calcCtFreezing( + surface_salinity, PressureMid(ICell, KSurf) * Pa2Db, 0.0_Real); + const Real heat_flux = + LocLatentHeatFlux(ICell) + LocSensibleHeatFlux(ICell) + + LocLongWaveHeatFluxUp(ICell) + LocLongWaveHeatFluxDown(ICell) + + LocSeaIceHeatFlux(ICell) + LocShortWaveHeatFlux(ICell) + + (LocRainFlux(ICell) + LocRiverRunoffFlux(ICell)) * Cp0Sw * + surface_temp + + (LocSnowFlux(ICell) + LocIceRunoffFlux(ICell)) * + (Cp0Sw * ct_freezing - LatIce); + const Real freshwater_flux = + LocSnowFlux(ICell) + LocRainFlux(ICell) + + LocSeaIceFreshWaterFlux(ICell) + LocIceRunoffFlux(ICell) + + LocRiverRunoffFlux(ICell) + LocEvaporationFlux(ICell); + const Real temp_flux = heat_flux * HeatFluxToTracerFluxFactor; + const Real salt_flux = LocSeaIceSaltFlux(ICell) / RhoSw - + freshwater_flux * surface_salinity / RhoSw; + const Real spec_vol = + Kokkos::max(1.0e-12_Real, LocSpecVol(ICell, KSurf)); + const Real rho_surface = 1.0_Real / spec_vol; + Real alpha = 0.0_Real; + Real beta = 0.0_Real; + if (LocEosChoice == EosType::Teos10Eos) { + alpha = Teos10Coeff.calcAlpha( + AbsSalinity(ICell, KSurf), ConservTemp(ICell, KSurf), + PressureMid(ICell, KSurf) * Pa2Db, spec_vol); + beta = Teos10Coeff.calcBeta( + AbsSalinity(ICell, KSurf), ConservTemp(ICell, KSurf), + PressureMid(ICell, KSurf) * Pa2Db, spec_vol); + } else if (LocEosChoice == EosType::LinearEos) { + alpha = -LocLinearDRhodT / rho_surface; + beta = LocLinearDRhodS / rho_surface; + } + LocSurfaceBuoyancyFlux(ICell) = + Gravity * (alpha * temp_flux - beta * salt_flux); + if (LocUpdateSurfaceTracerFlux) { + LocSurfaceTracerFlux(TempTracerIndex, ICell) = temp_flux; + LocSurfaceTracerFlux(SaltTracerIndex, ICell) = salt_flux; + } + }); + + Array1DReal WindSpeed10m; + KPPInstance->computeKPPMix( + PotentialDensity, NormalVelEdge, TangentialVelEdge, + KPPInstance->SurfaceFrictionVelocity, KPPInstance->SurfaceBuoyancyFlux, + EqState->BruntVaisalaFreqSq, IceFraction, WindSpeed10m); +} + } // end namespace OMEGA //===----------------------------------------------------------------------===// diff --git a/components/omega/src/ocn/Tendencies.h b/components/omega/src/ocn/Tendencies.h index c60a2783ddb3..fc5a4a0360a4 100644 --- a/components/omega/src/ocn/Tendencies.h +++ b/components/omega/src/ocn/Tendencies.h @@ -81,6 +81,24 @@ class Tendencies { TracerHyperDiffOnCell TracerHyperDiff; SurfaceTracerRestoringOnCell SurfaceTracerRestoring; + // Surface tracer flux used for KPP non-local tracer tendency [NTracers, + // NCellsAll] + Array2DReal SurfaceTracerFlux; + + // Diagnostics for temperature forcing pathways used in KPP comparison. + // These are raw contributions added to TracerTend before tracer update. + Array2DReal TempNonLocalTendDiag; + Array1DReal TempNonLocalColumnSumDiag; + + // Enables explicit non-local tracer tendency from KPP + bool TracerNonLocalFluxEnabled = false; + + // Enable diagnostics that isolate temperature non-local terms. + bool TracerNonLocalDiagnosticsEnable = true; + + // Controls whether KPP is recomputed during tendency stages. + bool StageVerticalMixingEnabled = true; + std::string Name; // Methods to compute tendency groups @@ -120,6 +138,13 @@ class Tendencies { int ThickTimeLevel, int VelTimeLevel, TimeInstant Time); + void setSurfaceTracerFlux(const Array2DReal &Flux); + + void computeStageVerticalMixing(const OceanState *State, + const AuxiliaryState *AuxState, + const Array3DReal &TracerArray, + int ThickTimeLevel, int VelTimeLevel); + // Create a non-default group of tendencies template static Tendencies *create(const std::string &Name, ArgTypes &&...Args) { diff --git a/components/omega/src/ocn/VertMix.cpp b/components/omega/src/ocn/VertMix.cpp index 45431292ed16..207b6279970b 100644 --- a/components/omega/src/ocn/VertMix.cpp +++ b/components/omega/src/ocn/VertMix.cpp @@ -16,6 +16,7 @@ #include "GlobalConstants.h" #include "HorzMesh.h" #include "HorzOperators.h" +#include "KPPMix.h" #include "TimeStepper.h" #include "TriDiagSolvers.h" @@ -224,6 +225,17 @@ void VertMix::computeVertMix(const Array2DReal &NormalVelocity, OMEGA_SCOPE(LocBackVisc, BackVisc); OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); + const Real LocConvDiff = LocComputeVertMixConv.ConvDiff; + const Real LocConvTriggerBVF = LocComputeVertMixConv.ConvTriggerBVF; + Array1DI4 KPPBoundaryLayerIndex("VertMix-KPPBoundaryLayerIndex", + Mesh->NCellsAll); + deepCopy(KPPBoundaryLayerIndex, -1); + KPPMix *KPPInstance = KPPMix::getInstance(); + const bool LocKPPEnabled = (KPPInstance && KPPInstance->Enabled); + if (LocKPPEnabled) { + deepCopy(KPPBoundaryLayerIndex, KPPInstance->IndexBoundaryLayerDepth); + } + OMEGA_SCOPE(LocKPPBoundaryLayerIndex, KPPBoundaryLayerIndex); /// First, initialize VertDiff and VertVisc to background values parallelForOuter( @@ -311,7 +323,34 @@ void VertMix::computeVertMix(const Array2DReal &NormalVelocity, }); }); } - /// Third, compute convective mixing if enabled + + /// Third, apply KPP mixing if enabled + if (LocKPPEnabled) { + const I4 NVertLayers = VCoord->NVertLayers; + I4 KPPMergeMode = 0; // 0=additive profile, 1=matched coefficients + if (KPPInstance->MatchTechniqueStr == "MatchBoth") { + KPPMergeMode = 1; + } + + OMEGA_SCOPE(LocKPPVertDiff, KPPInstance->VertDiff); + OMEGA_SCOPE(LocKPPVertVisc, KPPInstance->VertVisc); + + parallelFor( + "VertMix-KPP", {Mesh->NCellsAll, NVertLayers + 1}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + if (K <= LocKPPBoundaryLayerIndex(ICell) + 1) { + if (KPPMergeMode == 1) { + LocVertDiff(ICell, K) = LocKPPVertDiff(ICell, K); + LocVertVisc(ICell, K) = LocKPPVertVisc(ICell, K); + } else { + LocVertDiff(ICell, K) += LocKPPVertDiff(ICell, K); + LocVertVisc(ICell, K) += LocKPPVertVisc(ICell, K); + } + } + }); + } + + /// Fourth, compute convective mixing if enabled if (LocComputeVertMixConv.Enabled) { parallelForOuter( "VertMix-Conv", {Mesh->NCellsAll}, @@ -322,8 +361,19 @@ void VertMix::computeVertMix(const Array2DReal &NormalVelocity, parallelForInner( Team, KRange, INNER_LAMBDA(int KChunk) { - LocComputeVertMixConv(LocVertDiff, LocVertVisc, ICell, - KChunk, BruntVaisalaFreqSq); + const I4 KStart = chunkStart(KChunk, KMin); + const I4 KLen = chunkLength(KChunk, KStart, KMax); + for (int KVec = 0; KVec < KLen; ++KVec) { + const I4 K = KStart + KVec; + const bool ApplyConv = + (!LocKPPEnabled) || + (K > LocKPPBoundaryLayerIndex(ICell) + 1); + if (ApplyConv && + BruntVaisalaFreqSq(ICell, K) < LocConvTriggerBVF) { + LocVertDiff(ICell, K) += LocConvDiff; + LocVertVisc(ICell, K) += LocConvDiff; + } + } }); }); } diff --git a/components/omega/src/timeStepping/ForwardBackwardStepper.cpp b/components/omega/src/timeStepping/ForwardBackwardStepper.cpp index 6e1b2f2f47d9..bdfbaaafa413 100644 --- a/components/omega/src/timeStepping/ForwardBackwardStepper.cpp +++ b/components/omega/src/timeStepping/ForwardBackwardStepper.cpp @@ -91,8 +91,14 @@ void ForwardBackwardStepper::doStep( Tracers::updateTimeLevels(); Pacer::stop("ForwardBackward:haloExch", 3); + // Recompute KPP once on the fully updated state before implicit mixing. + CurTracerArray = Tracers::getAll(TracerCurLevel); + AuxState->computeAll(State, CurTracerArray, ThickCurLevel, VelCurLevel, + TimeStep); + Tend->computeStageVerticalMixing(State, AuxState, CurTracerArray, + ThickCurLevel, VelCurLevel); + // Apply implicit vertical mixing - CurTracerArray = Tracers::getAll(VelCurLevel); if (VMix->VelVertMixSetup.Enabled or VMix->TracerVertMixSetup.Enabled) { VMix->VertMixImplicit(State, AuxState, CurTracerArray, NTracers, VelCurLevel); diff --git a/components/omega/src/timeStepping/RungeKutta2Stepper.cpp b/components/omega/src/timeStepping/RungeKutta2Stepper.cpp index dcf33ad611a2..2191cca2b370 100644 --- a/components/omega/src/timeStepping/RungeKutta2Stepper.cpp +++ b/components/omega/src/timeStepping/RungeKutta2Stepper.cpp @@ -75,8 +75,13 @@ void RungeKutta2Stepper::doStep(OceanState *State, // model state Tracers::updateTimeLevels(); Pacer::stop("RK2:haloExch", 3); - // Apply implicit vertical mixing + // Recompute KPP once on the fully updated state before implicit mixing. CurTracerArray = Tracers::getAll(CurLevel); + AuxState->computeAll(State, CurTracerArray, CurLevel, CurLevel, TimeStep); + Tend->computeStageVerticalMixing(State, AuxState, CurTracerArray, CurLevel, + CurLevel); + + // Apply implicit vertical mixing if (VMix->VelVertMixSetup.Enabled or VMix->TracerVertMixSetup.Enabled) { VMix->VertMixImplicit(State, AuxState, CurTracerArray, NTracers, CurLevel); diff --git a/components/omega/src/timeStepping/RungeKutta4Stepper.cpp b/components/omega/src/timeStepping/RungeKutta4Stepper.cpp index abd83b03043e..f5a8e0bcd493 100644 --- a/components/omega/src/timeStepping/RungeKutta4Stepper.cpp +++ b/components/omega/src/timeStepping/RungeKutta4Stepper.cpp @@ -85,7 +85,10 @@ void RungeKutta4Stepper::doStep(OceanState *State, // model state Array3DReal NextTracerArray = Tracers::getAll(NextLevel); TimeInstant ForcingStageTime = SimTime; - VertMix *VMix = VertMix::getInstance(); + VertMix *VMix = VertMix::getInstance(); + const bool StageKPPEnabledPrev = Tend->StageVerticalMixingEnabled; + Tend->StageVerticalMixingEnabled = + StageKPPEnabledPrev && Tend->TracerNonLocalFluxEnabled; for (int Stage = 0; Stage < NStages; ++Stage) { const TimeInstant StageTime = SimTime + RKC[Stage] * TimeStep; @@ -139,8 +142,14 @@ void RungeKutta4Stepper::doStep(OceanState *State, // model state Tracers::updateTimeLevels(); Pacer::stop("RK4:haloExch", 3); - // Apply implicit vertical mixing + // Recompute KPP once on the fully updated state before implicit mixing. CurTracerArray = Tracers::getAll(CurLevel); + AuxState->computeAll(State, CurTracerArray, CurLevel, CurLevel, TimeStep); + Tend->computeStageVerticalMixing(State, AuxState, CurTracerArray, CurLevel, + CurLevel); + Tend->StageVerticalMixingEnabled = StageKPPEnabledPrev; + + // Apply implicit vertical mixing if (VMix->VelVertMixSetup.Enabled or VMix->TracerVertMixSetup.Enabled) { VMix->VertMixImplicit(State, AuxState, CurTracerArray, NTracers, CurLevel); diff --git a/components/omega/test/CMakeLists.txt b/components/omega/test/CMakeLists.txt index 20fe917bdd48..5c1dfa98dd41 100644 --- a/components/omega/test/CMakeLists.txt +++ b/components/omega/test/CMakeLists.txt @@ -568,6 +568,23 @@ add_omega_test( "-n;4" ) +########################## +# KPP tests +########################## + +add_omega_test( + KPP_PROFILES_TEST + testKPPMix.exe + ocn/KPPMixTest.cpp + "-n;1" + test_arg=profiles +) +add_omega_ctest(KPP_BLD_TEST testKPPMix.exe "-n;1" bld) +add_omega_ctest(KPP_VMIX_COEFF_TEST testKPPMix.exe "-n;1" vmix) +add_omega_ctest(KPP_INTEGRATION_TEST testKPPMix.exe "-n;1" integration) +add_omega_ctest(KPP_CONFIG_GRADIENT_TEST testKPPMix.exe "-n;1" config-gradient) +add_omega_ctest(KPP_CONFIG_UNSUPPORTED_TEST testKPPMix.exe "-n;1" config-unsupported) + ################## # VAdv test ################## diff --git a/components/omega/test/ocn/KPPMixTest.cpp b/components/omega/test/ocn/KPPMixTest.cpp new file mode 100644 index 000000000000..d7a6cd842184 --- /dev/null +++ b/components/omega/test/ocn/KPPMixTest.cpp @@ -0,0 +1,1996 @@ +//===-- Test driver for OMEGA KPP mixing -------------------------*- C++ +//-*-===// +/// +/// \file +/// \brief Unit tests for KPP profiles, utilities, and mixing coefficients +/// +//===----------------------------------------------------------------------===// + +#include "KPPMix.h" +#include "Config.h" +#include "Decomp.h" +#include "Dimension.h" +#include "Field.h" +#include "Halo.h" +#include "HorzMesh.h" +#include "IO.h" +#include "IOStream.h" +#include "KPPConstants.h" +#include "Logging.h" +#include "MachEnv.h" +#include "OceanTestCommon.h" +#include "OmegaKokkos.h" +#include "Pacer.h" +#include "TimeMgr.h" +#include "VertCoord.h" +#include "mpi.h" + +#include +#include + +using namespace OMEGA; + +namespace { + +#ifdef SINGLE_PRECISION +constexpr Real RTol = 2.0e-5_Real; +constexpr Real ATol = 2.0e-6_Real; +constexpr Real BLDRTol = 2.0e-4_Real; +#else +constexpr Real RTol = 2.0e-10_Real; +constexpr Real ATol = 2.0e-12_Real; +constexpr Real BLDRTol = RTol; +#endif + +constexpr Real LayerThickness = 10.0_Real; +constexpr Real TestOBLDepth = 40.0_Real; +constexpr I4 TestOBLIndex = 3; + +std::unique_ptr TestClock; +Clock *TestClockPtr = nullptr; + +void initKPPMixTest(const std::string &TestGroup) { + MachEnv::init(MPI_COMM_WORLD); + MachEnv *DefEnv = MachEnv::getDefault(); + MPI_Comm DefComm = DefEnv->getComm(); + initLogging(DefEnv); + LOG_INFO("------ KPP Mixing Unit Tests ------"); + + Config("Omega"); + Config::readAll("omega.yml"); + if (TestGroup == "config-gradient" || TestGroup == "config-unsupported") { + Config VertMixConfig("VertMix"); + Config KPPConfig("KPP"); + Error Err; + Err += Config::getOmegaConfig()->get(VertMixConfig); + Err += VertMixConfig.get(KPPConfig); + CHECK_ERROR_ABORT(Err, "KPPMixTest: unable to access KPP configuration"); + const std::string MatchTechnique = + TestGroup == "config-gradient" ? "MatchGradient" : "NotAKPPMode"; + KPPConfig.set("MatchTechnique", MatchTechnique); + } + IO::init(DefComm); + Decomp::init(); + Halo::init(); + + Calendar::init("No Leap"); + TimeInstant StartTime(0, 1, 1, 0, 0, 0.0); + TimeInterval TimeStep(1, TimeUnits::Hours); + TestClock = std::make_unique(StartTime, TimeStep); + TestClockPtr = TestClock.get(); + Field::init(TestClockPtr); + IOStream::init(TestClockPtr); + HorzMesh::init(TestClockPtr); + VertCoord::init(false); + KPPMix::init(); +} + +void finalizeKPPMixTest() { + KPPMix::destroyInstance(); + IOStream::finalize(); + VertCoord::clear(); + HorzMesh::clear(); + Halo::clear(); + Decomp::clear(); + Field::clear(); + Dimension::clear(); + TestClockPtr = nullptr; + TestClock.reset(); + MachEnv::removeAll(); +} + +void checkResult(const char *TestName, int NumErrors) { + if (NumErrors != 0) { + ABORT_ERROR("KPPMixTest: {} FAIL with {} errors", TestName, NumErrors); + } + LOG_INFO("KPPMixTest: {} PASS", TestName); +} + +void testStabilityFunctions() { + constexpr int NTests = 9; + int NumErrors = 0; + + parallelReduce( + "KPPMixTest-StabilityFunctions", {NTests}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + Real Zeta = 0.0_Real; + switch (ITest) { + case 0: + Zeta = 2.0_Real; + break; + case 1: + Zeta = 0.1_Real; + break; + case 2: + Zeta = 0.0_Real; + break; + case 3: + Zeta = -0.1_Real; + break; + case 4: + Zeta = KPP::ZETA_M; + break; + case 5: + Zeta = KPP::ZETA_M - 1.0e-4_Real; + break; + case 6: + Zeta = KPP::ZETA_S; + break; + case 7: + Zeta = KPP::ZETA_S - 1.0e-4_Real; + break; + default: + Zeta = -10.0_Real; + break; + } + + Real ExpectedM; + if (Zeta >= 0.0_Real) { + ExpectedM = 1.0_Real / (1.0_Real + 5.0_Real * Zeta); + } else if (Zeta >= KPP::ZETA_M) { + ExpectedM = Kokkos::pow(1.0_Real - 16.0_Real * Zeta, 0.25_Real); + } else { + ExpectedM = Kokkos::pow(KPP::A_MO_M - KPP::C_MO_M * Zeta, + 1.0_Real / 3.0_Real); + } + + Real ExpectedS; + if (Zeta >= 0.0_Real) { + ExpectedS = 1.0_Real / (1.0_Real + 5.0_Real * Zeta); + } else if (Zeta >= KPP::ZETA_S) { + ExpectedS = Kokkos::sqrt(1.0_Real - 16.0_Real * Zeta); + } else { + ExpectedS = Kokkos::pow(KPP::A_MO_S - KPP::C_MO_S * Zeta, + 1.0_Real / 3.0_Real); + } + + const Real ActualM = KPP::KPPProfileM2(Zeta); + const Real ActualS = KPP::KPPProfileS2(Zeta); + if (!isApprox(ActualM, ExpectedM, RTol, ATol) || ActualM <= 0.0_Real) + ++ErrorCount; + if (!isApprox(ActualS, ExpectedS, RTol, ATol) || ActualS <= 0.0_Real) + ++ErrorCount; + }, + NumErrors); + + checkResult("stability functions", NumErrors); + + NumErrors = 0; + parallelReduce( + "KPPMixTest-StabilityContinuity", {2}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + const Real Transition = ITest == 0 ? KPP::ZETA_M : KPP::ZETA_S; + const Real Epsilon = 1.0e-6_Real; + const Real Above = ITest == 0 + ? KPP::KPPProfileM2(Transition + Epsilon) + : KPP::KPPProfileS2(Transition + Epsilon); + const Real Below = ITest == 0 + ? KPP::KPPProfileM2(Transition - Epsilon) + : KPP::KPPProfileS2(Transition - Epsilon); + if (!isApprox(Above, Below, 2.0e-5_Real, 2.0e-5_Real)) + ++ErrorCount; + }, + NumErrors); + checkResult("stability transition continuity", NumErrors); +} + +void testShapeFunctions() { + constexpr int NTests = 7; + int NumErrors = 0; + + parallelReduce( + "KPPMixTest-ShapeFunctions", {NTests}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + Real Sigma; + switch (ITest) { + case 0: + Sigma = 0.25_Real; + break; + case 1: + Sigma = 0.0_Real; + break; + case 2: + Sigma = -0.25_Real; + break; + case 3: + Sigma = -0.5_Real; + break; + case 4: + Sigma = -0.75_Real; + break; + case 5: + Sigma = -1.0_Real; + break; + default: + Sigma = -1.25_Real; + break; + } + + const Real SigmaClamped = + Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, Sigma)); + const Real SigmaMu = -SigmaClamped; + const Real OneMinus = 1.0_Real - SigmaMu; + const Real ExpectedSimple = SigmaMu * OneMinus * OneMinus; + const Real ExpectedParabolic = OneMinus * OneMinus; + const Real ExpectedMatchBoth = + OneMinus * OneMinus * (1.0_Real + 2.0_Real * SigmaMu); + constexpr Real ShapeAtBase = 0.125_Real; + const Real Smooth = + SigmaMu * SigmaMu * (3.0_Real - 2.0_Real * SigmaMu); + + if (!isApprox(KPP::KPPProfileG(Sigma), ExpectedSimple, RTol, ATol)) + ++ErrorCount; + if (!isApprox(KPP::KPPProfileM1(Sigma), ExpectedSimple, RTol, ATol)) + ++ErrorCount; + if (!isApprox(KPP::KPPProfileS1(Sigma), ExpectedSimple, RTol, ATol)) + ++ErrorCount; + if (!isApprox(KPP::KPPProfileGParabolicNonLocal(Sigma), + ExpectedParabolic, RTol, ATol)) + ++ErrorCount; + if (!isApprox(KPP::KPPProfileGMatchBoth(Sigma), ExpectedMatchBoth, + RTol, ATol)) + ++ErrorCount; + if (!isApprox(KPP::KPPProfileMatched(Sigma, ShapeAtBase), + ExpectedSimple + ShapeAtBase * Smooth, RTol, ATol)) + ++ErrorCount; + if (!isApprox(KPP::KPPProfileMatched(Sigma, 0.0_Real), ExpectedSimple, + RTol, ATol)) + ++ErrorCount; + if (!isApprox(KPP::KPPHu(Sigma), KPP::HUON * (1.0_Real + Sigma), RTol, + ATol)) + ++ErrorCount; + }, + NumErrors); + + checkResult("shape functions", NumErrors); +} + +void testLangmuirFunctions() { + int NumErrors = 0; + + parallelReduce( + "KPPMixTest-LangmuirFunctions", {4}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + const Real Wind = ITest == 0 ? -5.0_Real + : ITest == 1 ? 0.0_Real + : ITest == 2 ? 10.0_Real + : 100.0_Real; + const Real UStar = ITest < 2 ? 0.0_Real : 0.01_Real; + const Real WindClamped = Kokkos::fmax(0.0_Real, Wind); + const Real ExpectedStokes = 0.016_Real * WindClamped; + const Real UStarClamped = Kokkos::fmax(KPP::MIN_USTAR, UStar); + const Real StokesClamped = Kokkos::fmax(1.0e-8_Real, ExpectedStokes); + const Real ExpectedLa = Kokkos::sqrt(UStarClamped / StokesClamped); + const Real LaInv = 1.0_Real / Kokkos::fmax(0.5_Real, ExpectedLa); + const Real ExpectedEnhancement = Kokkos::fmin( + 2.0_Real, + Kokkos::fmax(1.0_Real, + Kokkos::sqrt(1.0_Real + 0.5_Real * LaInv * LaInv))); + + const Real Stokes = KPP::EstokesSLModel(Wind, 50.0_Real); + const Real La = KPP::ComputeLangmuirNumber(UStar, Stokes); + const Real Enhancement = + KPP::ComputeEnhancementFactor(Wind, UStar, 50.0_Real); + if (!isApprox(Stokes, ExpectedStokes, RTol, ATol)) + ++ErrorCount; + if (!isApprox(La, ExpectedLa, RTol, ATol)) + ++ErrorCount; + if (!isApprox(Enhancement, ExpectedEnhancement, RTol, ATol) || + Enhancement < 1.0_Real || Enhancement > 2.0_Real) + ++ErrorCount; + }, + NumErrors); + + checkResult("Langmuir functions", NumErrors); +} + +void testOBLUtilities() { + int NumErrors = 0; + + parallelReduce( + "KPPMixTest-OBLUtilities", {4}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + const Real IceFraction = + ITest == 0 ? 0.0_Real + : ITest == 1 ? KPP::ICE_SUPPRESSION_THRESHOLD + : ITest == 2 ? KPP::ICE_SUPPRESSION_THRESHOLD + 0.01_Real + : 0.0_Real; + const I4 LandIceMask = ITest == 3 ? 1 : 0; + const bool ExpectedSuppression = + LandIceMask != 0 || IceFraction > KPP::ICE_SUPPRESSION_THRESHOLD; + if (KPP::ShouldSuppressOBL(IceFraction, LandIceMask) != + ExpectedSuppression) + ++ErrorCount; + + const Real InputDepth = ITest == 0 ? 1.0_Real + : ITest == 1 ? 20.0_Real + : ITest == 2 ? 1.0_Real + : 200.0_Real; + Real ExpectedDepth = Kokkos::fmax(InputDepth, 2.0_Real); + if (IceFraction > KPP::ICE_SUPPRESSION_THRESHOLD) + ExpectedDepth = + Kokkos::fmax(ExpectedDepth, KPP::MIN_OBL_UNDER_ICE); + ExpectedDepth = Kokkos::fmin(ExpectedDepth, 95.0_Real); + if (!isApprox(KPP::ConstrainOBLDepth(InputDepth, 4.0_Real, 100.0_Real, + IceFraction), + ExpectedDepth, RTol, ATol)) + ++ErrorCount; + }, + NumErrors); + + checkResult("OBL utilities", NumErrors); +} + +void testTurbulentVelocityScale() { + int NumErrors = 0; + + parallelReduce( + "KPPMixTest-TurbulentVelocityScale", {6}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + const Real UStar = ITest == 0 ? 0.02_Real + : ITest == 1 ? 0.0_Real + : ITest == 2 ? 0.02_Real + : ITest == 5 ? -0.02_Real + : 0.0_Real; + const Real B0 = ITest == 0 ? 0.0_Real + : ITest == 1 ? -1.0e-7_Real + : ITest == 2 ? -1.0e-7_Real + : ITest == 3 ? 1.0e-7_Real + : 0.0_Real; + const Real H = ITest == 5 ? -50.0_Real : 50.0_Real; + const Real UStarClamped = Kokkos::fmax(0.0_Real, UStar); + const Real HClamped = Kokkos::fmax(0.0_Real, H); + const Real Momentum = UStarClamped * UStarClamped * UStarClamped; + const Real Buoyancy = + 0.35_Real * Kokkos::fmax(0.0_Real, -B0) * HClamped; + const Real Expected = + Kokkos::pow(Momentum + Buoyancy, 1.0_Real / 3.0_Real); + const Real Actual = KPP::ComputeTurbulentVelocityScale(UStar, B0, H); + if (!isApprox(Actual, Expected, RTol, ATol) || Actual < 0.0_Real) + ++ErrorCount; + }, + NumErrors); + + checkResult("turbulent velocity scale", NumErrors); +} + +void setCoefficientTestGeometry() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + + OMEGA_SCOPE(GeomZInterface, VCoord->GeomZInterface); + OMEGA_SCOPE(GeomZMid, VCoord->GeomZMid); + OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); + OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); + OMEGA_SCOPE(BoundaryLayerDepth, KPPInstance->BoundaryLayerDepth); + OMEGA_SCOPE(IndexBoundaryLayerDepth, KPPInstance->IndexBoundaryLayerDepth); + + parallelFor( + "KPPMixTest-SetGeometry", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { + MinLayerCell(ICell) = 0; + MaxLayerCell(ICell) = NVertLayers - 1; + BoundaryLayerDepth(ICell) = TestOBLDepth; + IndexBoundaryLayerDepth(ICell) = TestOBLIndex; + for (I4 K = 0; K <= NVertLayers; ++K) { + GeomZInterface(ICell, K) = -LayerThickness * K; + if (K < NVertLayers) { + GeomZMid(ICell, K) = -LayerThickness * (K + 0.5_Real); + } + } + }); +} + +Real nonLocalNormalization() { + return 10.0_Real * VonKar * + Kokkos::pow(KPP::C_MO_S * VonKar * KPP::SURFACE_LAYER_EXTENT, + 1.0_Real / 3.0_Real); +} + +void testWindOnlyCoefficients() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + setCoefficientTestGeometry(); + + Array2DReal Density("KPPMixTest-Density", Mesh->NCellsSize, + VCoord->NVertLayers); + Array1DReal UStar("KPPMixTest-UStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-B0", Mesh->NCellsSize); + deepCopy(Density, RhoSw); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, 0.0_Real); + + KPPInstance->UseEnhancedDiffusion = false; + KPPInstance->UseNonLocalFlux = true; + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + + const auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + const auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + const auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + const auto TurbVelH = + createHostMirrorCopy(KPPInstance->TurbulentVelocityScale); + + const Real Sigma = -0.5_Real; + const Real Shape = 0.125_Real; + const Real TurbVel = VonKar * 0.02_Real; + const Real ExpectedMix = TestOBLDepth * TurbVel * Shape; + const Real ExpectedNonLocal = nonLocalNormalization() * Shape; + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(VertDiffH(ICell, 2), ExpectedMix, RTol, ATol) || + !isApprox(VertViscH(ICell, 2), ExpectedMix, RTol, ATol) || + !isApprox(TurbVelH(ICell, 2), TurbVel, RTol, ATol) || + !isApprox(NonLocalH(ICell, 2), ExpectedNonLocal, RTol, ATol)) { + ++NumErrors; + } + if (!isApprox(KPP::KPPProfileM1(Sigma), Shape, RTol, ATol) || + VertDiffH(ICell, 0) != 0.0_Real || VertViscH(ICell, 0) != 0.0_Real || + VertDiffH(ICell, 4) != 0.0_Real || VertViscH(ICell, 4) != 0.0_Real || + VertDiffH(ICell, 5) != 0.0_Real || NonLocalH(ICell, 5) != 0.0_Real) { + ++NumErrors; + } + } + checkResult("wind-only coefficients", NumErrors); +} + +void testConvectionOnlyCoefficients() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + setCoefficientTestGeometry(); + + Array2DReal Density("KPPMixTest-ConvDensity", Mesh->NCellsSize, + VCoord->NVertLayers); + Array1DReal UStar("KPPMixTest-ConvUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-ConvB0", Mesh->NCellsSize); + deepCopy(Density, RhoSw); + deepCopy(UStar, 0.0_Real); + deepCopy(B0, -1.0e-7_Real); + + KPPInstance->UseEnhancedDiffusion = false; + KPPInstance->UseNonLocalFlux = true; + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + + const auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + const auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + const auto TurbVelH = + createHostMirrorCopy(KPPInstance->TurbulentVelocityScale); + const Real SigmaLoc = KPP::SURFACE_LAYER_EXTENT; + const Real WM = VonKar * Kokkos::pow(KPP::C_MO_M * SigmaLoc * TestOBLDepth * + VonKar * 1.0e-7_Real, + 1.0_Real / 3.0_Real); + const Real WS = VonKar * Kokkos::pow(KPP::C_MO_S * SigmaLoc * TestOBLDepth * + VonKar * 1.0e-7_Real, + 1.0_Real / 3.0_Real); + constexpr Real Shape = 0.125_Real; + const Real ExpectedVisc = TestOBLDepth * WM * Shape; + const Real ExpectedDiff = TestOBLDepth * WS * Shape; + + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(VertViscH(ICell, 2), ExpectedVisc, RTol, ATol) || + !isApprox(VertDiffH(ICell, 2), ExpectedDiff, RTol, ATol) || + !isApprox(TurbVelH(ICell, 2), WS, RTol, ATol) || + !(VertDiffH(ICell, 2) > VertViscH(ICell, 2))) { + ++NumErrors; + } + } + checkResult("convection-only coefficients", NumErrors); +} + +void testNonLocalProfileModes() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + setCoefficientTestGeometry(); + + Array2DReal Density("KPPMixTest-ProfileDensity", Mesh->NCellsSize, + VCoord->NVertLayers); + Array1DReal UStar("KPPMixTest-ProfileUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-ProfileB0", Mesh->NCellsSize); + deepCopy(Density, RhoSw); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, 0.0_Real); + + KPPInstance->UseEnhancedDiffusion = false; + KPPInstance->UseNonLocalFlux = true; + const Real Normalization = nonLocalNormalization(); + int NumErrors = 0; + + KPPInstance->MatchTechniqueStr = "ParabolicNonLocal"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(NonLocalH(ICell, 0), Normalization, RTol, ATol) || + !isApprox(NonLocalH(ICell, 2), 0.25_Real * Normalization, RTol, + ATol) || + NonLocalH(ICell, 4) != 0.0_Real) { + ++NumErrors; + } + } + + KPPInstance->MatchTechniqueStr = "MatchBoth"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(NonLocalH(ICell, 0), Normalization, RTol, ATol) || + !isApprox(NonLocalH(ICell, 2), 0.5_Real * Normalization, RTol, + ATol) || + NonLocalH(ICell, 4) != 0.0_Real) { + ++NumErrors; + } + } + + KPPInstance->UseNonLocalFlux = false; + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (NonLocalH(ICell, 0) != 0.0_Real || NonLocalH(ICell, 2) != 0.0_Real) { + ++NumErrors; + } + } + checkResult("non-local profile modes", NumErrors); +} + +void testMatchBothInteriorCoefficients() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + setCoefficientTestGeometry(); + + Array2DReal Density("KPPMixTest-MatchDensity", Mesh->NCellsSize, + NVertLayers); + Array1DReal UStar("KPPMixTest-MatchUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-MatchB0", Mesh->NCellsSize); + Array2DReal InteriorDiff("KPPMixTest-InteriorDiff", Mesh->NCellsSize, + NVertLayers + 1); + Array2DReal InteriorVisc("KPPMixTest-InteriorVisc", Mesh->NCellsSize, + NVertLayers + 1); + constexpr Real ExpectedInteriorDiff = 2.0e-3_Real; + constexpr Real ExpectedInteriorVisc = 4.0e-3_Real; + deepCopy(Density, RhoSw); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, 0.0_Real); + deepCopy(InteriorDiff, ExpectedInteriorDiff); + deepCopy(InteriorVisc, ExpectedInteriorVisc); + + KPPInstance->UseEnhancedDiffusion = false; + KPPInstance->UseNonLocalFlux = true; + KPPInstance->MatchTechniqueStr = "MatchBoth"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0, InteriorDiff, + InteriorVisc); + + const auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + const auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + const auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + constexpr Real Sigma = -0.5_Real; + constexpr Real SimpleShape = 0.125_Real; + constexpr Real SmoothAtSigma = 0.5_Real; + const Real TurbVel = VonKar * 0.02_Real; + const Real ExpectedDiffMid = TestOBLDepth * TurbVel * SimpleShape + + SmoothAtSigma * ExpectedInteriorDiff; + const Real ExpectedViscMid = TestOBLDepth * TurbVel * SimpleShape + + SmoothAtSigma * ExpectedInteriorVisc; + const Real ExpectedNonLocal = + nonLocalNormalization() * KPP::KPPProfileGMatchBoth(Sigma); + + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(VertDiffH(ICell, 2), ExpectedDiffMid, RTol, ATol) || + !isApprox(VertViscH(ICell, 2), ExpectedViscMid, RTol, ATol) || + !isApprox(NonLocalH(ICell, 2), ExpectedNonLocal, RTol, ATol) || + !isApprox(VertDiffH(ICell, 4), ExpectedInteriorDiff, RTol, ATol) || + !isApprox(VertViscH(ICell, 4), ExpectedInteriorVisc, RTol, ATol) || + !isApprox(VertDiffH(ICell, 5), ExpectedInteriorDiff, RTol, ATol) || + !isApprox(VertViscH(ICell, 5), ExpectedInteriorVisc, RTol, ATol) || + NonLocalH(ICell, 4) != 0.0_Real || NonLocalH(ICell, 5) != 0.0_Real) { + ++NumErrors; + } + } + checkResult("MatchBoth interior coefficients", NumErrors); +} + +void testEnhancedDiffusion() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + setCoefficientTestGeometry(); + + Array2DReal Density("KPPMixTest-EnhancedDensity", Mesh->NCellsSize, + NVertLayers); + Array1DReal UStar("KPPMixTest-EnhancedUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-EnhancedB0", Mesh->NCellsSize); + deepCopy(Density, RhoSw); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, 0.0_Real); + + KPPInstance->UseNonLocalFlux = true; + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->UseEnhancedDiffusion = false; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (VertDiffH(ICell, 4) != 0.0_Real) { + ++NumErrors; + } + } + + KPPInstance->UseEnhancedDiffusion = true; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + constexpr Real OutsideDelta = 0.5_Real; + const Real OutsideSigma = -35.0_Real / TestOBLDepth; + const Real OutsideProfile = + TestOBLDepth * VonKar * 0.02_Real * KPP::KPPProfileS1(OutsideSigma); + const Real ExpectedOutside = OutsideDelta * (1.0_Real - OutsideDelta) * + (1.0_Real - OutsideDelta) * OutsideProfile; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(VertDiffH(ICell, 4), ExpectedOutside, RTol, ATol) || + !isApprox(VertViscH(ICell, 4), ExpectedOutside, RTol, ATol) || + NonLocalH(ICell, 4) != 0.0_Real) { + ++NumErrors; + } + } + + constexpr Real InsideOBLDepth = 32.0_Real; + deepCopy(KPPInstance->BoundaryLayerDepth, InsideOBLDepth); + deepCopy(KPPInstance->IndexBoundaryLayerDepth, TestOBLIndex); + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + + constexpr Real InsideDelta = 0.7_Real; + constexpr Real OneMinusInsideDelta = 1.0_Real - InsideDelta; + const Real KtupSigma = -25.0_Real / InsideOBLDepth; + const Real TargetSigma = -30.0_Real / InsideOBLDepth; + const Real KtupProfile = + InsideOBLDepth * VonKar * 0.02_Real * KPP::KPPProfileS1(KtupSigma); + const Real TargetProfile = + InsideOBLDepth * VonKar * 0.02_Real * KPP::KPPProfileS1(TargetSigma); + const Real ExpectedInside = + InsideDelta * (OneMinusInsideDelta * OneMinusInsideDelta * KtupProfile + + InsideDelta * InsideDelta * TargetProfile); + const Real ExpectedInsideNonLocal = nonLocalNormalization() * + KPP::KPPProfileG(TargetSigma) * + ExpectedInside / TargetProfile; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(VertDiffH(ICell, 3), ExpectedInside, RTol, ATol) || + !isApprox(VertViscH(ICell, 3), ExpectedInside, RTol, ATol) || + !isApprox(NonLocalH(ICell, 3), ExpectedInsideNonLocal, RTol, ATol)) { + ++NumErrors; + } + } + + Array2DReal InteriorDiff("KPPMixTest-EnhancedInteriorDiff", Mesh->NCellsSize, + NVertLayers + 1); + Array2DReal InteriorVisc("KPPMixTest-EnhancedInteriorVisc", Mesh->NCellsSize, + NVertLayers + 1); + constexpr Real InteriorDiffValue = 2.0e-3_Real; + constexpr Real InteriorViscValue = 4.0e-3_Real; + deepCopy(InteriorDiff, InteriorDiffValue); + deepCopy(InteriorVisc, InteriorViscValue); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, 0.0_Real); + deepCopy(KPPInstance->BoundaryLayerDepth, TestOBLDepth); + deepCopy(KPPInstance->IndexBoundaryLayerDepth, TestOBLIndex); + KPPInstance->MatchTechniqueStr = "MatchBoth"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0, InteriorDiff, + InteriorVisc); + VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + const Real InteriorKtupSigma = -35.0_Real / TestOBLDepth; + const Real DiffMatchShape = + InteriorDiffValue / (TestOBLDepth * VonKar * 0.02_Real); + const Real ViscMatchShape = + InteriorViscValue / (TestOBLDepth * VonKar * 0.02_Real); + const Real DiffKtup = + TestOBLDepth * VonKar * 0.02_Real * + KPP::KPPProfileMatched(InteriorKtupSigma, DiffMatchShape); + const Real ViscKtup = + TestOBLDepth * VonKar * 0.02_Real * + KPP::KPPProfileMatched(InteriorKtupSigma, ViscMatchShape); + const Real ExpectedInteriorEnhancedDiff = + 0.625_Real * InteriorDiffValue + 0.125_Real * DiffKtup; + const Real ExpectedInteriorEnhancedVisc = + 0.625_Real * InteriorViscValue + 0.125_Real * ViscKtup; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(VertDiffH(ICell, 4), ExpectedInteriorEnhancedDiff, RTol, + ATol) || + !isApprox(VertViscH(ICell, 4), ExpectedInteriorEnhancedVisc, RTol, + ATol)) { + ++NumErrors; + } + } + + deepCopy(UStar, 0.0_Real); + deepCopy(B0, 0.0_Real); + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + deepCopy(KPPInstance->BoundaryLayerDepth, InsideOBLDepth); + deepCopy(KPPInstance->IndexBoundaryLayerDepth, TestOBLIndex); + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (VertDiffH(ICell, 3) != 0.0_Real || VertViscH(ICell, 3) != 0.0_Real || + NonLocalH(ICell, 3) != 0.0_Real) { + ++NumErrors; + } + } + checkResult("enhanced diffusion", NumErrors); +} + +void testStableAndZeroForcing() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + setCoefficientTestGeometry(); + + Array2DReal Density("KPPMixTest-StableDensity", Mesh->NCellsSize, + VCoord->NVertLayers); + Array1DReal UStar("KPPMixTest-StableUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-StableB0", Mesh->NCellsSize); + deepCopy(Density, RhoSw); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, 1.0e-7_Real); + + KPPInstance->UseEnhancedDiffusion = false; + KPPInstance->UseNonLocalFlux = true; + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!(VertDiffH(ICell, 2) > 0.0_Real) || + NonLocalH(ICell, 2) != 0.0_Real) { + ++NumErrors; + } + } + + deepCopy(UStar, 0.0_Real); + deepCopy(B0, 0.0_Real); + KPPInstance->UseNonLocalFlux = false; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + const auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (VertDiffH(ICell, 2) != 0.0_Real || VertViscH(ICell, 2) != 0.0_Real) { + ++NumErrors; + } + } + checkResult("stable and zero forcing", NumErrors); +} + +void testCoefficientVerticalDomainEdges() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + setCoefficientTestGeometry(); + + Array2DReal Density("KPPMixTest-EdgeDensity", Mesh->NCellsSize, NVertLayers); + Array1DReal UStar("KPPMixTest-EdgeUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-EdgeB0", Mesh->NCellsSize); + deepCopy(Density, -99.0_Real); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, 0.0_Real); + + OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); + OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); + OMEGA_SCOPE(BoundaryLayerDepth, KPPInstance->BoundaryLayerDepth); + OMEGA_SCOPE(IndexBoundaryLayerDepth, KPPInstance->IndexBoundaryLayerDepth); + parallelFor( + "KPPMixTest-SetPartialColumn", {Mesh->NCellsAll}, + KOKKOS_LAMBDA(I4 ICell) { + MinLayerCell(ICell) = 2; + MaxLayerCell(ICell) = 4; + BoundaryLayerDepth(ICell) = 40.0_Real; + IndexBoundaryLayerDepth(ICell) = 3; + }); + + KPPInstance->UseEnhancedDiffusion = false; + KPPInstance->UseNonLocalFlux = true; + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + const Real ExpectedPartial = + 40.0_Real * VonKar * 0.02_Real * KPP::KPPProfileS1(-0.5_Real); + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (VertDiffH(ICell, 0) != 0.0_Real || VertDiffH(ICell, 1) != 0.0_Real || + !isApprox(VertDiffH(ICell, 2), ExpectedPartial, RTol, ATol) || + !isApprox(VertViscH(ICell, 2), ExpectedPartial, RTol, ATol) || + !(NonLocalH(ICell, 2) > 0.0_Real) || + VertDiffH(ICell, 4) != 0.0_Real || VertDiffH(ICell, 5) != 0.0_Real) { + ++NumErrors; + } + } + + parallelFor( + "KPPMixTest-SetOneLayerColumn", {Mesh->NCellsAll}, + KOKKOS_LAMBDA(I4 ICell) { + MinLayerCell(ICell) = 2; + MaxLayerCell(ICell) = 2; + BoundaryLayerDepth(ICell) = 25.0_Real; + IndexBoundaryLayerDepth(ICell) = 2; + }); + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + const Real ExpectedOneLayer = + 25.0_Real * VonKar * 0.02_Real * KPP::KPPProfileS1(-0.8_Real); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(VertDiffH(ICell, 2), ExpectedOneLayer, RTol, ATol) || + !isApprox(VertViscH(ICell, 2), ExpectedOneLayer, RTol, ATol) || + !(NonLocalH(ICell, 2) > 0.0_Real) || + VertDiffH(ICell, 3) != 0.0_Real || VertViscH(ICell, 3) != 0.0_Real || + NonLocalH(ICell, 3) != 0.0_Real) { + ++NumErrors; + } + } + setCoefficientTestGeometry(); + checkResult("coefficient vertical-domain edges", NumErrors); +} + +void testCoefficientInvalidWetBounds() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + setCoefficientTestGeometry(); + + Array2DReal Density("KPPMixTest-InvalidBoundsDensity", Mesh->NCellsSize, + NVertLayers); + Array1DReal UStar("KPPMixTest-InvalidBoundsUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-InvalidBoundsB0", Mesh->NCellsSize); + deepCopy(Density, -99.0_Real); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, -1.0e-7_Real); + deepCopy(KPPInstance->VertDiff, -7.0_Real); + deepCopy(KPPInstance->VertVisc, -7.0_Real); + deepCopy(KPPInstance->VertNonLocalFlux, -7.0_Real); + deepCopy(KPPInstance->TurbulentVelocityScale, -7.0_Real); + + OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); + OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); + parallelFor( + "KPPMixTest-SetInvalidWetBounds", {Mesh->NCellsAll}, + KOKKOS_LAMBDA(I4 ICell) { + MinLayerCell(ICell) = 2; + MaxLayerCell(ICell) = 1; + }); + + KPPInstance->UseEnhancedDiffusion = false; + KPPInstance->UseNonLocalFlux = true; + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + + const auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + const auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + const auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + const auto TurbVelH = + createHostMirrorCopy(KPPInstance->TurbulentVelocityScale); + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + for (I4 K = 0; K <= NVertLayers; ++K) { + if (VertDiffH(ICell, K) != 0.0_Real || + VertViscH(ICell, K) != 0.0_Real || + NonLocalH(ICell, K) != 0.0_Real || + TurbVelH(ICell, K) != 0.0_Real) { + ++NumErrors; + } + } + } + + setCoefficientTestGeometry(); + checkResult("coefficient invalid wet bounds", NumErrors); +} + +void testConfigurationNormalization() { + KPPMix *KPPInstance = KPPMix::getInstance(); + const int NumErrors = + KPPInstance->MatchTechniqueStr == "SimpleShapes" ? 0 : 1; + checkResult("configuration match normalization", NumErrors); +} + +void testConfiguredValues() { + Config VertMixConfig("VertMix"); + Config KPPConfig("KPP"); + Error Err; + Err += Config::getOmegaConfig()->get(VertMixConfig); + Err += VertMixConfig.get(KPPConfig); + + bool ExpectedEnabled = false; + bool ExpectedNonLocal = false; + bool ExpectedSmoothing = false; + bool ExpectedEnhanced = false; + bool ExpectedDebug = true; + Real ExpectedCriticalRi = 0.0_Real; + Real ExpectedLangmuirIce = 0.0_Real; + Real ExpectedMinimumOBLIce = 0.0_Real; + Real ExpectedMinimumOBL = 0.0_Real; + std::string ExpectedMatch; + std::string ExpectedInterp; + Err += KPPConfig.get("Enable", ExpectedEnabled); + Err += KPPConfig.get("UseNonLocalFlux", ExpectedNonLocal); + Err += KPPConfig.get("UseBLDSmoothing", ExpectedSmoothing); + Err += KPPConfig.get("UseEnhancedDiffusion", ExpectedEnhanced); + Err += KPPConfig.get("DebugDiagnostics", ExpectedDebug); + Err += KPPConfig.get("CriticalBulkRichardsonNumber", ExpectedCriticalRi); + Err += KPPConfig.get("IceFractionThresholdForLangmuir", ExpectedLangmuirIce); + Err += KPPConfig.get("IceFractionThresholdForMinimumOBL", + ExpectedMinimumOBLIce); + Err += KPPConfig.get("MinimumOBLUnderSeaIce", ExpectedMinimumOBL); + Err += KPPConfig.get("MatchTechnique", ExpectedMatch); + Err += KPPConfig.get("InterpType2", ExpectedInterp); + CHECK_ERROR_ABORT(Err, "KPPMixTest: unable to read configured KPP values"); + + const KPPMix *KPPInstance = KPPMix::getInstance(); + int NumErrors = 0; + if (KPPInstance->Enabled != ExpectedEnabled || + KPPInstance->UseNonLocalFlux != ExpectedNonLocal || + KPPInstance->UseBLDSmoothing != ExpectedSmoothing || + KPPInstance->UseEnhancedDiffusion != ExpectedEnhanced || + KPPInstance->DebugDiagnostics != ExpectedDebug || + KPPInstance->MatchTechniqueStr != ExpectedMatch || + KPPInstance->InterpType2Str != ExpectedInterp || + !isApprox(KPPInstance->CriticalRichardson, ExpectedCriticalRi, RTol, + ATol) || + !isApprox(KPPInstance->IceFractionThresholdForLangmuir, + ExpectedLangmuirIce, RTol, ATol) || + !isApprox(KPPInstance->IceFractionThresholdForMinimumOBL, + ExpectedMinimumOBLIce, RTol, ATol) || + !isApprox(KPPInstance->MinimumOBLUnderSeaIce, ExpectedMinimumOBL, RTol, + ATol) || + !isApprox(KPPInstance->StopOBLSearchMult, 1.0_Real, RTol, ATol) || + !isApprox(KPPInstance->SurfaceLayerExtent, 0.1_Real, RTol, ATol) || + !KPPInstance->UseLangmuirCirculation || + !isApprox(KPPInstance->BackgroundVisc, 1.0e-4_Real, RTol, ATol) || + !isApprox(KPPInstance->BackgroundDiff, 1.0e-5_Real, RTol, ATol)) { + ++NumErrors; + } + checkResult("configured values and optional defaults", NumErrors); +} + +void testBoundaryLayerDepth() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + setCoefficientTestGeometry(); + VCoord->minMaxLayerEdge(Halo::getDefault()); + + Array2DReal Density("KPPMixTest-BLDDensity", Mesh->NCellsSize, NVertLayers); + Array2DReal NormalVelocity("KPPMixTest-BLDNormalVelocity", Mesh->NEdgesSize, + NVertLayers); + Array2DReal TangentialVelocity("KPPMixTest-BLDTangentialVelocity", + Mesh->NEdgesSize, NVertLayers); + Array1DReal UStar("KPPMixTest-BLDUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-BLDB0", Mesh->NCellsSize); + Array2DReal BVF("KPPMixTest-BLDBVF", Mesh->NCellsSize, NVertLayers + 1); + Array1DReal IceFraction("KPPMixTest-BLDIce", Mesh->NCellsSize); + Array1DReal Wind; + + deepCopy(NormalVelocity, 0.0_Real); + deepCopy(TangentialVelocity, 0.0_Real); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, 0.0_Real); + deepCopy(BVF, 1.0_Real); + deepCopy(IceFraction, 0.0_Real); + + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + constexpr Real TestN = 1.0_Real; + const Real UnresolvedShearConstant = + Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + (VonKar * VonKar); + const Real WindTurbulentScale = VonKar * 0.02_Real; + parallelFor( + "KPPMixTest-SetRichardsonDensity", {Mesh->NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + Real TargetRi = 0.0_Real; + if (K == 1) { + TargetRi = 0.1_Real; + } else if (K >= 2) { + TargetRi = 0.4_Real; + } + const Real ZCenter = LayerThickness * (K + 0.5_Real); + const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * + TestN * WindTurbulentScale / 0.25_Real; + const Real DeltaRho = + TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); + Density(ICell, K) = RhoSw + DeltaRho; + }); + + KPPInstance->CriticalRichardson = 0.25_Real; + KPPInstance->StopOBLSearchMult = 1.0_Real; + KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->UseLangmuirCirculation = false; + KPPInstance->UseBLDSmoothing = false; + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + + auto BLDH = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + auto BLDIndexH = createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + auto BulkRiH = createHostMirrorCopy(KPPInstance->BulkRichardsonNumber); + const auto BulkShearH = + createHostMirrorCopy(KPPInstance->BulkRichardsonShear); + const auto UnresolvedShearH = + createHostMirrorCopy(KPPInstance->UnresolvedShear); + const auto BuoyancyJumpH = createHostMirrorCopy(KPPInstance->BuoyancyJump); + + constexpr Real RiAbove = 0.1_Real; + constexpr Real RiBelow = 0.4_Real; + constexpr Real ZAbove = 15.0_Real; + constexpr Real Slope = 0.01_Real; + constexpr Real Quadratic = 0.002_Real; + const Real Discriminant = + Slope * Slope - 4.0_Real * Quadratic * (RiAbove - 0.25_Real); + const Real ExpectedBLD = + ZAbove + (-Slope + Kokkos::sqrt(Discriminant)) / (2.0_Real * Quadratic); + const Real ExpectedVt2 = 1.7_Real * UnresolvedShearConstant * 25.0_Real * + TestN * WindTurbulentScale / 0.25_Real; + const Real ExpectedDeltaB = 0.4_Real * ExpectedVt2 / (RiScaling * 25.0_Real); + + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(BulkRiH(ICell, 2), RiAbove, BLDRTol, ATol) || + !isApprox(BulkRiH(ICell, 3), RiBelow, BLDRTol, ATol) || + !isApprox(BLDH(ICell), ExpectedBLD, BLDRTol, ATol) || + BLDIndexH(ICell) != 2 || + !isApprox(BulkShearH(ICell, 3), 1.0e-15_Real, RTol, 1.0e-16_Real) || + !isApprox(UnresolvedShearH(ICell, 3), ExpectedVt2, RTol, ATol) || + !isApprox(BuoyancyJumpH(ICell, 3), ExpectedDeltaB, BLDRTol, ATol)) { + ++NumErrors; + } + } + checkResult("analytic boundary-layer depth", NumErrors); + + parallelFor( + "KPPMixTest-SetLinearRichardsonDensity", {Mesh->NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + const Real TargetRi = K == 0 ? 0.0_Real + : K == 1 ? 0.1_Real + : K == 2 ? 0.2_Real + : 0.3_Real; + const Real ZCenter = LayerThickness * (K + 0.5_Real); + const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * + TestN * WindTurbulentScale / 0.25_Real; + const Real DeltaRho = + TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); + Density(ICell, K) = RhoSw + DeltaRho; + }); + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + BLDH = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + BLDIndexH = createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + BulkRiH = createHostMirrorCopy(KPPInstance->BulkRichardsonNumber); + NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(BulkRiH(ICell, 3), 0.2_Real, BLDRTol, ATol) || + !isApprox(BulkRiH(ICell, 4), 0.3_Real, BLDRTol, ATol) || + !isApprox(BLDH(ICell), 30.0_Real, BLDRTol, ATol) || + BLDIndexH(ICell) != 2) { + ++NumErrors; + } + } + checkResult("boundary-layer linear interpolation", NumErrors); + + OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); + OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); + parallelFor( + "KPPMixTest-SetPartialBLDBounds", {Mesh->NCellsAll}, + KOKKOS_LAMBDA(I4 ICell) { + MinLayerCell(ICell) = 2; + MaxLayerCell(ICell) = 4; + }); + parallelFor( + "KPPMixTest-SetPartialBLDColumn", {Mesh->NCellsAll, NVertLayers + 1}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + if (K < NVertLayers) { + Density(ICell, K) = K >= 2 && K <= 4 ? RhoSw : -99.0_Real; + } + BVF(ICell, K) = K >= 3 && K <= 5 ? 1.0_Real : -99.0_Real; + }); + parallelFor( + "KPPMixTest-SetPartialBLDEdges", {Mesh->NEdgesAll, NVertLayers}, + KOKKOS_LAMBDA(I4 IEdge, I4 K) { + const Real Value = K >= 2 && K <= 4 ? 0.0_Real : 99.0_Real; + NormalVelocity(IEdge, K) = Value; + TangentialVelocity(IEdge, K) = Value; + }); + VCoord->minMaxLayerEdge(Halo::getDefault()); + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + BLDH = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + BLDIndexH = createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + BulkRiH = createHostMirrorCopy(KPPInstance->BulkRichardsonNumber); + NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(BLDH(ICell), 45.0_Real, RTol, ATol) || + BLDIndexH(ICell) != 4 || BulkRiH(ICell, 1) != 0.0_Real || + BulkRiH(ICell, 2) != 0.0_Real) { + ++NumErrors; + } + } + checkResult("boundary-layer partial wet column", NumErrors); + setCoefficientTestGeometry(); + VCoord->minMaxLayerEdge(Halo::getDefault()); + deepCopy(NormalVelocity, 0.0_Real); + deepCopy(TangentialVelocity, 0.0_Real); + deepCopy(BVF, 1.0_Real); + + parallelFor( + "KPPMixTest-RestoreRichardsonDensity", {Mesh->NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + Real TargetRi = 0.0_Real; + if (K == 1) { + TargetRi = 0.1_Real; + } else if (K >= 2) { + TargetRi = 0.4_Real; + } + const Real ZCenter = LayerThickness * (K + 0.5_Real); + const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * + TestN * WindTurbulentScale / 0.25_Real; + const Real DeltaRho = + TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); + Density(ICell, K) = RhoSw + DeltaRho; + }); + + parallelFor( + "KPPMixTest-SetResolvedShear", {Mesh->NEdgesAll, NVertLayers}, + KOKKOS_LAMBDA(I4 IEdge, I4 K) { + NormalVelocity(IEdge, K) = 0.1_Real * K; + TangentialVelocity(IEdge, K) = 0.2_Real * K; + }); + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + BLDH = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + const auto ShearedBulkRiH = + createHostMirrorCopy(KPPInstance->BulkRichardsonNumber); + const auto ResolvedShearH = + createHostMirrorCopy(KPPInstance->BulkRichardsonShear); + NumErrors = 0; + constexpr Real ExpectedResolvedShear = 0.2_Real; + const Real ExpectedShearedRi = RiScaling * ExpectedDeltaB * 25.0_Real / + (ExpectedResolvedShear + ExpectedVt2); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(ResolvedShearH(ICell, 3), ExpectedResolvedShear, RTol, + ATol) || + !isApprox(ShearedBulkRiH(ICell, 3), ExpectedShearedRi, BLDRTol, + ATol) || + !(BLDH(ICell) > ExpectedBLD)) { + ++NumErrors; + } + } + checkResult("boundary-layer resolved shear", NumErrors); + deepCopy(NormalVelocity, 0.0_Real); + deepCopy(TangentialVelocity, 0.0_Real); + + deepCopy(Density, RhoSw); + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + BLDH = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + BLDIndexH = createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + NumErrors = 0; + const Real DeepestMidpoint = + LayerThickness * (static_cast(NVertLayers) - 0.5_Real); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(BLDH(ICell), DeepestMidpoint, RTol, ATol) || + BLDIndexH(ICell) != NVertLayers - 1) { + ++NumErrors; + } + } + checkResult("boundary-layer no-crossing fallback", NumErrors); + + parallelFor( + "KPPMixTest-SetShallowCrossingDensity", {Mesh->NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + const Real ZCenter = LayerThickness * (K + 0.5_Real); + const Real TargetRi = K == 0 ? 0.0_Real : 1.0_Real; + const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * + TestN * WindTurbulentScale / 0.25_Real; + const Real DeltaRho = + TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); + Density(ICell, K) = RhoSw + DeltaRho; + }); + deepCopy(IceFraction, 0.5_Real); + KPPInstance->IceFractionThresholdForMinimumOBL = 0.15_Real; + KPPInstance->MinimumOBLUnderSeaIce = 25.0_Real; + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + BLDH = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + BLDIndexH = createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(BLDH(ICell), 25.0_Real, RTol, ATol) || + BLDIndexH(ICell) != 2) { + ++NumErrors; + } + } + checkResult("boundary-layer sea-ice minimum", NumErrors); +} + +void testBoundaryLayerNonuniformThickness() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + + Array2DReal Density("KPPMixTest-NonuniformDensity", Mesh->NCellsSize, + NVertLayers); + Array2DReal NormalVelocity("KPPMixTest-NonuniformNormalVelocity", + Mesh->NEdgesSize, NVertLayers); + Array2DReal TangentialVelocity("KPPMixTest-NonuniformTangentialVelocity", + Mesh->NEdgesSize, NVertLayers); + Array1DReal UStar("KPPMixTest-NonuniformUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-NonuniformB0", Mesh->NCellsSize); + Array2DReal BVF("KPPMixTest-NonuniformBVF", Mesh->NCellsSize, + NVertLayers + 1); + Array1DReal IceFraction("KPPMixTest-NonuniformIce", Mesh->NCellsSize); + Array1DReal Wind; + + OMEGA_SCOPE(GeomZInterface, VCoord->GeomZInterface); + OMEGA_SCOPE(GeomZMid, VCoord->GeomZMid); + OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); + OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); + + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + constexpr Real TestN = 1.0_Real; + constexpr Real TestUStar = 0.02_Real; + const Real UnresolvedShearConstant = + Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + (VonKar * VonKar); + const Real WindTurbulentScale = VonKar * TestUStar; + + deepCopy(Density, RhoSw); + deepCopy(UStar, TestUStar); + deepCopy(B0, 0.0_Real); + deepCopy(BVF, TestN * TestN); + deepCopy(IceFraction, 0.0_Real); + parallelFor( + "KPPMixTest-SetNonuniformVelocity", {Mesh->NEdgesAll, NVertLayers}, + KOKKOS_LAMBDA(I4 IEdge, I4 K) { + NormalVelocity(IEdge, K) = K == 0 ? 0.1_Real + : K == 1 ? 0.4_Real + : K == 2 ? 0.1_Real + : K == 3 ? 0.6_Real + : 0.6_Real; + TangentialVelocity(IEdge, K) = K == 0 ? 0.2_Real + : K == 1 ? 0.5_Real + : K == 2 ? 0.2_Real + : K == 3 ? 0.8_Real + : 0.8_Real; + }); + + parallelFor( + "KPPMixTest-SetNonuniformColumn", {Mesh->NCellsAll}, + KOKKOS_LAMBDA(I4 ICell) { + MinLayerCell(ICell) = 0; + MaxLayerCell(ICell) = NVertLayers - 1; + + for (I4 K = 0; K <= NVertLayers; ++K) { + Real Depth = 25.0_Real + 25.0_Real * (K - 4); + if (K == 0) { + Depth = 0.0_Real; + } else if (K == 1) { + Depth = 1.0_Real; + } else if (K == 2) { + Depth = 3.0_Real; + } else if (K == 3) { + Depth = 10.0_Real; + } else if (K == 4) { + Depth = 25.0_Real; + } + GeomZInterface(ICell, K) = -Depth; + if (K < NVertLayers) { + Real NextDepth = 25.0_Real + 25.0_Real * (K - 3); + if (K == 0) { + NextDepth = 1.0_Real; + } else if (K == 1) { + NextDepth = 3.0_Real; + } else if (K == 2) { + NextDepth = 10.0_Real; + } else if (K == 3) { + NextDepth = 25.0_Real; + } + GeomZMid(ICell, K) = -0.5_Real * (Depth + NextDepth); + } + } + + constexpr Real Shear1 = 0.18_Real; + constexpr Real Shear3 = 0.25_Real; + const Real Vt2Layer1 = 1.7_Real * UnresolvedShearConstant * 2.0_Real * + TestN * WindTurbulentScale / 0.25_Real; + const Real Vt2Layer2 = 1.7_Real * UnresolvedShearConstant * 6.5_Real * + TestN * WindTurbulentScale / 0.25_Real; + const Real Vt2Layer3 = 1.7_Real * UnresolvedShearConstant * + 17.5_Real * TestN * WindTurbulentScale / + 0.25_Real; + const Real DeltaRho1 = 0.05_Real * (Shear1 + Vt2Layer1) * RhoSw / + (RiScaling * Gravity * 2.0_Real); + const Real DeltaRho2 = + 0.10_Real * Vt2Layer2 * RhoSw / (RiScaling * Gravity * 6.5_Real); + const Real DeltaRho3 = 0.40_Real * (Shear3 + Vt2Layer3) * RhoSw / + (RiScaling * Gravity * 17.5_Real); + Density(ICell, 0) = RhoSw; + Density(ICell, 1) = RhoSw + DeltaRho1; + Density(ICell, 2) = RhoSw + DeltaRho2; + + // At k=3, the 2.5 m surface layer contains the unequal 1 m and + // 2 m layers. Construct rho(3) relative to that weighted mean. + const Real WeightedSurfaceDensity = + (Density(ICell, 0) + 2.0_Real * Density(ICell, 1)) / 3.0_Real; + Density(ICell, 3) = WeightedSurfaceDensity + DeltaRho3; + }); + VCoord->minMaxLayerEdge(Halo::getDefault()); + + KPPInstance->CriticalRichardson = 0.25_Real; + KPPInstance->StopOBLSearchMult = 1.0_Real; + KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->UseLangmuirCirculation = false; + KPPInstance->UseBLDSmoothing = false; + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + + const auto BLDH = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + const auto BLDIndexH = + createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + const auto BulkRiH = createHostMirrorCopy(KPPInstance->BulkRichardsonNumber); + const auto BulkShearH = + createHostMirrorCopy(KPPInstance->BulkRichardsonShear); + const auto BuoyancyJumpH = createHostMirrorCopy(KPPInstance->BuoyancyJump); + + constexpr Real ZPrevious = 1.5_Real; + constexpr Real ZAbove = 6.5_Real; + constexpr Real ZBelow = 17.5_Real; + constexpr Real RiPrevious = 0.05_Real; + constexpr Real RiAbove = 0.10_Real; + constexpr Real RiBelow = 0.40_Real; + const Real Slope = (RiAbove - RiPrevious) / (ZAbove - ZPrevious); + const Real H = ZBelow - ZAbove; + const Real Quadratic = (RiBelow - RiAbove - Slope * H) / (H * H); + const Real Discriminant = + Slope * Slope - 4.0_Real * Quadratic * (RiAbove - 0.25_Real); + const Real ExpectedBLD = + ZAbove + (-Slope + Kokkos::sqrt(Discriminant)) / (2.0_Real * Quadratic); + const Real ExpectedDeltaB = + 0.40_Real * + (0.25_Real + 1.7_Real * UnresolvedShearConstant * ZBelow * TestN * + WindTurbulentScale / 0.25_Real) / + (RiScaling * ZBelow); + + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(BulkRiH(ICell, 2), RiPrevious, BLDRTol, ATol) || + !isApprox(BulkRiH(ICell, 3), RiAbove, BLDRTol, ATol) || + !isApprox(BulkRiH(ICell, 4), RiBelow, BLDRTol, ATol) || + !isApprox(BulkShearH(ICell, 2), 0.18_Real, RTol, ATol) || + !isApprox(BulkShearH(ICell, 4), 0.25_Real, RTol, ATol) || + !isApprox(BuoyancyJumpH(ICell, 4), ExpectedDeltaB, BLDRTol, ATol) || + !isApprox(BLDH(ICell), ExpectedBLD, BLDRTol, ATol) || + BLDIndexH(ICell) != 3) { + ++NumErrors; + } + } + + setCoefficientTestGeometry(); + VCoord->minMaxLayerEdge(Halo::getDefault()); + checkResult("boundary-layer nonuniform thickness", NumErrors); +} + +void testBoundaryLayerEdgeFallbacks() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + setCoefficientTestGeometry(); + VCoord->minMaxLayerEdge(Halo::getDefault()); + + Array2DReal Density("KPPMixTest-EdgeFallbackDensity", Mesh->NCellsSize, + NVertLayers); + Array2DReal NormalVelocity("KPPMixTest-EdgeFallbackNormalVelocity", + Mesh->NEdgesSize, NVertLayers); + Array2DReal TangentialVelocity("KPPMixTest-EdgeFallbackTangentialVelocity", + Mesh->NEdgesSize, NVertLayers); + Array1DReal UStar("KPPMixTest-EdgeFallbackUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-EdgeFallbackB0", Mesh->NCellsSize); + Array2DReal BVF("KPPMixTest-EdgeFallbackBVF", Mesh->NCellsSize, + NVertLayers + 1); + Array1DReal IceFraction("KPPMixTest-EdgeFallbackIce", Mesh->NCellsSize); + Array1DReal Wind; + Array1DReal OriginalDcEdge("KPPMixTest-OriginalDcEdge", + Mesh->DcEdge.extent(0)); + deepCopy(OriginalDcEdge, Mesh->DcEdge); + + deepCopy(Density, RhoSw); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, 0.0_Real); + deepCopy(BVF, 1.0_Real); + deepCopy(IceFraction, 0.0_Real); + parallelFor( + "KPPMixTest-SetEdgeFallbackVelocity", {Mesh->NEdgesAll, NVertLayers}, + KOKKOS_LAMBDA(I4 IEdge, I4 K) { + NormalVelocity(IEdge, K) = 0.1_Real * K; + TangentialVelocity(IEdge, K) = 0.2_Real * K; + }); + + KPPInstance->CriticalRichardson = 0.25_Real; + KPPInstance->StopOBLSearchMult = 1.0_Real; + KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->UseLangmuirCirculation = false; + KPPInstance->UseBLDSmoothing = false; + + // Zero geometric weights force the equal weighting fallback over all + // vertically valid edges. + deepCopy(Mesh->DcEdge, 0.0_Real); + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + auto BulkShearH = createHostMirrorCopy(KPPInstance->BulkRichardsonShear); + int NumErrors = 0; + constexpr Real ExpectedEqualWeightShear = + 0.2_Real; // (0.2)^2 + (0.4)^2 at k=2 + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(BulkShearH(ICell, 3), ExpectedEqualWeightShear, RTol, + ATol)) { + ++NumErrors; + } + } + deepCopy(Mesh->DcEdge, OriginalDcEdge); + + OMEGA_SCOPE(MinLayerEdgeBot, VCoord->MinLayerEdgeBot); + OMEGA_SCOPE(MaxLayerEdgeTop, VCoord->MaxLayerEdgeTop); + deepCopy(MinLayerEdgeBot, -1); + deepCopy(MaxLayerEdgeTop, -1); + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + BulkShearH = createHostMirrorCopy(KPPInstance->BulkRichardsonShear); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(BulkShearH(ICell, 3), 1.0e-15_Real, RTol, 1.0e-16_Real)) { + ++NumErrors; + } + } + + VCoord->minMaxLayerEdge(Halo::getDefault()); + checkResult("boundary-layer edge fallbacks", NumErrors); +} + +void testBoundaryLayerLangmuir() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + setCoefficientTestGeometry(); + VCoord->minMaxLayerEdge(Halo::getDefault()); + + Array2DReal Density("KPPMixTest-LangmuirDensity", Mesh->NCellsSize, + NVertLayers); + Array2DReal NormalVelocity("KPPMixTest-LangmuirNormalVelocity", + Mesh->NEdgesSize, NVertLayers); + Array2DReal TangentialVelocity("KPPMixTest-LangmuirTangentialVelocity", + Mesh->NEdgesSize, NVertLayers); + Array1DReal UStar("KPPMixTest-LangmuirUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-LangmuirB0", Mesh->NCellsSize); + Array2DReal BVF("KPPMixTest-LangmuirBVF", Mesh->NCellsSize, NVertLayers + 1); + Array1DReal IceFraction("KPPMixTest-LangmuirIce", Mesh->NCellsSize); + Array1DReal Wind("KPPMixTest-LangmuirWind", Mesh->NCellsSize); + + constexpr Real TestUStar = 0.02_Real; + constexpr Real TestB0 = -1.0e-7_Real; + constexpr Real TestN = 1.0_Real; + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + const Real UnresolvedShearConstant = + Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + (VonKar * VonKar); + + deepCopy(NormalVelocity, 0.0_Real); + deepCopy(TangentialVelocity, 0.0_Real); + deepCopy(UStar, TestUStar); + deepCopy(B0, TestB0); + deepCopy(BVF, TestN * TestN); + deepCopy(IceFraction, 0.0_Real); + deepCopy(Wind, 10.0_Real); + parallelFor( + "KPPMixTest-SetLangmuirDensity", {Mesh->NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + const Real ZDepth = LayerThickness * (K + 1.0_Real); + const Real ZCenter = LayerThickness * (K + 0.5_Real); + const Real Zeta = KPP::SURFACE_LAYER_EXTENT * ZDepth * VonKar * + TestB0 / (TestUStar * TestUStar * TestUStar); + const Real PhiInv = Kokkos::sqrt(1.0_Real - 16.0_Real * Zeta); + const Real WTurb = VonKar * TestUStar * PhiInv; + const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * + TestN * WTurb / 0.25_Real; + const Real TargetRi = + K == 0 ? 0.0_Real : (K == 1 ? 0.1_Real : 0.26_Real); + const Real DeltaRho = + TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); + Density(ICell, K) = RhoSw + DeltaRho; + }); + + KPPInstance->CriticalRichardson = 0.25_Real; + KPPInstance->StopOBLSearchMult = 1.0_Real; + KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->UseBLDSmoothing = false; + KPPInstance->UseLangmuirCirculation = false; + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + Array1DReal DisabledBLD("KPPMixTest-LangmuirDisabledBLD", Mesh->NCellsAll); + Array2DReal DisabledRi("KPPMixTest-LangmuirDisabledRi", Mesh->NCellsAll, + NVertLayers + 1); + Array2DReal DisabledVt2("KPPMixTest-LangmuirDisabledVt2", Mesh->NCellsAll, + NVertLayers + 1); + deepCopy(DisabledBLD, KPPInstance->BoundaryLayerDepth); + deepCopy(DisabledRi, KPPInstance->BulkRichardsonNumber); + deepCopy(DisabledVt2, KPPInstance->UnresolvedShear); + const auto DisabledBLDH = createHostMirrorCopy(DisabledBLD); + const auto DisabledRiH = createHostMirrorCopy(DisabledRi); + const auto DisabledVt2H = createHostMirrorCopy(DisabledVt2); + + KPPInstance->UseLangmuirCirculation = true; + KPPInstance->IceFractionThresholdForLangmuir = 0.05_Real; + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + Array1DReal EnabledBLD("KPPMixTest-LangmuirEnabledBLD", Mesh->NCellsAll); + Array2DReal EnabledRi("KPPMixTest-LangmuirEnabledRi", Mesh->NCellsAll, + NVertLayers + 1); + Array2DReal EnabledVt2("KPPMixTest-LangmuirEnabledVt2", Mesh->NCellsAll, + NVertLayers + 1); + deepCopy(EnabledBLD, KPPInstance->BoundaryLayerDepth); + deepCopy(EnabledRi, KPPInstance->BulkRichardsonNumber); + deepCopy(EnabledVt2, KPPInstance->UnresolvedShear); + const auto EnabledBLDH = createHostMirrorCopy(EnabledBLD); + const auto EnabledRiH = createHostMirrorCopy(EnabledRi); + const auto EnabledVt2H = createHostMirrorCopy(EnabledVt2); + + deepCopy(IceFraction, 0.1_Real); + KPPInstance->IceFractionThresholdForMinimumOBL = 2.0_Real; + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + const auto SuppressedBLDH = + createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + const auto SuppressedRiH = + createHostMirrorCopy(KPPInstance->BulkRichardsonNumber); + + constexpr Real ZDepth = 30.0_Real; + constexpr Real ZCenter = 25.0_Real; + const Real Enhancement = Kokkos::sqrt(3.0_Real); + const Real DisabledZeta = KPP::SURFACE_LAYER_EXTENT * ZDepth * VonKar * + TestB0 / (TestUStar * TestUStar * TestUStar); + const Real EnabledZeta = DisabledZeta * Enhancement; + const Real DisabledWTurb = + VonKar * TestUStar * Kokkos::sqrt(1.0_Real - 16.0_Real * DisabledZeta); + const Real EnabledWTurb = + VonKar * TestUStar * Kokkos::sqrt(1.0_Real - 16.0_Real * EnabledZeta); + const Real ExpectedDisabledVt2 = 1.7_Real * UnresolvedShearConstant * + ZCenter * TestN * DisabledWTurb / 0.25_Real; + const Real ExpectedEnabledVt2 = 1.7_Real * UnresolvedShearConstant * + ZCenter * TestN * EnabledWTurb / 0.25_Real; + const Real ExpectedEnabledRi = + 0.26_Real * ExpectedDisabledVt2 / ExpectedEnabledVt2; + + int RiErrors = 0; + int Vt2Errors = 0; + int DepthErrors = 0; + int SuppressionErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(DisabledRiH(ICell, 3), 0.26_Real, BLDRTol, ATol) || + !isApprox(EnabledRiH(ICell, 3), ExpectedEnabledRi, BLDRTol, ATol)) { + ++RiErrors; + } + if (!isApprox(DisabledVt2H(ICell, 3), ExpectedDisabledVt2, RTol, ATol) || + !isApprox(EnabledVt2H(ICell, 3), ExpectedEnabledVt2, RTol, ATol)) { + ++Vt2Errors; + } + if (!(EnabledBLDH(ICell) > DisabledBLDH(ICell))) { + ++DepthErrors; + } + if (!isApprox(SuppressedRiH(ICell, 3), DisabledRiH(ICell, 3), RTol, + ATol) || + !isApprox(SuppressedBLDH(ICell), DisabledBLDH(ICell), RTol, ATol)) { + ++SuppressionErrors; + } + } + if (RiErrors != 0 || Vt2Errors != 0 || DepthErrors != 0 || + SuppressionErrors != 0) { + LOG_ERROR( + "Langmuir BLD failures: Ri={} Vt2={} depth={} suppression={}; " + "cell 0 disabled Ri={} Vt2={} BLD={}, enabled Ri={} Vt2={} " + "BLD={}, suppressed Ri={} BLD={}; expected enabled Ri={} Vt2={}", + RiErrors, Vt2Errors, DepthErrors, SuppressionErrors, + DisabledRiH(0, 3), DisabledVt2H(0, 3), DisabledBLDH(0), + EnabledRiH(0, 3), EnabledVt2H(0, 3), EnabledBLDH(0), + SuppressedRiH(0, 3), SuppressedBLDH(0), ExpectedEnabledRi, + ExpectedEnabledVt2); + } + KPPInstance->UseLangmuirCirculation = false; + checkResult("boundary-layer Langmuir enhancement and ice suppression", + RiErrors + Vt2Errors + DepthErrors + SuppressionErrors); +} + +void testBoundaryLayerSmoothing() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + setCoefficientTestGeometry(); + VCoord->minMaxLayerEdge(Halo::getDefault()); + + Array2DReal Density("KPPMixTest-SmoothingDensity", Mesh->NCellsSize, + NVertLayers); + Array2DReal NormalVelocity("KPPMixTest-SmoothingNormalVelocity", + Mesh->NEdgesSize, NVertLayers); + Array2DReal TangentialVelocity("KPPMixTest-SmoothingTangentialVelocity", + Mesh->NEdgesSize, NVertLayers); + Array1DReal UStar("KPPMixTest-SmoothingUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-SmoothingB0", Mesh->NCellsSize); + Array2DReal BVF("KPPMixTest-SmoothingBVF", Mesh->NCellsSize, + NVertLayers + 1); + Array1DReal IceFraction("KPPMixTest-SmoothingIce", Mesh->NCellsSize); + Array1DReal Wind; + + constexpr Real TestUStar = 0.02_Real; + constexpr Real TestN = 1.0_Real; + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + const Real UnresolvedShearConstant = + Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + (VonKar * VonKar); + const Real WTurb = VonKar * TestUStar; + + deepCopy(NormalVelocity, 0.0_Real); + deepCopy(TangentialVelocity, 0.0_Real); + deepCopy(UStar, TestUStar); + deepCopy(B0, 0.0_Real); + deepCopy(BVF, TestN * TestN); + deepCopy(IceFraction, 0.0_Real); + parallelFor( + "KPPMixTest-SetSmoothingDensity", {Mesh->NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + Real TargetRi = 0.0_Real; + if (ICell % 2 == 0) { + TargetRi = K == 1 ? 0.1_Real : (K >= 2 ? 0.4_Real : 0.0_Real); + } else { + TargetRi = + K == 1 ? 0.05_Real + : (K == 2 ? 0.1_Real : (K >= 3 ? 0.4_Real : 0.0_Real)); + } + const Real ZCenter = LayerThickness * (K + 0.5_Real); + const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * + TestN * WTurb / 0.25_Real; + const Real DeltaRho = + TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); + Density(ICell, K) = RhoSw + DeltaRho; + }); + + KPPInstance->CriticalRichardson = 0.25_Real; + KPPInstance->StopOBLSearchMult = 1.0_Real; + KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->UseLangmuirCirculation = false; + KPPInstance->UseBLDSmoothing = false; + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + Array1DReal UnsmoothedBLD("KPPMixTest-UnsmoothedBLD", Mesh->NCellsAll); + deepCopy(UnsmoothedBLD, KPPInstance->BoundaryLayerDepth); + const auto UnsmoothedBLDH = createHostMirrorCopy(UnsmoothedBLD); + + KPPInstance->UseBLDSmoothing = true; + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + const auto SmoothedBLDH = + createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + const auto SmoothedIndexH = + createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + + int NumErrors = 0; + int NumChanged = 0; + const Real MinDepth = 0.5_Real * LayerThickness; + const Real MaxDepth = + LayerThickness * (static_cast(NVertLayers) - 0.5_Real); + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + Real WeightedDepth = 0.0_Real; + Real AreaSum = 0.0_Real; + I4 ValidNeighbors = 0; + for (I4 J = 0; J < Mesh->NEdgesOnCellH(ICell); ++J) { + const I4 INeighbor = Mesh->CellsOnCellH(ICell, J); + if (INeighbor == Mesh->NCellsAll) { + continue; + } + WeightedDepth += + 2.0_Real * Mesh->AreaCellH(INeighbor) * UnsmoothedBLDH(INeighbor); + AreaSum += 2.0_Real * Mesh->AreaCellH(INeighbor); + ++ValidNeighbors; + } + if (ValidNeighbors > 0) { + WeightedDepth += + UnsmoothedBLDH(ICell) * ValidNeighbors * Mesh->AreaCellH(ICell); + AreaSum += ValidNeighbors * Mesh->AreaCellH(ICell); + } + Real ExpectedDepth = + AreaSum > 0.0_Real ? WeightedDepth / AreaSum : UnsmoothedBLDH(ICell); + ExpectedDepth = Kokkos::fmax(MinDepth, ExpectedDepth); + ExpectedDepth = Kokkos::fmin(MaxDepth, ExpectedDepth); + + I4 ExpectedIndex = NVertLayers - 1; + for (I4 K = 0; K < NVertLayers - 1; ++K) { + const Real ZAbove = LayerThickness * K; + const Real ZBelow = LayerThickness * (K + 1); + if (ExpectedDepth >= ZAbove && ExpectedDepth <= ZBelow) { + ExpectedIndex = K; + break; + } + } + if (!isApprox(SmoothedBLDH(ICell), ExpectedDepth, BLDRTol, ATol) || + SmoothedIndexH(ICell) != ExpectedIndex) { + ++NumErrors; + } + if (!isApprox(SmoothedBLDH(ICell), UnsmoothedBLDH(ICell), BLDRTol, + ATol)) { + ++NumChanged; + } + } + if (NumChanged == 0) { + ++NumErrors; + } + KPPInstance->UseBLDSmoothing = false; + checkResult("boundary-layer horizontal smoothing", NumErrors); +} + +void testEnabledFullCall() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + setCoefficientTestGeometry(); + VCoord->minMaxLayerEdge(Halo::getDefault()); + + Array2DReal Density("KPPMixTest-FullCallDensity", Mesh->NCellsAll, + NVertLayers); + Array2DReal NormalVelocity("KPPMixTest-FullCallNormalVelocity", + Mesh->NEdgesSize, NVertLayers); + Array2DReal TangentialVelocity("KPPMixTest-FullCallTangentialVelocity", + Mesh->NEdgesSize, NVertLayers); + Array1DReal UStar("KPPMixTest-FullCallUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-FullCallB0", Mesh->NCellsSize); + Array2DReal BVF("KPPMixTest-FullCallBVF", Mesh->NCellsSize, NVertLayers + 1); + Array1DReal IceFraction("KPPMixTest-FullCallIce", Mesh->NCellsSize); + Array1DReal Wind; + + parallelFor( + "KPPMixTest-SetFullCallDensity", {Mesh->NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + Density(ICell, K) = RhoSw + 0.01_Real * K; + }); + deepCopy(NormalVelocity, 0.0_Real); + deepCopy(TangentialVelocity, 0.0_Real); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, -1.0e-7_Real); + deepCopy(BVF, 1.0e-4_Real); + deepCopy(IceFraction, 0.0_Real); + + KPPInstance->Enabled = true; + KPPInstance->UseLangmuirCirculation = false; + KPPInstance->UseBLDSmoothing = false; + KPPInstance->UseEnhancedDiffusion = false; + KPPInstance->UseNonLocalFlux = true; + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + + Array1DReal ExpectedBLD("KPPMixTest-ExpectedBLD", Mesh->NCellsAll); + Array1DI4 ExpectedBLDIndex("KPPMixTest-ExpectedBLDIndex", Mesh->NCellsAll); + Array2DReal ExpectedBulkRi("KPPMixTest-ExpectedBulkRi", Mesh->NCellsAll, + NVertLayers + 1); + Array2DReal ExpectedVertDiff("KPPMixTest-ExpectedVertDiff", Mesh->NCellsAll, + NVertLayers + 1); + Array2DReal ExpectedVertVisc("KPPMixTest-ExpectedVertVisc", Mesh->NCellsAll, + NVertLayers + 1); + Array2DReal ExpectedNonLocal("KPPMixTest-ExpectedNonLocal", Mesh->NCellsAll, + NVertLayers + 1); + Array2DReal ExpectedTurbVel("KPPMixTest-ExpectedTurbVel", Mesh->NCellsAll, + NVertLayers + 1); + deepCopy(ExpectedBLD, KPPInstance->BoundaryLayerDepth); + deepCopy(ExpectedBLDIndex, KPPInstance->IndexBoundaryLayerDepth); + deepCopy(ExpectedBulkRi, KPPInstance->BulkRichardsonNumber); + deepCopy(ExpectedVertDiff, KPPInstance->VertDiff); + deepCopy(ExpectedVertVisc, KPPInstance->VertVisc); + deepCopy(ExpectedNonLocal, KPPInstance->VertNonLocalFlux); + deepCopy(ExpectedTurbVel, KPPInstance->TurbulentVelocityScale); + + deepCopy(KPPInstance->BoundaryLayerDepth, -1.0_Real); + deepCopy(KPPInstance->IndexBoundaryLayerDepth, -1); + deepCopy(KPPInstance->BulkRichardsonNumber, -1.0_Real); + deepCopy(KPPInstance->VertDiff, -1.0_Real); + deepCopy(KPPInstance->VertVisc, -1.0_Real); + deepCopy(KPPInstance->VertNonLocalFlux, -1.0_Real); + deepCopy(KPPInstance->TurbulentVelocityScale, -1.0_Real); + deepCopy(KPPInstance->PotentialDensity, -1.0_Real); + + KPPInstance->computeKPPMix(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + + const auto ExpectedBLDH = createHostMirrorCopy(ExpectedBLD); + const auto ExpectedBLDIndexH = createHostMirrorCopy(ExpectedBLDIndex); + const auto ExpectedBulkRiH = createHostMirrorCopy(ExpectedBulkRi); + const auto ExpectedVertDiffH = createHostMirrorCopy(ExpectedVertDiff); + const auto ExpectedVertViscH = createHostMirrorCopy(ExpectedVertVisc); + const auto ExpectedNonLocalH = createHostMirrorCopy(ExpectedNonLocal); + const auto ExpectedTurbVelH = createHostMirrorCopy(ExpectedTurbVel); + const auto ActualBLDH = + createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + const auto ActualBLDIndexH = + createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + const auto ActualBulkRiH = + createHostMirrorCopy(KPPInstance->BulkRichardsonNumber); + const auto ActualVertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + const auto ActualVertViscH = createHostMirrorCopy(KPPInstance->VertVisc); + const auto ActualNonLocalH = + createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + const auto ActualTurbVelH = + createHostMirrorCopy(KPPInstance->TurbulentVelocityScale); + const auto RetainedDensityH = + createHostMirrorCopy(KPPInstance->PotentialDensity); + const auto InputDensityH = createHostMirrorCopy(Density); + + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (!isApprox(ActualBLDH(ICell), ExpectedBLDH(ICell), RTol, ATol) || + ActualBLDIndexH(ICell) != ExpectedBLDIndexH(ICell)) { + ++NumErrors; + } + for (I4 K = 0; K <= NVertLayers; ++K) { + if (!isApprox(ActualBulkRiH(ICell, K), ExpectedBulkRiH(ICell, K), RTol, + ATol) || + !isApprox(ActualVertDiffH(ICell, K), ExpectedVertDiffH(ICell, K), + RTol, ATol) || + !isApprox(ActualVertViscH(ICell, K), ExpectedVertViscH(ICell, K), + RTol, ATol) || + !isApprox(ActualNonLocalH(ICell, K), ExpectedNonLocalH(ICell, K), + RTol, ATol) || + !isApprox(ActualTurbVelH(ICell, K), ExpectedTurbVelH(ICell, K), + RTol, ATol)) { + ++NumErrors; + } + if (K < NVertLayers && + !isApprox(RetainedDensityH(ICell, K), InputDensityH(ICell, K), + RTol, ATol)) { + ++NumErrors; + } + } + } + checkResult("enabled full call", NumErrors); +} + +void testDisabledFullCall() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + + Array2DReal Density("KPPMixTest-DisabledDensity", Mesh->NCellsSize, + NVertLayers); + Array2DReal NormalVelocity("KPPMixTest-DisabledNormalVelocity", + Mesh->NEdgesSize, NVertLayers); + Array2DReal TangentialVelocity("KPPMixTest-DisabledTangentialVelocity", + Mesh->NEdgesSize, NVertLayers); + Array1DReal UStar("KPPMixTest-DisabledUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-DisabledB0", Mesh->NCellsSize); + Array2DReal BVF("KPPMixTest-DisabledBVF", Mesh->NCellsSize, NVertLayers + 1); + Array1DReal IceFraction("KPPMixTest-DisabledIce", Mesh->NCellsSize); + deepCopy(Density, RhoSw); + deepCopy(NormalVelocity, 0.0_Real); + deepCopy(TangentialVelocity, 0.0_Real); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, -1.0e-7_Real); + deepCopy(BVF, 0.0_Real); + deepCopy(IceFraction, 0.0_Real); + deepCopy(KPPInstance->VertDiff, -7.0_Real); + deepCopy(KPPInstance->BoundaryLayerDepth, -9.0_Real); + + KPPInstance->Enabled = false; + KPPInstance->computeKPPMix(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction); + const auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); + const auto BLDH = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + if (VertDiffH(ICell, 2) != -7.0_Real || BLDH(ICell) != -9.0_Real) { + ++NumErrors; + } + } + KPPInstance->Enabled = true; + checkResult("disabled full call", NumErrors); +} + +} // namespace + +int main(int argc, char *argv[]) { + const std::string TestGroup = argc > 1 ? argv[1] : "all"; + + MPI_Init(&argc, &argv); + Kokkos::initialize(argc, argv); + Pacer::initialize(MPI_COMM_WORLD); + Pacer::setPrefix("Omega:"); + + initKPPMixTest(TestGroup); + + if (TestGroup == "profiles" || TestGroup == "all") { + testStabilityFunctions(); + testShapeFunctions(); + testLangmuirFunctions(); + testOBLUtilities(); + testTurbulentVelocityScale(); + } + if (TestGroup == "bld" || TestGroup == "all") { + testBoundaryLayerDepth(); + testBoundaryLayerNonuniformThickness(); + testBoundaryLayerEdgeFallbacks(); + testBoundaryLayerLangmuir(); + testBoundaryLayerSmoothing(); + } + if (TestGroup == "vmix" || TestGroup == "all") { + testWindOnlyCoefficients(); + testConvectionOnlyCoefficients(); + testNonLocalProfileModes(); + testMatchBothInteriorCoefficients(); + testEnhancedDiffusion(); + testStableAndZeroForcing(); + testCoefficientVerticalDomainEdges(); + testCoefficientInvalidWetBounds(); + } + if (TestGroup == "integration" || TestGroup == "all") { + testConfiguredValues(); + testEnabledFullCall(); + testDisabledFullCall(); + } + if (TestGroup == "config-gradient" || TestGroup == "config-unsupported") { + testConfigurationNormalization(); + } + if (TestGroup != "profiles" && TestGroup != "bld" && TestGroup != "vmix" && + TestGroup != "integration" && TestGroup != "config-gradient" && + TestGroup != "config-unsupported" && TestGroup != "all") { + ABORT_ERROR("KPPMixTest: unknown test group '{}'", TestGroup); + } + + LOG_INFO("------ KPP {} Tests Successful ------", TestGroup); + finalizeKPPMixTest(); + Kokkos::finalize(); + MPI_Finalize(); + return 0; +} + +//===----------------------------------------------------------------------===// From 0cd2f5a16cdd71139b44bbf4e17d589b97043ce5 Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Wed, 12 Aug 2026 00:58:00 -0400 Subject: [PATCH 24/36] fixes to unit tests --- components/omega/src/ocn/Eos.h | 6 ++++ components/omega/src/ocn/KPPMix.cpp | 23 ++++++------ components/omega/src/ocn/Tendencies.cpp | 6 ++-- components/omega/test/CMakeLists.txt | 37 +++++++++++++++++-- components/omega/test/ocn/KPPMixTest.cpp | 46 ++++++++++++------------ 5 files changed, 79 insertions(+), 39 deletions(-) diff --git a/components/omega/src/ocn/Eos.h b/components/omega/src/ocn/Eos.h index 2494d6a2d5d8..b1b04f09b05e 100644 --- a/components/omega/src/ocn/Eos.h +++ b/components/omega/src/ocn/Eos.h @@ -777,6 +777,12 @@ class Eos { return Pt; } + /// Get linear EOS density derivative with respect to temperature. + Real getLinearDRhodT() const { return ComputeSpecVolLinear.DRhodT; } + + /// Get linear EOS density derivative with respect to salinity. + Real getLinearDRhodS() const { return ComputeSpecVolLinear.DRhodS; } + /// Calculate freezing temperature of seawater. /// For TEOS-10, uses the Roquet et al. 75-term polynomial. /// For LinearEos, uses a simple linear salinity-dependent approximation. diff --git a/components/omega/src/ocn/KPPMix.cpp b/components/omega/src/ocn/KPPMix.cpp index c39ad7253ba1..fa4027d44c47 100644 --- a/components/omega/src/ocn/KPPMix.cpp +++ b/components/omega/src/ocn/KPPMix.cpp @@ -679,9 +679,9 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, Real w_turb = 0.0_Real; if (u_star > 1.0e-12_Real) { - const Real u3 = u_star * u_star * u_star; - const Real zeta = sigma_loc * z_depth * VonKar * b0_eff / - Kokkos::max(u3, 1.0e-20_Real); + const Real u3 = u_star * u_star * u_star; + const Real zeta = sigma_loc * z_depth * VonKar * b0_eff / + Kokkos::max(u3, 1.0e-20_Real); const Real phi_inv_s = KPP::KPPProfileS2(zeta); w_turb = VonKar * u_star * Kokkos::max(phi_inv_s, 0.0_Real); } else if (b0_eff < 0.0_Real) { @@ -993,6 +993,9 @@ void KPPMix::computeMixingCoefficients( const I4 KMin = MinLayerCell(ICell); const I4 KMax = MaxLayerCell(ICell); + if (KMin < 0 || KMin >= NVertLayers || KMax < KMin) { + return; + } const I4 KMatch = Kokkos::min(KMax + 1, LocIndexBoundaryLayerDepth(ICell) + 1); @@ -1028,7 +1031,7 @@ void KPPMix::computeMixingCoefficients( if (u_star > 0.0_Real) { const Real u3 = u_star * u_star * u_star; zeta = sigma_loc * h_obl * b0 * LocKappa / - Kokkos::max(u3, 1.0e-20_Real); + Kokkos::max(u3, 1.0e-20_Real); // KPPProfileM2/S2 return phi^{-1}; do not invert again. const Real phi_inv_m = KPP::KPPProfileM2(zeta); @@ -1104,12 +1107,12 @@ void KPPMix::computeMixingCoefficients( } else { // Below OBL: preserve interior values for MatchBoth, otherwise // no KPP contribution. - LocVertDiff(ICell, k) = LocUseInteriorMix - ? LocInteriorVertDiff(ICell, k) - : 0.0_Real; - LocVertVisc(ICell, k) = LocUseInteriorMix - ? LocInteriorVertVisc(ICell, k) - : 0.0_Real; + LocVertDiff(ICell, k) = LocUseInteriorMix + ? LocInteriorVertDiff(ICell, k) + : 0.0_Real; + LocVertVisc(ICell, k) = LocUseInteriorMix + ? LocInteriorVertVisc(ICell, k) + : 0.0_Real; LocVertNonLocalFlux(ICell, k) = 0.0; LocTurbulentVelocityScale(ICell, k) = 0.0; } diff --git a/components/omega/src/ocn/Tendencies.cpp b/components/omega/src/ocn/Tendencies.cpp index b927aa4c54d9..47543035e34e 100644 --- a/components/omega/src/ocn/Tendencies.cpp +++ b/components/omega/src/ocn/Tendencies.cpp @@ -1399,7 +1399,6 @@ void Tendencies::computeStageVerticalMixing(const OceanState *State, OMEGA_SCOPE(LocSurfaceTracerFlux, SurfaceTracerFlux); OMEGA_SCOPE(LocSpecVol, EqState->SpecVol); Teos10BruntVaisalaFreqSq Teos10Coeff(VCoord); - Teos10Eos Teos10EosImpl(VCoord); const bool LocUpdateSurfaceTracerFlux = TracerNonLocalFluxEnabled; const bool LocUseTracerForcing = SfcTracerForcing.Enabled; @@ -1432,8 +1431,9 @@ void Tendencies::computeStageVerticalMixing(const OceanState *State, const I4 KSurf = MinLayerCell(ICell); const Real surface_salinity = AbsSalinity(ICell, KSurf); const Real surface_temp = ConservTemp(ICell, KSurf); - const Real ct_freezing = Teos10EosImpl.calcCtFreezing( - surface_salinity, PressureMid(ICell, KSurf) * Pa2Db, 0.0_Real); + const Real ct_freezing = + Eos::calcCtFreezing(LocEosChoice, surface_salinity, + PressureMid(ICell, KSurf) * Pa2Db, 0.0_Real); const Real heat_flux = LocLatentHeatFlux(ICell) + LocSensibleHeatFlux(ICell) + LocLongWaveHeatFluxUp(ICell) + LocLongWaveHeatFluxDown(ICell) + diff --git a/components/omega/test/CMakeLists.txt b/components/omega/test/CMakeLists.txt index 5c1dfa98dd41..14afabb71607 100644 --- a/components/omega/test/CMakeLists.txt +++ b/components/omega/test/CMakeLists.txt @@ -13,6 +13,12 @@ function(add_omega_test test_name exe_name source_files mpi_args) # Copy extra arguments to a local variable so they can be treated # as a list set(extra_args ${ARGN}) + set(test_arg "") + foreach(extra_arg ${extra_args}) + if(extra_arg MATCHES "^test_arg=(.*)") + set(test_arg "${CMAKE_MATCH_1}") + endif() + endforeach() # Create the executable add_executable(${exe_name} ${source_files}) @@ -58,19 +64,19 @@ function(add_omega_test test_name exe_name source_files mpi_args) if("${OMEGA_ARCH}" STREQUAL "SYCL") add_test( NAME ${test_name} - COMMAND ${OMEGA_MPI_EXEC} ${mpi_args} ${OMEGA_MPI_ARGS} ./${exe_name} + COMMAND ${OMEGA_MPI_EXEC} ${mpi_args} ${OMEGA_MPI_ARGS} ./${exe_name} ${test_arg} ) else() add_test( NAME ${test_name} - COMMAND ${OMEGA_MPI_EXEC} ${OMEGA_MPI_ARGS} ${mpi_args} -- ./${exe_name} + COMMAND ${OMEGA_MPI_EXEC} ${OMEGA_MPI_ARGS} ${mpi_args} -- ./${exe_name} ${test_arg} ) endif() else() add_test( NAME ${test_name} - COMMAND ../omega_env.sh ./${exe_name} + COMMAND ../omega_env.sh ./${exe_name} ${test_arg} ) endif() @@ -88,6 +94,31 @@ function(add_omega_test test_name exe_name source_files mpi_args) endfunction() +function(add_omega_ctest test_name exe_name mpi_args test_arg) + if (mpi_args) + + if("${OMEGA_ARCH}" STREQUAL "SYCL") + add_test( + NAME ${test_name} + COMMAND ${OMEGA_MPI_EXEC} ${mpi_args} ${OMEGA_MPI_ARGS} ./${exe_name} ${test_arg} + ) + else() + add_test( + NAME ${test_name} + COMMAND ${OMEGA_MPI_EXEC} ${OMEGA_MPI_ARGS} ${mpi_args} -- ./${exe_name} ${test_arg} + ) + endif() + + else() + add_test( + NAME ${test_name} + COMMAND ../omega_env.sh ./${exe_name} ${test_arg} + ) + endif() + + set_tests_properties(${test_name} PROPERTIES LABELS "${OMEGA_ARCH};Omega-0") +endfunction() + ################## # Data type test ################## diff --git a/components/omega/test/ocn/KPPMixTest.cpp b/components/omega/test/ocn/KPPMixTest.cpp index d7a6cd842184..ec5baf74d14c 100644 --- a/components/omega/test/ocn/KPPMixTest.cpp +++ b/components/omega/test/ocn/KPPMixTest.cpp @@ -594,9 +594,9 @@ void testMatchBothInteriorCoefficients() { constexpr Real SmoothAtSigma = 0.5_Real; const Real TurbVel = VonKar * 0.02_Real; const Real ExpectedDiffMid = TestOBLDepth * TurbVel * SimpleShape + - SmoothAtSigma * ExpectedInteriorDiff; - const Real ExpectedViscMid = TestOBLDepth * TurbVel * SimpleShape + - SmoothAtSigma * ExpectedInteriorVisc; + SmoothAtSigma * ExpectedInteriorDiff; + const Real ExpectedViscMid = TestOBLDepth * TurbVel * SimpleShape + + SmoothAtSigma * ExpectedInteriorVisc; const Real ExpectedNonLocal = nonLocalNormalization() * KPP::KPPProfileGMatchBoth(Sigma); @@ -1030,7 +1030,7 @@ void testBoundaryLayerDepth() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1062,8 +1062,8 @@ void testBoundaryLayerDepth() { Slope * Slope - 4.0_Real * Quadratic * (RiAbove - 0.25_Real); const Real ExpectedBLD = ZAbove + (-Slope + Kokkos::sqrt(Discriminant)) / (2.0_Real * Quadratic); - const Real ExpectedVt2 = 1.7_Real * UnresolvedShearConstant * 25.0_Real * - TestN * WindTurbulentScale / 0.25_Real; + const Real ExpectedVt2 = 1.7_Real * UnresolvedShearConstant * 25.0_Real * + TestN * WindTurbulentScale / 0.25_Real; const Real ExpectedDeltaB = 0.4_Real * ExpectedVt2 / (RiScaling * 25.0_Real); int NumErrors = 0; @@ -1089,7 +1089,7 @@ void testBoundaryLayerDepth() { : 0.3_Real; const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1165,7 +1165,7 @@ void testBoundaryLayerDepth() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1223,7 +1223,7 @@ void testBoundaryLayerDepth() { const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real TargetRi = K == 0 ? 0.0_Real : 1.0_Real; const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1347,9 +1347,9 @@ void testBoundaryLayerNonuniformThickness() { 0.10_Real * Vt2Layer2 * RhoSw / (RiScaling * Gravity * 6.5_Real); const Real DeltaRho3 = 0.40_Real * (Shear3 + Vt2Layer3) * RhoSw / (RiScaling * Gravity * 17.5_Real); - Density(ICell, 0) = RhoSw; - Density(ICell, 1) = RhoSw + DeltaRho1; - Density(ICell, 2) = RhoSw + DeltaRho2; + Density(ICell, 0) = RhoSw; + Density(ICell, 1) = RhoSw + DeltaRho1; + Density(ICell, 2) = RhoSw + DeltaRho2; // At k=3, the 2.5 m surface layer contains the unequal 1 m and // 2 m layers. Construct rho(3) relative to that weighted mean. @@ -1375,7 +1375,7 @@ void testBoundaryLayerNonuniformThickness() { createHostMirrorCopy(KPPInstance->BulkRichardsonShear); const auto BuoyancyJumpH = createHostMirrorCopy(KPPInstance->BuoyancyJump); - constexpr Real ZPrevious = 1.5_Real; + constexpr Real ZPrevious = 2.0_Real; constexpr Real ZAbove = 6.5_Real; constexpr Real ZBelow = 17.5_Real; constexpr Real RiPrevious = 0.05_Real; @@ -1408,9 +1408,9 @@ void testBoundaryLayerNonuniformThickness() { } } + checkResult("boundary-layer nonuniform thickness", NumErrors); setCoefficientTestGeometry(); VCoord->minMaxLayerEdge(Halo::getDefault()); - checkResult("boundary-layer nonuniform thickness", NumErrors); } void testBoundaryLayerEdgeFallbacks() { @@ -1530,11 +1530,11 @@ void testBoundaryLayerLangmuir() { const Real ZDepth = LayerThickness * (K + 1.0_Real); const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Zeta = KPP::SURFACE_LAYER_EXTENT * ZDepth * VonKar * - TestB0 / (TestUStar * TestUStar * TestUStar); - const Real PhiInv = Kokkos::sqrt(1.0_Real - 16.0_Real * Zeta); - const Real WTurb = VonKar * TestUStar * PhiInv; - const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WTurb / 0.25_Real; + TestB0 / (TestUStar * TestUStar * TestUStar); + const Real PhiInv = Kokkos::sqrt(1.0_Real - 16.0_Real * Zeta); + const Real WTurb = VonKar * TestUStar * PhiInv; + const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * + TestN * WTurb / 0.25_Real; const Real TargetRi = K == 0 ? 0.0_Real : (K == 1 ? 0.1_Real : 0.26_Real); const Real DeltaRho = @@ -1591,15 +1591,15 @@ void testBoundaryLayerLangmuir() { const Real Enhancement = Kokkos::sqrt(3.0_Real); const Real DisabledZeta = KPP::SURFACE_LAYER_EXTENT * ZDepth * VonKar * TestB0 / (TestUStar * TestUStar * TestUStar); - const Real EnabledZeta = DisabledZeta * Enhancement; + const Real EnabledZeta = DisabledZeta * Enhancement; const Real DisabledWTurb = VonKar * TestUStar * Kokkos::sqrt(1.0_Real - 16.0_Real * DisabledZeta); const Real EnabledWTurb = VonKar * TestUStar * Kokkos::sqrt(1.0_Real - 16.0_Real * EnabledZeta); const Real ExpectedDisabledVt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * TestN * DisabledWTurb / 0.25_Real; - const Real ExpectedEnabledVt2 = 1.7_Real * UnresolvedShearConstant * - ZCenter * TestN * EnabledWTurb / 0.25_Real; + const Real ExpectedEnabledVt2 = 1.7_Real * UnresolvedShearConstant * + ZCenter * TestN * EnabledWTurb / 0.25_Real; const Real ExpectedEnabledRi = 0.26_Real * ExpectedDisabledVt2 / ExpectedEnabledVt2; @@ -1690,7 +1690,7 @@ void testBoundaryLayerSmoothing() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WTurb / 0.25_Real; + TestN * WTurb / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; From b6a1de2e4471ceafcfa931297093349527437d38 Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Wed, 12 Aug 2026 15:46:15 -0400 Subject: [PATCH 25/36] adds KPP connections to all time stepping routines --- .../timeStepping/ForwardBackwardStepper.cpp | 26 +----------- .../src/timeStepping/RungeKutta2Stepper.cpp | 24 +---------- .../src/timeStepping/RungeKutta4Stepper.cpp | 25 ++--------- .../omega/src/timeStepping/TimeStepper.cpp | 42 +++++++++++++++++-- .../omega/src/timeStepping/TimeStepper.h | 10 +++++ 5 files changed, 55 insertions(+), 72 deletions(-) diff --git a/components/omega/src/timeStepping/ForwardBackwardStepper.cpp b/components/omega/src/timeStepping/ForwardBackwardStepper.cpp index bdfbaaafa413..ec490ed311ba 100644 --- a/components/omega/src/timeStepping/ForwardBackwardStepper.cpp +++ b/components/omega/src/timeStepping/ForwardBackwardStepper.cpp @@ -6,7 +6,6 @@ #include "ForwardBackwardStepper.h" #include "Pacer.h" -#include "VertMix.h" namespace OMEGA { @@ -38,13 +37,9 @@ void ForwardBackwardStepper::doStep( const int ThickNextLevel = 1; const int TracerNextLevel = 1; - int NTracers = Tracers::getNumTracers(); - Array3DReal CurTracerArray = Tracers::getAll(TracerCurLevel); Array3DReal NextTracerArray = Tracers::getAll(TracerNextLevel); - VertMix *VMix = VertMix::getInstance(); - if (State == nullptr) LOG_CRITICAL("Invalid State"); if (AuxState == nullptr) @@ -91,25 +86,8 @@ void ForwardBackwardStepper::doStep( Tracers::updateTimeLevels(); Pacer::stop("ForwardBackward:haloExch", 3); - // Recompute KPP once on the fully updated state before implicit mixing. - CurTracerArray = Tracers::getAll(TracerCurLevel); - AuxState->computeAll(State, CurTracerArray, ThickCurLevel, VelCurLevel, - TimeStep); - Tend->computeStageVerticalMixing(State, AuxState, CurTracerArray, - ThickCurLevel, VelCurLevel); - - // Apply implicit vertical mixing - if (VMix->VelVertMixSetup.Enabled or VMix->TracerVertMixSetup.Enabled) { - VMix->VertMixImplicit(State, AuxState, CurTracerArray, NTracers, - VelCurLevel); - - // Re-exchange halos after vertical mixing - Pacer::timingBarrier("ForwardBackward:vMixHaloExchBarrier", 3, Comm); - Pacer::start("ForwardBackward:vMixHaloExch", 3); - State->exchangeHalo(VelCurLevel); - Tracers::exchangeHalo(VelCurLevel); - Pacer::stop("ForwardBackward:vMixHaloExch", 3); - } + applyPostStepVerticalMixing(State, TracerCurLevel, ThickCurLevel, + VelCurLevel, "ForwardBackward"); validateOceanState(State, AuxState, VertCoord::getDefault(), 0); diff --git a/components/omega/src/timeStepping/RungeKutta2Stepper.cpp b/components/omega/src/timeStepping/RungeKutta2Stepper.cpp index 2191cca2b370..729dacd2c343 100644 --- a/components/omega/src/timeStepping/RungeKutta2Stepper.cpp +++ b/components/omega/src/timeStepping/RungeKutta2Stepper.cpp @@ -6,7 +6,6 @@ #include "RungeKutta2Stepper.h" #include "Pacer.h" -#include "VertMix.h" namespace OMEGA { @@ -32,13 +31,9 @@ void RungeKutta2Stepper::doStep(OceanState *State, // model state const int CurLevel = 0; const int NextLevel = 1; - int NTracers = Tracers::getNumTracers(); - Array3DReal CurTracerArray = Tracers::getAll(CurLevel); Array3DReal NextTracerArray = Tracers::getAll(NextLevel); - VertMix *VMix = VertMix::getInstance(); - prescribeState(State, CurLevel, State, CurLevel, SimTime); // q = (h,u,phi) @@ -75,24 +70,7 @@ void RungeKutta2Stepper::doStep(OceanState *State, // model state Tracers::updateTimeLevels(); Pacer::stop("RK2:haloExch", 3); - // Recompute KPP once on the fully updated state before implicit mixing. - CurTracerArray = Tracers::getAll(CurLevel); - AuxState->computeAll(State, CurTracerArray, CurLevel, CurLevel, TimeStep); - Tend->computeStageVerticalMixing(State, AuxState, CurTracerArray, CurLevel, - CurLevel); - - // Apply implicit vertical mixing - if (VMix->VelVertMixSetup.Enabled or VMix->TracerVertMixSetup.Enabled) { - VMix->VertMixImplicit(State, AuxState, CurTracerArray, NTracers, - CurLevel); - - // Re-exchange halos after vertical mixing - Pacer::timingBarrier("RK2:vMixHaloExchBarrier", 3, Comm); - Pacer::start("RK2:vMixHaloExch", 3); - State->exchangeHalo(CurLevel); - Tracers::exchangeHalo(CurLevel); - Pacer::stop("RK2:vMixHaloExch", 3); - } + applyPostStepVerticalMixing(State, CurLevel, CurLevel, CurLevel, "RK2"); validateOceanState(State, AuxState, VertCoord::getDefault(), CurLevel); diff --git a/components/omega/src/timeStepping/RungeKutta4Stepper.cpp b/components/omega/src/timeStepping/RungeKutta4Stepper.cpp index f5a8e0bcd493..0cbf060c8c68 100644 --- a/components/omega/src/timeStepping/RungeKutta4Stepper.cpp +++ b/components/omega/src/timeStepping/RungeKutta4Stepper.cpp @@ -5,8 +5,8 @@ //===----------------------------------------------------------------------===// #include "RungeKutta4Stepper.h" +#include "KPPMix.h" #include "Pacer.h" -#include "VertMix.h" namespace OMEGA { @@ -79,16 +79,15 @@ void RungeKutta4Stepper::doStep(OceanState *State, // model state const int CurLevel = 0; const int NextLevel = 1; - int NTracers = Tracers::getNumTracers(); Array3DReal CurTracerArray = Tracers::getAll(CurLevel); Array3DReal NextTracerArray = Tracers::getAll(NextLevel); TimeInstant ForcingStageTime = SimTime; - VertMix *VMix = VertMix::getInstance(); const bool StageKPPEnabledPrev = Tend->StageVerticalMixingEnabled; + KPPMix *KPPInstance = KPPMix::getInstance(); Tend->StageVerticalMixingEnabled = - StageKPPEnabledPrev && Tend->TracerNonLocalFluxEnabled; + StageKPPEnabledPrev && KPPInstance && KPPInstance->Enabled; for (int Stage = 0; Stage < NStages; ++Stage) { const TimeInstant StageTime = SimTime + RKC[Stage] * TimeStep; @@ -143,25 +142,9 @@ void RungeKutta4Stepper::doStep(OceanState *State, // model state Pacer::stop("RK4:haloExch", 3); // Recompute KPP once on the fully updated state before implicit mixing. - CurTracerArray = Tracers::getAll(CurLevel); - AuxState->computeAll(State, CurTracerArray, CurLevel, CurLevel, TimeStep); - Tend->computeStageVerticalMixing(State, AuxState, CurTracerArray, CurLevel, - CurLevel); + applyPostStepVerticalMixing(State, CurLevel, CurLevel, CurLevel, "RK4"); Tend->StageVerticalMixingEnabled = StageKPPEnabledPrev; - // Apply implicit vertical mixing - if (VMix->VelVertMixSetup.Enabled or VMix->TracerVertMixSetup.Enabled) { - VMix->VertMixImplicit(State, AuxState, CurTracerArray, NTracers, - CurLevel); - - // Re-exchange halos after vertical mixing - Pacer::timingBarrier("RK4:vMixHaloExchBarrier", 3, Comm); - Pacer::start("RK4:vMixHaloExch", 3); - State->exchangeHalo(CurLevel); - Tracers::exchangeHalo(CurLevel); - Pacer::stop("RK4:vMixHaloExch", 3); - } - validateOceanState(State, AuxState, VertCoord::getDefault(), CurLevel); // Advance the clock and update the simulation time diff --git a/components/omega/src/timeStepping/TimeStepper.cpp b/components/omega/src/timeStepping/TimeStepper.cpp index 4576f2946de2..fc2306adbe40 100644 --- a/components/omega/src/timeStepping/TimeStepper.cpp +++ b/components/omega/src/timeStepping/TimeStepper.cpp @@ -9,8 +9,10 @@ #include "Error.h" #include "ForwardBackwardStepper.h" #include "Logging.h" +#include "Pacer.h" #include "RungeKutta2Stepper.h" #include "RungeKutta4Stepper.h" +#include "VertMix.h" namespace OMEGA { //------------------------------------------------------------------------------ @@ -610,11 +612,11 @@ void TimeStepper::prescribeVelocity(OceanState *State1, int TimeLevel1, const R8 lon_p = LonEdge(IEdge) - 2.0 * Pi * TSim / Tau; const R8 u = (1 / Tau) * (10.0 * Kokkos::pow(sin(lon_p), 2) * - sin(2.0 * LatEdge(IEdge)) * - cos(Pi * TSim / Tau) + - 2.0 * Pi * cos(LatEdge(IEdge))); + sin(2.0 * LatEdge(IEdge)) * + cos(Pi * TSim / Tau) + + 2.0 * Pi * cos(LatEdge(IEdge))); const R8 v = (10.0 / Tau) * sin(2.0 * lon_p) * - cos(LatEdge(IEdge)) * cos(Pi * TSim / Tau); + cos(LatEdge(IEdge)) * cos(Pi * TSim / Tau); const R8 normalVel = REarth * (u * cos(AngleEdge(IEdge)) + v * sin(AngleEdge(IEdge))); @@ -790,4 +792,36 @@ void TimeStepper::finalizeTracersUpdate(const Array3DReal &NextTracers, }); } +//------------------------------------------------------------------------------ +// Recompute stage vertical mixing and apply implicit vertical mixing after +// state/tracer time levels are updated. +void TimeStepper::applyPostStepVerticalMixing( + OceanState *State, int TracerTimeLevel, int ThickTimeLevel, + int VelTimeLevel, const std::string &TimerPrefix) const { + + Array3DReal CurTracerArray = Tracers::getAll(TracerTimeLevel); + AuxState->computeAll(State, CurTracerArray, ThickTimeLevel, VelTimeLevel, + TimeStep); + Tend->computeStageVerticalMixing(State, AuxState, CurTracerArray, + ThickTimeLevel, VelTimeLevel); + + VertMix *VMix = VertMix::getInstance(); + if (!VMix) + return; + + if (VMix->VelVertMixSetup.Enabled or VMix->TracerVertMixSetup.Enabled) { + const int NTracers = Tracers::getNumTracers(); + VMix->VertMixImplicit(State, AuxState, CurTracerArray, NTracers, + VelTimeLevel); + + // Re-exchange halos after vertical mixing + const MPI_Comm Comm = MeshHalo->getComm(); + Pacer::timingBarrier(TimerPrefix + ":vMixHaloExchBarrier", 3, Comm); + Pacer::start(TimerPrefix + ":vMixHaloExch", 3); + State->exchangeHalo(VelTimeLevel); + Tracers::exchangeHalo(VelTimeLevel); + Pacer::stop(TimerPrefix + ":vMixHaloExch", 3); + } +} + } // namespace OMEGA diff --git a/components/omega/src/timeStepping/TimeStepper.h b/components/omega/src/timeStepping/TimeStepper.h index 7e95019da641..f53fce116393 100644 --- a/components/omega/src/timeStepping/TimeStepper.h +++ b/components/omega/src/timeStepping/TimeStepper.h @@ -294,6 +294,16 @@ class TimeStepper { int TimeLevel ///< [in] time level index ) const; + /// Recompute stage vertical mixing and apply implicit vertical mixing after + /// state/tracer time levels are updated. + void applyPostStepVerticalMixing( + OceanState *State, ///< [inout] model state + int TracerTimeLevel, ///< [in] tracer time level + int ThickTimeLevel, ///< [in] pseudo-thickness time level + int VelTimeLevel, ///< [in] velocity time level + const std::string &TimerPrefix ///< [in] timer name prefix + ) const; + protected: /// Name of time stepper std::string Name; From 0315a4066a464facdf4d9a412bbad2a255495086 Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Tue, 18 Aug 2026 15:55:55 -0400 Subject: [PATCH 26/36] Updates to docs for KPP --- components/omega/doc/design/KPPMix.md | 110 +++++++++++++---------- components/omega/doc/devGuide/KPPMix.md | 69 +++++++++----- components/omega/doc/userGuide/KPPMix.md | 34 ++++--- 3 files changed, 135 insertions(+), 78 deletions(-) diff --git a/components/omega/doc/design/KPPMix.md b/components/omega/doc/design/KPPMix.md index 00ceaae11c53..2541f8c43200 100644 --- a/components/omega/doc/design/KPPMix.md +++ b/components/omega/doc/design/KPPMix.md @@ -10,48 +10,38 @@ ## 1 Overview -This document describes the OMEGA implementation of K Profile Parameterization -(KPP) ocean boundary layer mixing. KPP computes boundary-layer depth, vertical -viscosity, vertical diffusivity, and an optional non-local tracer flux shape -used by tracer tendencies. +This document describes the OMEGA implementation of the K Profile Parameterization +(KPP) ocean boundary layer mixing. KPP computes a boundary-layer depth, vertical +viscosity, vertical diffusivity, and a non-local tracer flux shape implemented outside +the implicit vertical mixing routine. The implementation is in `KPPMix` and is integrated with the OMEGA tendency and -RK4 stepping workflow. Relative to broad vertical mixing documentation, this -page focuses specifically on KPP theory, algorithm choices, and verification. - -Related pages: -- User usage/configuration: [KPP in the User Guide](../userGuide/KPPMix.md) -- Developer implementation details: [KPP in the Developer Guide](../devGuide/KPPMix.md) -- Broader vertical mixing context: [Vertical Mixing Coefficients](./VerticalMixingCoeff.md) +RK2, RK4, and Forward-backward stepping routines. Relative to broad vertical mixing documentation, this +page focuses specifically on KPP theory, algorithmic choices, and testing. ## 2 Requirements ### 2.1 Requirement: Boundary-layer depth from bulk Richardson criterion -The OBL depth must be diagnosed from a bulk Richardson criterion so that -mixing depth responds to evolving stratification, shear, and surface forcing. +Following [Large et al (1994)](https://agupubs.onlinelibrary.wiley.com/doi/10.1029/94RG01872), the OBL depth must be diagnosed from a bulk Richardson criterion so that +mixing depth responds to evolving stratification, shear, and surface forcing. It also +must include a unresolved turbulent shear contribution. ### 2.2 Requirement: Coefficients must be computable in parallel over columns The KPP implementation must operate over many columns in parallel using OMEGA -array/kernels, rather than serial single-column calls, to match accelerator -performance goals. +array/kernels, rather than serial single-column calls. ### 2.3 Requirement: Compatible with additive vertical-mixing framework KPP viscosity/diffusivity fields must be compatible with existing OMEGA vertical -mixing infrastructure so they can be merged with other configured contributions. - -### 2.4 Desired: Optional non-local flux and profile matching controls - -KPP should support optional non-local tracer flux profiles and configurable -matching/interpolation choices to support scientific tuning studies. +mixing infrastructure so that other chosen vertical mixing sources can be merged +with KPP. -### 2.5 Desired: Stable RK4 interaction +### 2.4 Desired: Non-local flux and profile matching controls -For RK4, KPP should be computed in a way that avoids repeated stage re-evaluation -when configuration requires a single post-stage update on the fully updated -state. +KPP will support a non-local tracer flux from LMD94 and include configurable +viscosity/diffusivity matching at the base of the boundary layer. ## 3 Algorithmic Formulation @@ -72,16 +62,20 @@ $$ Ri_b(h) = Ri_{crit}. $$ -Here, $\Delta b$ is buoyancy jump relative to the near-surface reference, -$|\Delta \mathbf{U}|^2$ is shear contribution, and $V_t^2$ is unresolved shear. -The code supports interpolation/matching choices near the crossing and applies -configured constraints such as minimum OBL under sea ice and a maximum by water +Here, $\Delta b$ is buoyancy jump relative to a surface layer average, +$|\Delta \mathbf{U}|^2$ is shear contribution again computed relative to the +surface layer average, and $V_t^2$ is unresolved shear. + +When the bulk Richardson number falls between model layers, quadratic interpolation +is utilized to find the depth. In addition the boundary layer depth is constrained +to fall between a configurable minimum OBL under sea ice and a maximum set by the water column depth. -### 3.2 Stage 2: KPP coefficients and optional non-local flux +### 3.2 Stage 2: KPP coefficients and non-local flux -Given diagnosed $h$, KPP computes interface coefficients using shape functions -in normalized depth $\sigma = -z/h$: +Given a diagnosed $h$, KPP computes interface viscosity and diffusivity coefficients +using shape functions in normalized depth $\sigma = -d/h$, where $d$ is the depth relative +to the sea surface height, not the physical depth: $$ K_m(\sigma) = h\, w_m(\sigma)\, M_1(\sigma), @@ -92,11 +86,24 @@ K_s(\sigma) = h\, w_s(\sigma)\, S_1(\sigma), $$ where $w_m$ and $w_s$ are turbulent velocity scales from Monin-Obukhov style -stability functions. Optional non-local tracer flux shape $G(\sigma)$ is -computed when enabled. +stability functions, see Appendix B of [Large et al, 1994](https://agupubs.onlinelibrary.wiley.com/doi/10.1029/94RG01872). Where $M_1$ and $S_1$ are shape functions. +The generic form of the shape function is given by + +$$ +X(\sigma) = c_1 \sigma^3 + c_2 \sigma^2 + c_3 \sigma + c_4 +$$ + +The coefficients are determined by various conditions, e.g., zero viscosity and diffusivity at the +surface, assuming a linear reduction of the turbulent flux with distance from the surface in the +surface layer. As in MPAS-Ocean, we include two options to determine the final coefficients. The original +version of KPP matches predicted viscosities and diffusivities to those predicted by other schemes +(e.g., shear instability driven mixing) and a second option where viscosities and diffusivities are +instead additive. In the latter case, the shape function greatly simplifies to $X(\sigma) = \sigma(1-\sigma)^2. + +For either shape function, enhanced diffusivity can be included near the boundary layer base. This can +smooth boundary layer deepening. + -Below OBL, coefficients revert to configured background values, with optional -enhanced diffusion handling near the OBL base. ## 4 Design @@ -151,12 +158,16 @@ Internal stages: - `computeOBLDepth(...)` - `computeMixingCoefficients(...)` -### 4.3 RK4 coupling behavior +### 4.3 Time stepper coupling behavior -In OMEGA RK4 stepping, stage-level KPP recomputation can be gated off and KPP -is recomputed once after all RK4 stages on the fully updated state before -implicit vertical mixing is applied. This behavior is part of the current -coupling design and is described in detail for developers and users in: +KPP is coupled to all three OMEGA time steppers -- Forward-Backward (default, +split-explicit-style), RungeKutta2, and RungeKutta4. For each stepper, KPP +recomputes boundary-layer depth and coefficients at every internal stage of +that stepper, and once more on the fully updated state after time levels are +advanced, immediately before implicit vertical mixing is applied. The final, +post-step recompute is what determines the KPP diagnostics for that step +across all three steppers. Full call-flow detail per stepper is described for +developers and users in: - [Developer KPP workflow](../devGuide/KPPMix.md) - [User runtime notes](../userGuide/KPPMix.md) @@ -173,21 +184,26 @@ Use targeted tests and diagnostics to verify: Tests cover requirements: 2.1, 2.2, 2.3, 2.4. -### 5.2 Coupled/regression checks +### 5.2 Polaris testing -Run regression cases and compare key diagnostics over time: +The single column test case can be run across a wide range of surface forcing +(heat, evaporative, and momentum fluxes) and the following diagnostics will be +plotted over time - `BoundaryLayerDepth` - `BulkRichardsonNumber` - `VertDiff`, `VertVisc` -- `VertNonLocalFlux` (when enabled) +- `VertNonLocalFlux` + +For simple cases, such as free convection, boundary layer depth can be compared against +a semi-analytic solution (e.g., Appendix F, ([Van Roekel et al, 2018](https://agupubs.onlinelibrary.wiley.com/doi/full/10.1029/2018MS001336)). -For full OMEGA testing workflow, see the developer testing guide: -[Testing Code](../devGuide/Testing.md). +The global test case, forced by annual averaged ERA-5 net surface heat, freshwater, and +momentum fluxes provides a qualitative assessment of KPP behavior. ### 5.3 Configuration sensitivity checks -Perform short experiments varying: +Short single column and global test cases can be run varying critical parameters such as - `CriticalBulkRichardsonNumber` - `MatchTechnique` diff --git a/components/omega/doc/devGuide/KPPMix.md b/components/omega/doc/devGuide/KPPMix.md index de5f30528c5f..60d588d0284c 100644 --- a/components/omega/doc/devGuide/KPPMix.md +++ b/components/omega/doc/devGuide/KPPMix.md @@ -2,18 +2,13 @@ # KPP Boundary Layer Mixing -This page maps OMEGA KPP implementation details to runtime behavior and code +This guide maps Omega KPP implementation details to runtime behavior and code locations. It complements the design page by focusing on concrete APIs, call flow, and developer test strategy. -Related pages: -- Design and theory: [Design KPP document](../design/KPPMix.md) -- User configuration and workflow: [User KPP guide](../userGuide/KPPMix.md) -- Broader vertical mixing: [Developer Vertical Mixing Coefficients](./VerticalMixingCoeff.md) - ## Implementation Overview -OMEGA KPP is implemented in `KPPMix` as a singleton with two major compute +Omega KPP is implemented in `KPPMix` as a singleton with two major compute phases: 1. OBL depth diagnosis (`computeOBLDepth`) @@ -45,20 +40,47 @@ Then it calls: KPPInstance->computeKPPMix(...) ``` -### RK4 interaction +### Time stepper interaction -Current RK4 behavior is: +KPP is hooked into all three OMEGA time steppers +(`src/timeStepping/RungeKutta4Stepper.cpp`, +`src/timeStepping/RungeKutta2Stepper.cpp`, +`src/timeStepping/ForwardBackwardStepper.cpp`) through two mechanisms: -1. Disable stage KPP recompute while stepping RK sub-stages by setting - `StageVerticalMixingEnabled = false`. -2. After RK4 stage accumulation and time-level update, recompute auxiliary - state and call `computeStageVerticalMixing(...)` once on the fully updated +1. **Stage recompute**: `Tendencies::StageVerticalMixingEnabled` (default + `true`) gates a call to `computeStageVerticalMixing(...)` inside + `computeAllTendencies`, `computeVelocityTendencies`, and + `computeTracerTendencies`. Whichever of these tendency functions a stepper + calls during its stages will trigger a KPP recompute on that stage's state. -3. Restore previous stage-mixing flag. -4. Apply implicit vertical mixing. - -This behavior is implemented in the RK4 stepper and is important for -consistency with current coupling expectations. +2. **Post-step recompute**: `TimeStepper::applyPostStepVerticalMixing(...)` + (in `src/timeStepping/TimeStepper.cpp`) is called by every stepper's + `doStep()` immediately after `State->updateTimeLevels()`. It recomputes + auxiliary state and calls `computeStageVerticalMixing(...)` once more on + the fully updated state, then applies implicit vertical mixing via + `VertMix::VertMixImplicit(...)` if enabled. + +Per-stepper call flow: + +- **RungeKutta4Stepper**: calls `computeAllTendencies(...)` once per stage + (base stage plus 3 provisional stages), so KPP recomputes 4 times during + stepping, followed by `applyPostStepVerticalMixing(..., "RK4")`. +- **RungeKutta2Stepper**: calls `computeAllTendencies(...)` twice (initial + stage, midpoint stage), so KPP recomputes twice during stepping, followed + by `applyPostStepVerticalMixing(..., "RK2")`. +- **ForwardBackwardStepper**: calls `computeVelocityTendencies(...)` and + `computeTracerTendencies(...)` separately, each triggering a KPP recompute, + followed by `applyPostStepVerticalMixing(..., "ForwardBackward")`. + +In every stepper, the post-step recompute uses the fully updated state and is +what determines the KPP diagnostics written to output for that step. + +Note: `RungeKutta4Stepper::doStep` still saves/restores +`StageVerticalMixingEnabled` around its stage loop and ANDs it with +`KPPMix::Enabled`. This is currently a no-op with respect to gating stage +recompute, since `computeStageVerticalMixing` already early-returns when KPP +is disabled; do not assume stage recompute is suppressed during RK4 +sub-stages when reading that code. ## Configuration Mapping @@ -73,7 +95,10 @@ Important keys and class members: - `InterpType2` -> `InterpType2Str` - `UseEnhancedDiffusion` -> `UseEnhancedDiffusion` - `UseLangmuirCirculation` -> `UseLangmuirCirculation` -- `UseNonLocalFlux` -> `UseNonLocalFlux` +- `UseNonLocalFlux` -> `UseNonLocalFlux` (expert/debugging override only -- + non-local flux is on by default and required for physically correct + tracer transport; this key is intentionally omitted from the User Guide so + it is not disabled by non-experts) - `IceFractionThresholdForLangmuir` -> `IceFractionThresholdForLangmuir` - `IceFractionThresholdForMinimumOBL` -> `IceFractionThresholdForMinimumOBL` - `MinimumOBLUnderSeaIce` -> `MinimumOBLUnderSeaIce` @@ -117,8 +142,10 @@ behavior in experiments. 1. Verify KPP initialization with explicit and default YAML keys. 2. Verify stage call path executes with KPP enabled and is skipped when disabled. -3. Verify RK4 sequencing: no stage recompute during sub-stages, one recompute - before implicit vertical mixing. +3. Verify per-stepper sequencing: stage recompute at each stage of the active + stepper (4 for RK4, 2 for RK2, 2 for Forward-Backward), plus one final + recompute on the updated state before implicit vertical mixing, for all + three steppers. ### Diagnostics-based checks diff --git a/components/omega/doc/userGuide/KPPMix.md b/components/omega/doc/userGuide/KPPMix.md index 22f789f446e7..e01122d09bde 100644 --- a/components/omega/doc/userGuide/KPPMix.md +++ b/components/omega/doc/userGuide/KPPMix.md @@ -17,19 +17,31 @@ KPP computes: - Ocean boundary layer depth (`BoundaryLayerDepth`) - Vertical viscosity (`VertVisc`) - Vertical diffusivity (`VertDiff`) -- Optional non-local tracer flux profile (`VertNonLocalFlux`) +- Non-local tracer flux profile (`VertNonLocalFlux`) It uses a bulk Richardson depth search followed by profile-based coefficient -construction. +construction. The non-local flux is a standard part of the KPP formulation and +is included by default. ## How KPP Is Used in Time Stepping -In the current RK4 workflow, stage-level KPP recomputation is gated off during -RK4 sub-stages and KPP is recomputed once after all four stages, on the fully -updated state, before implicit vertical mixing is applied. +KPP is connected to all three OMEGA time steppers: Forward-Backward (the +default, split-explicit-style stepper), RungeKutta2, and RungeKutta4. For +whichever stepper is active, KPP recomputes boundary-layer depth and +coefficients at each internal stage of that stepper, and then once more on +the fully updated state after time levels are advanced, immediately before +implicit vertical mixing is applied: -This means KPP diagnostics in output correspond to the post-stage state for -each RK4 step. +- **Forward-Backward** (default): KPP recomputes when velocity tendencies + are evaluated and again when tracer tendencies are evaluated, then once + more on the updated state. +- **RungeKutta2**: KPP recomputes at the initial stage and at the midpoint + stage, then once more on the updated state. +- **RungeKutta4**: KPP recomputes at each of the four RK4 stages, then once + more on the updated state. + +In all cases, the final recompute on the fully updated state is what +determines the KPP diagnostics written to output for that step. ## Configuration @@ -41,7 +53,6 @@ KPP settings are under `VertMix: KPP` in `omega.yml`. VertMix: KPP: Enable: true - UseNonLocalFlux: true CriticalBulkRichardsonNumber: 0.25 MatchTechnique: SimpleShapes InterpType2: LMD94 @@ -57,7 +68,6 @@ VertMix: | Key | Meaning | Typical default | |---|---|---| | `Enable` | Enable KPP mixing | `true` | -| `UseNonLocalFlux` | Enable non-local tracer flux profile | `true` | | `CriticalBulkRichardsonNumber` | OBL depth criterion threshold | `0.25` | | `MatchTechnique` | KPP profile matching mode | `SimpleShapes` | | `InterpType2` | Interpolation type used near OBL matching/base logic | `LMD94` | @@ -102,6 +112,10 @@ To diagnose KPP, include KPP fields in output stream contents. Common fields: ## Practical Notes -- Keep `UseNonLocalFlux` enabled when you want tracer non-local transport. +- Non-local tracer transport is included by default and should be left + enabled; it is required for physically correct KPP boundary layer tracer + fluxes. An expert-only override exists for debugging and is documented in + the [Developer KPP document](../devGuide/KPPMix.md) -- it is not intended + for general use. - Use `DebugDiagnostics` sparingly for troubleshooting targeted cases. - When studying sea-ice regions, review minimum-OBL and ice-threshold options. From eb78878997180b913e141eb50e33467f3d906e89 Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Tue, 18 Aug 2026 14:18:45 -0700 Subject: [PATCH 27/36] Fixes KPP depth and adds cTest for depth --- components/omega/doc/devGuide/KPPMix.md | 9 ++ components/omega/src/ocn/KPPMix.cpp | 87 ++++++----- components/omega/src/ocn/KPPNonLocalFlux.h | 105 ------------- components/omega/test/ocn/KPPMixTest.cpp | 170 ++++++++++++++++++--- 4 files changed, 205 insertions(+), 166 deletions(-) delete mode 100755 components/omega/src/ocn/KPPNonLocalFlux.h diff --git a/components/omega/doc/devGuide/KPPMix.md b/components/omega/doc/devGuide/KPPMix.md index 60d588d0284c..f0799e3aacc8 100644 --- a/components/omega/doc/devGuide/KPPMix.md +++ b/components/omega/doc/devGuide/KPPMix.md @@ -17,6 +17,15 @@ phases: Main class/API surface is in `src/ocn/KPPMix.h` and implementation is in `src/ocn/KPPMix.cpp`. +### Depth convention + +All KPP depths are measured downward from the free surface, not from the geoid. +`VertCoord::GeomZInterface` and `GeomZMid` are geometric heights relative to +`z = 0`, with `GeomZInterface(ICell, MinLayerCell)` equal to +`VertCoord::SshCell`. KPP therefore forms depths as +`SshCell(ICell) - GeomZ...(ICell, K)`. Layer thicknesses are differences of +geometric heights and are unaffected by the sea surface height. + ## Runtime Call Flow ### Tendency coupling diff --git a/components/omega/src/ocn/KPPMix.cpp b/components/omega/src/ocn/KPPMix.cpp index fa4027d44c47..9687b271e8d8 100644 --- a/components/omega/src/ocn/KPPMix.cpp +++ b/components/omega/src/ocn/KPPMix.cpp @@ -249,6 +249,7 @@ void KPPMix::logDiagnostics(const Array2DReal &PotentialDensity, const auto MinLayerCellH = createHostMirrorCopy(VCoord->MinLayerCell); const auto MaxLayerCellH = createHostMirrorCopy(VCoord->MaxLayerCell); const auto ZInterfaceH = createHostMirrorCopy(VCoord->GeomZInterface); + const auto SshCellH = createHostMirrorCopy(VCoord->SshCell); const auto DensityH = createHostMirrorCopy(PotentialDensity); const auto UStarH = createHostMirrorCopy(SurfaceFrictionVelocity); const auto B0H = createHostMirrorCopy(SurfaceBuoyancyFlux); @@ -357,7 +358,7 @@ void KPPMix::logDiagnostics(const Array2DReal &PotentialDensity, for (int K = KMin; K <= KTop; ++K) { const int kCell = Kokkos::min(K, NVertLayers - 1); const int kInt = Kokkos::min(K + 1, NVertLayers); - const Real z_depth = Kokkos::abs(ZInterfaceH(ICell, kInt)); + const Real z_depth = SshCellH(ICell) - ZInterfaceH(ICell, kInt); const Real rho_k = DensityH(ICell, kCell); const Real delta_rho = rho_k - rho_surf; @@ -440,6 +441,7 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, OMEGA_SCOPE(MaxLayerEdgeTop, VCoord->MaxLayerEdgeTop); OMEGA_SCOPE(ZInterface, VCoord->GeomZInterface); OMEGA_SCOPE(ZMid, VCoord->GeomZMid); + OMEGA_SCOPE(LocSshCell, VCoord->SshCell); OMEGA_SCOPE(NEdgesOnCell, Mesh->NEdgesOnCell); OMEGA_SCOPE(EdgesOnCell, Mesh->EdgesOnCell); OMEGA_SCOPE(CellsOnCell, Mesh->CellsOnCell); @@ -486,7 +488,11 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, const Real iceFrac = LocIceFraction(ICell); - Real obl_depth = Kokkos::abs(ZInterface(ICell, KIntDeep)); + // KPP depths are measured below the free surface, so geometric + // heights must be offset by the sea surface height. + const Real Ssh = LocSshCell(ICell); + + Real obl_depth = Ssh - ZInterface(ICell, KIntDeep); I4 k_cross = -1; const Real ri_crit = LocCriticalRichardson; const Real ri_stop_crit = @@ -596,8 +602,8 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, } const I4 kCell = Kokkos::min(k, NVertLayers - 1); const I4 kInt = Kokkos::min(k + 1, NVertLayers); - const Real z_depth = Kokkos::abs(ZInterface(ICell, kInt)); - const Real z_center = Kokkos::abs(ZMid(ICell, kCell)); + const Real z_depth = Ssh - ZInterface(ICell, kInt); + const Real z_center = Ssh - ZMid(ICell, kCell); if (z_depth < 1.0e-12) continue; @@ -605,7 +611,7 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, // Advance cell surface average for density while (k_surface_avg < k && - Kokkos::abs(ZInterface(ICell, k_surface_avg + 1)) < + (Ssh - ZInterface(ICell, k_surface_avg + 1)) < surf_layer_depth) { ++k_surface_avg; const I4 ksa = Kokkos::min(k_surface_avg, NVertLayers - 1); @@ -625,7 +631,7 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, const I4 IEdge = EdgesOnCell(ICell, J); const I4 KEMax = MaxLayerEdgeTop(IEdge); while (k_surf_e[J] < k && - Kokkos::abs(ZInterface(ICell, k_surf_e[J] + 1)) < + (Ssh - ZInterface(ICell, k_surf_e[J] + 1)) < surf_layer_depth) { ++k_surf_e[J]; const I4 ke = Kokkos::min( @@ -679,9 +685,9 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, Real w_turb = 0.0_Real; if (u_star > 1.0e-12_Real) { - const Real u3 = u_star * u_star * u_star; - const Real zeta = sigma_loc * z_depth * VonKar * b0_eff / - Kokkos::max(u3, 1.0e-20_Real); + const Real u3 = u_star * u_star * u_star; + const Real zeta = sigma_loc * z_depth * VonKar * b0_eff / + Kokkos::max(u3, 1.0e-20_Real); const Real phi_inv_s = KPP::KPPProfileS2(zeta); w_turb = VonKar * u_star * Kokkos::max(phi_inv_s, 0.0_Real); } else if (b0_eff < 0.0_Real) { @@ -719,8 +725,8 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, const I4 kBelow = Kokkos::min(k_cross, NVertLayers - 1); const I4 kAboveRi = Kokkos::min(kAbove + 1, NVertLayers); const I4 kBelowRi = Kokkos::min(kBelow + 1, NVertLayers); - const Real z_above = Kokkos::abs(ZMid(ICell, kAbove)); - const Real z_below = Kokkos::abs(ZMid(ICell, kBelow)); + const Real z_above = Ssh - ZMid(ICell, kAbove); + const Real z_below = Ssh - ZMid(ICell, kBelow); const Real ri_above = LocBulkRichardson(ICell, kAboveRi); const Real ri_below = LocBulkRichardson(ICell, kBelowRi); @@ -733,7 +739,7 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, if (k_cross > KMin + 1) { const I4 kPrev = Kokkos::max(KMin, kAbove - 1); const I4 kPrevRi = Kokkos::min(kPrev + 1, NVertLayers); - const Real z_prev = Kokkos::abs(ZMid(ICell, kPrev)); + const Real z_prev = Ssh - ZMid(ICell, kPrev); const Real ri_prev = LocBulkRichardson(ICell, kPrevRi); const Real dz_prev = z_above - z_prev; if (Kokkos::abs(dz_prev) > 1.0e-12_Real) { @@ -794,16 +800,16 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, } else { // Match center-based OBL convention when crossing occurs in // the top interval. - obl_depth = Kokkos::abs(ZMid(ICell, KMin)); + obl_depth = Ssh - ZMid(ICell, KMin); } } else { - obl_depth = Kokkos::abs(ZInterface(ICell, KIntDeep)); + obl_depth = Ssh - ZInterface(ICell, KIntDeep); } const Real top_layer_thickness = Kokkos::abs(ZInterface(ICell, KIntTop) - ZInterface(ICell, KMin)); const Real min_obl_depth = 0.5_Real * top_layer_thickness; - const Real max_obl_depth = Kokkos::abs(ZMid(ICell, KMax)); + const Real max_obl_depth = Ssh - ZMid(ICell, KMax); obl_depth = Kokkos::fmax(obl_depth, min_obl_depth); if (iceFrac > LocIceFracThresholdForMinOBL) { obl_depth = Kokkos::fmax(obl_depth, LocMinimumOBLUnderSeaIce); @@ -812,8 +818,8 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, I4 k_final = KMax; for (I4 k = KMin; k < KMax; ++k) { - const Real z_above = Kokkos::abs(ZInterface(ICell, k)); - const Real z_below = Kokkos::abs(ZInterface(ICell, k + 1)); + const Real z_above = Ssh - ZInterface(ICell, k); + const Real z_below = Ssh - ZInterface(ICell, k + 1); if (obl_depth >= z_above && obl_depth <= z_below) { k_final = k; break; @@ -886,11 +892,13 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, return; } + const Real Ssh = LocSshCell(ICell); + const I4 KIntTop = Kokkos::min(KMin + 1, NVertLayers); const Real top_layer_thickness = Kokkos::abs( ZInterface(ICell, KIntTop) - ZInterface(ICell, KMin)); const Real min_obl_depth = 0.5_Real * top_layer_thickness; - const Real max_obl_depth = Kokkos::abs(ZMid(ICell, KMax)); + const Real max_obl_depth = Ssh - ZMid(ICell, KMax); Real obl_depth = LocBoundaryLayerDepthSmooth(ICell); obl_depth = Kokkos::fmax(obl_depth, min_obl_depth); @@ -898,8 +906,8 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, I4 k_final = KMax; for (I4 k = KMin; k < KMax; ++k) { - const Real z_above = Kokkos::abs(ZInterface(ICell, k)); - const Real z_below = Kokkos::abs(ZInterface(ICell, k + 1)); + const Real z_above = Ssh - ZInterface(ICell, k); + const Real z_below = Ssh - ZInterface(ICell, k + 1); if (obl_depth >= z_above && obl_depth <= z_below) { k_final = k; break; @@ -942,6 +950,7 @@ void KPPMix::computeMixingCoefficients( OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); OMEGA_SCOPE(ZInterface, VCoord->GeomZInterface); OMEGA_SCOPE(ZMid, VCoord->GeomZMid); + OMEGA_SCOPE(LocSshCell, VCoord->SshCell); OMEGA_SCOPE(LocInteriorVertDiff, InteriorVertDiff); OMEGA_SCOPE(LocInteriorVertVisc, InteriorVertVisc); @@ -999,6 +1008,10 @@ void KPPMix::computeMixingCoefficients( const I4 KMatch = Kokkos::min(KMax + 1, LocIndexBoundaryLayerDepth(ICell) + 1); + // KPP depths are measured below the free surface, so geometric + // heights must be offset by the sea surface height. + const Real Ssh = LocSshCell(ICell); + // ============================================================= // Compute turbulent velocity scales // ============================================================= @@ -1010,9 +1023,9 @@ void KPPMix::computeMixingCoefficients( // ============================================================= for (I4 k = KMin; k <= KMax + 1; ++k) { const I4 k_iface = Kokkos::min(Kokkos::max(k, 0), NVertLayers); - const Real z_depth = Kokkos::abs(ZInterface(ICell, k_iface)); + const Real z_depth = Ssh - ZInterface(ICell, k_iface); - // Check if within OBL using geometric depth. + // Check if within OBL using depth below the free surface. if (z_depth <= h_obl && h_obl > 0.0_Real) { // Normalized depth in Omega sign convention: sigma in [-1,0]. Real sigma = -z_depth / h_obl; @@ -1031,7 +1044,7 @@ void KPPMix::computeMixingCoefficients( if (u_star > 0.0_Real) { const Real u3 = u_star * u_star * u_star; zeta = sigma_loc * h_obl * b0 * LocKappa / - Kokkos::max(u3, 1.0e-20_Real); + Kokkos::max(u3, 1.0e-20_Real); // KPPProfileM2/S2 return phi^{-1}; do not invert again. const Real phi_inv_m = KPP::KPPProfileM2(zeta); @@ -1107,12 +1120,12 @@ void KPPMix::computeMixingCoefficients( } else { // Below OBL: preserve interior values for MatchBoth, otherwise // no KPP contribution. - LocVertDiff(ICell, k) = LocUseInteriorMix - ? LocInteriorVertDiff(ICell, k) - : 0.0_Real; - LocVertVisc(ICell, k) = LocUseInteriorMix - ? LocInteriorVertVisc(ICell, k) - : 0.0_Real; + LocVertDiff(ICell, k) = LocUseInteriorMix + ? LocInteriorVertDiff(ICell, k) + : 0.0_Real; + LocVertVisc(ICell, k) = LocUseInteriorMix + ? LocInteriorVertVisc(ICell, k) + : 0.0_Real; LocVertNonLocalFlux(ICell, k) = 0.0; LocTurbulentVelocityScale(ICell, k) = 0.0; } @@ -1123,7 +1136,7 @@ void KPPMix::computeMixingCoefficients( if (LocUseEnhancedDiffusion && h_obl > 0.0_Real) { const I4 k_obl = Kokkos::max( KMin, Kokkos::min(LocIndexBoundaryLayerDepth(ICell), KMax)); - const Real z_mid_obl = Kokkos::abs(ZMid(ICell, k_obl)); + const Real z_mid_obl = Ssh - ZMid(ICell, k_obl); const bool target_outside_obl = h_obl >= z_mid_obl; const I4 k_ktup = @@ -1132,15 +1145,15 @@ void KPPMix::computeMixingCoefficients( ? Kokkos::min(k_obl + 1, KMax + 1) : Kokkos::max(KMin + 1, k_obl); - const Real z_ktup = Kokkos::abs(ZMid(ICell, k_ktup)); - const Real z_next = - (k_ktup < KMax) ? Kokkos::abs(ZMid(ICell, k_ktup + 1)) - : Kokkos::abs(ZInterface(ICell, k_ktup + 1)); - const Real delta = Kokkos::fmax( + const Real z_ktup = Ssh - ZMid(ICell, k_ktup); + const Real z_next = (k_ktup < KMax) + ? (Ssh - ZMid(ICell, k_ktup + 1)) + : (Ssh - ZInterface(ICell, k_ktup + 1)); + const Real delta = Kokkos::fmax( 0.0_Real, Kokkos::fmin(1.0_Real, - (h_obl - z_ktup) / - Kokkos::max(z_next - z_ktup, 1.0e-12_Real))); + (h_obl - z_ktup) / + Kokkos::max(z_next - z_ktup, 1.0e-12_Real))); const Real one_minus_delta = 1.0_Real - delta; Real sigma_ktup = -z_ktup / h_obl; diff --git a/components/omega/src/ocn/KPPNonLocalFlux.h b/components/omega/src/ocn/KPPNonLocalFlux.h deleted file mode 100755 index 50146649bebf..000000000000 --- a/components/omega/src/ocn/KPPNonLocalFlux.h +++ /dev/null @@ -1,105 +0,0 @@ -#ifndef OMEGA_KPP_NONLOCAL_FLUX_H -#define OMEGA_KPP_NONLOCAL_FLUX_H -//===-- ocn/KPPNonLocalFlux.h - Non-local Flux Computation -----*- C++ -*-===// -// -/// \file -/// \brief Compute KPP non-local flux profiles for tracers -/// -/// This header defines functors for computing the non-local flux coefficient -/// G(σ) which is applied to surface tracer fluxes to produce vertical mixing -/// of tracers. The non-local flux represents transport by coherent plumes -/// within the OBL. -// -//===----------------------------------------------------------------------===// - -#include "KPPConstants.h" -#include "OmegaKokkos.h" - -namespace OMEGA::KPP { - -/// @brief Non-local flux profile functor -/// Computes G(σ) applied to surface tracer fluxes -/// -/// The non-local flux produces vertical transport: -/// flux(z) = G(σ) × Q_surf -/// where σ = -z/h_OBL (normalized depth) -/// -/// REFERENCES: Large et al. (1994) Eq. (12)-(13), Large et al. (1997) -class KPPComputeNonLocalFlux { - - public: - Array1DReal ZInterface; ///< Depth at interfaces (m, negative down) - Array1DReal ZCenter; ///< Depth at cell centers (m) - Array1DI4 MinLayerCell; ///< Min layer index per cell - Array1DI4 MaxLayerCell; ///< Max layer index per cell - - // OBL depth information - Real OBLDepth; ///< Current OBL depth (m) - I4 OBLIndex; ///< Layer index of OBL base - - // Reference profiles for shear stability correction - Array1DReal GradientRichardsonNum; ///< Ri_g for stability correction - - // Output - Array1DReal NonLocalFluxProfile; ///< G(σ) values at interfaces - - /// @brief Constructor - KPPComputeNonLocalFlux(const Array1DReal &ZInterface_in, - const Array1DReal &ZCenter_in, - const Array1DI4 &MinLayerCell_in, - const Array1DI4 &MaxLayerCell_in, Real obl_depth, - I4 obl_index, const Array1DReal &RiGrad_in, - const Array1DReal &G_profile_out) - : ZInterface(ZInterface_in), ZCenter(ZCenter_in), - MinLayerCell(MinLayerCell_in), MaxLayerCell(MaxLayerCell_in), - OBLDepth(obl_depth), OBLIndex(obl_index), - GradientRichardsonNum(RiGrad_in), NonLocalFluxProfile(G_profile_out) {} - - /// @brief Compute non-local flux profile G(σ) - /// - /// Algorithm: - /// 1. For each layer k from surface to OBL base: - /// a. Compute normalized depth σ = -z/h_OBL - /// b. Evaluate G(σ) profile function - /// c. Apply stability correction if needed - /// 2. Set G(σ) = 0 below OBL base - /// - KOKKOS_FUNCTION - void computeNonLocalFlux(I4 ICell) const { - - const I4 KMin = MinLayerCell(ICell); - const I4 KMax = MaxLayerCell(ICell); - - // Clamp OBL depth to reasonable bounds - Real h_obl = Kokkos::fmax(1.0, OBLDepth); - - // ======================================================================= - // Compute G(σ) at each interface - // ======================================================================= - for (I4 k = KMin; k <= KMax + 1; ++k) { - - Real z_interface = Kokkos::abs(ZInterface(k)); - - // Check if point is within OBL - if (z_interface <= h_obl) { - - // Normalized depth: σ = -z/h (negative in ocean convention) - Real sigma = -(z_interface / h_obl); // -1 <= sigma <= 0 - - // Evaluate G(σ) profile - Real g_sigma = KPPProfileG(sigma); - - NonLocalFluxProfile(k) = g_sigma; - - } else { - // Below OBL base: no non-local flux - NonLocalFluxProfile(k) = 0.0; - } - } - } - -}; // class KPPComputeNonLocalFlux - -} // namespace OMEGA::KPP - -#endif // OMEGA_KPP_NONLOCAL_FLUX_H diff --git a/components/omega/test/ocn/KPPMixTest.cpp b/components/omega/test/ocn/KPPMixTest.cpp index ec5baf74d14c..d6067ac9c2b9 100644 --- a/components/omega/test/ocn/KPPMixTest.cpp +++ b/components/omega/test/ocn/KPPMixTest.cpp @@ -374,7 +374,9 @@ void testTurbulentVelocityScale() { checkResult("turbulent velocity scale", NumErrors); } -void setCoefficientTestGeometry() { +// Builds a uniform column whose free surface sits at Ssh. All KPP results must +// be invariant to Ssh since depths are measured below the free surface. +void setCoefficientTestGeometry(Real Ssh = 0.0_Real) { const HorzMesh *Mesh = HorzMesh::getDefault(); VertCoord *VCoord = VertCoord::getDefault(); KPPMix *KPPInstance = KPPMix::getInstance(); @@ -382,6 +384,7 @@ void setCoefficientTestGeometry() { OMEGA_SCOPE(GeomZInterface, VCoord->GeomZInterface); OMEGA_SCOPE(GeomZMid, VCoord->GeomZMid); + OMEGA_SCOPE(SshCell, VCoord->SshCell); OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); OMEGA_SCOPE(BoundaryLayerDepth, KPPInstance->BoundaryLayerDepth); @@ -391,12 +394,13 @@ void setCoefficientTestGeometry() { "KPPMixTest-SetGeometry", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { MinLayerCell(ICell) = 0; MaxLayerCell(ICell) = NVertLayers - 1; + SshCell(ICell) = Ssh; BoundaryLayerDepth(ICell) = TestOBLDepth; IndexBoundaryLayerDepth(ICell) = TestOBLIndex; for (I4 K = 0; K <= NVertLayers; ++K) { - GeomZInterface(ICell, K) = -LayerThickness * K; + GeomZInterface(ICell, K) = Ssh - LayerThickness * K; if (K < NVertLayers) { - GeomZMid(ICell, K) = -LayerThickness * (K + 0.5_Real); + GeomZMid(ICell, K) = Ssh - LayerThickness * (K + 0.5_Real); } } }); @@ -594,9 +598,9 @@ void testMatchBothInteriorCoefficients() { constexpr Real SmoothAtSigma = 0.5_Real; const Real TurbVel = VonKar * 0.02_Real; const Real ExpectedDiffMid = TestOBLDepth * TurbVel * SimpleShape + - SmoothAtSigma * ExpectedInteriorDiff; - const Real ExpectedViscMid = TestOBLDepth * TurbVel * SimpleShape + - SmoothAtSigma * ExpectedInteriorVisc; + SmoothAtSigma * ExpectedInteriorDiff; + const Real ExpectedViscMid = TestOBLDepth * TurbVel * SimpleShape + + SmoothAtSigma * ExpectedInteriorVisc; const Real ExpectedNonLocal = nonLocalNormalization() * KPP::KPPProfileGMatchBoth(Sigma); @@ -1030,7 +1034,7 @@ void testBoundaryLayerDepth() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1062,8 +1066,8 @@ void testBoundaryLayerDepth() { Slope * Slope - 4.0_Real * Quadratic * (RiAbove - 0.25_Real); const Real ExpectedBLD = ZAbove + (-Slope + Kokkos::sqrt(Discriminant)) / (2.0_Real * Quadratic); - const Real ExpectedVt2 = 1.7_Real * UnresolvedShearConstant * 25.0_Real * - TestN * WindTurbulentScale / 0.25_Real; + const Real ExpectedVt2 = 1.7_Real * UnresolvedShearConstant * 25.0_Real * + TestN * WindTurbulentScale / 0.25_Real; const Real ExpectedDeltaB = 0.4_Real * ExpectedVt2 / (RiScaling * 25.0_Real); int NumErrors = 0; @@ -1089,7 +1093,7 @@ void testBoundaryLayerDepth() { : 0.3_Real; const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1165,7 +1169,7 @@ void testBoundaryLayerDepth() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1223,7 +1227,7 @@ void testBoundaryLayerDepth() { const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real TargetRi = K == 0 ? 0.0_Real : 1.0_Real; const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1266,6 +1270,7 @@ void testBoundaryLayerNonuniformThickness() { OMEGA_SCOPE(GeomZInterface, VCoord->GeomZInterface); OMEGA_SCOPE(GeomZMid, VCoord->GeomZMid); + OMEGA_SCOPE(SshCell, VCoord->SshCell); OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); @@ -1302,6 +1307,7 @@ void testBoundaryLayerNonuniformThickness() { KOKKOS_LAMBDA(I4 ICell) { MinLayerCell(ICell) = 0; MaxLayerCell(ICell) = NVertLayers - 1; + SshCell(ICell) = 0.0_Real; for (I4 K = 0; K <= NVertLayers; ++K) { Real Depth = 25.0_Real + 25.0_Real * (K - 4); @@ -1347,9 +1353,9 @@ void testBoundaryLayerNonuniformThickness() { 0.10_Real * Vt2Layer2 * RhoSw / (RiScaling * Gravity * 6.5_Real); const Real DeltaRho3 = 0.40_Real * (Shear3 + Vt2Layer3) * RhoSw / (RiScaling * Gravity * 17.5_Real); - Density(ICell, 0) = RhoSw; - Density(ICell, 1) = RhoSw + DeltaRho1; - Density(ICell, 2) = RhoSw + DeltaRho2; + Density(ICell, 0) = RhoSw; + Density(ICell, 1) = RhoSw + DeltaRho1; + Density(ICell, 2) = RhoSw + DeltaRho2; // At k=3, the 2.5 m surface layer contains the unequal 1 m and // 2 m layers. Construct rho(3) relative to that weighted mean. @@ -1413,6 +1419,121 @@ void testBoundaryLayerNonuniformThickness() { VCoord->minMaxLayerEdge(Halo::getDefault()); } +// KPP depths are measured below the free surface, so rigidly translating the +// whole column by the sea surface height must leave every KPP output unchanged. +void testSshOffsetInvariance() { + const HorzMesh *Mesh = HorzMesh::getDefault(); + VertCoord *VCoord = VertCoord::getDefault(); + KPPMix *KPPInstance = KPPMix::getInstance(); + const I4 NVertLayers = VCoord->NVertLayers; + + Array2DReal Density("KPPMixTest-SshDensity", Mesh->NCellsSize, NVertLayers); + Array2DReal NormalVelocity("KPPMixTest-SshNormalVelocity", Mesh->NEdgesSize, + NVertLayers); + Array2DReal TangentialVelocity("KPPMixTest-SshTangentialVelocity", + Mesh->NEdgesSize, NVertLayers); + Array1DReal UStar("KPPMixTest-SshUStar", Mesh->NCellsSize); + Array1DReal B0("KPPMixTest-SshB0", Mesh->NCellsSize); + Array2DReal BVF("KPPMixTest-SshBVF", Mesh->NCellsSize, NVertLayers + 1); + Array1DReal IceFraction("KPPMixTest-SshIce", Mesh->NCellsSize); + Array1DReal Wind; + + deepCopy(NormalVelocity, 0.0_Real); + deepCopy(TangentialVelocity, 0.0_Real); + deepCopy(UStar, 0.02_Real); + deepCopy(B0, -1.0e-7_Real); + deepCopy(BVF, 1.0_Real); + deepCopy(IceFraction, 0.0_Real); + + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + constexpr Real TestN = 1.0_Real; + const Real UnresolvedShearConstant = + Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + (VonKar * VonKar); + const Real WindTurbulentScale = VonKar * 0.02_Real; + parallelFor( + "KPPMixTest-SetSshDensity", {Mesh->NCellsAll, NVertLayers}, + KOKKOS_LAMBDA(I4 ICell, I4 K) { + Real TargetRi = 0.0_Real; + if (K == 1) { + TargetRi = 0.1_Real; + } else if (K >= 2) { + TargetRi = 0.4_Real; + } + const Real ZCenter = LayerThickness * (K + 0.5_Real); + const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * + TestN * WindTurbulentScale / 0.25_Real; + const Real DeltaRho = + TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); + Density(ICell, K) = RhoSw + DeltaRho; + }); + + KPPInstance->CriticalRichardson = 0.25_Real; + KPPInstance->StopOBLSearchMult = 1.0_Real; + KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->UseLangmuirCirculation = false; + KPPInstance->UseBLDSmoothing = false; + KPPInstance->UseNonLocalFlux = true; + KPPInstance->UseEnhancedDiffusion = true; + KPPInstance->MatchTechniqueStr = "SimpleShapes"; + + auto runWithSsh = [&](Real Ssh) { + setCoefficientTestGeometry(Ssh); + VCoord->minMaxLayerEdge(Halo::getDefault()); + KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, + UStar, B0, BVF, IceFraction, Wind); + KPPInstance->computeMixingCoefficients(Density, UStar, B0); + }; + + runWithSsh(0.0_Real); + const auto BLDRef = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + const auto BLDIndexRef = + createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + const auto DiffRef = createHostMirrorCopy(KPPInstance->VertDiff); + const auto ViscRef = createHostMirrorCopy(KPPInstance->VertVisc); + const auto NonLocalRef = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + + // Large enough that an uncorrected geoid-referenced depth shifts the OBL + // search across the analytic crossing. + constexpr Real TestSsh = 0.75_Real; + runWithSsh(TestSsh); + const auto BLDShift = createHostMirrorCopy(KPPInstance->BoundaryLayerDepth); + const auto BLDIndexShift = + createHostMirrorCopy(KPPInstance->IndexBoundaryLayerDepth); + const auto DiffShift = createHostMirrorCopy(KPPInstance->VertDiff); + const auto ViscShift = createHostMirrorCopy(KPPInstance->VertVisc); + const auto NonLocalShift = + createHostMirrorCopy(KPPInstance->VertNonLocalFlux); + + int NumErrors = 0; + for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { + // Guard against a degenerate all-zero comparison. + if (!(BLDRef(ICell) > 0.0_Real)) { + ++NumErrors; + continue; + } + if (!isApprox(BLDShift(ICell), BLDRef(ICell), BLDRTol, ATol) || + BLDIndexShift(ICell) != BLDIndexRef(ICell)) { + ++NumErrors; + continue; + } + for (I4 K = 0; K <= NVertLayers; ++K) { + if (!isApprox(DiffShift(ICell, K), DiffRef(ICell, K), BLDRTol, ATol) || + !isApprox(ViscShift(ICell, K), ViscRef(ICell, K), BLDRTol, ATol) || + !isApprox(NonLocalShift(ICell, K), NonLocalRef(ICell, K), BLDRTol, + ATol)) { + ++NumErrors; + break; + } + } + } + + checkResult("sea surface height offset invariance", NumErrors); + KPPInstance->UseBLDSmoothing = true; + setCoefficientTestGeometry(); + VCoord->minMaxLayerEdge(Halo::getDefault()); +} + void testBoundaryLayerEdgeFallbacks() { const HorzMesh *Mesh = HorzMesh::getDefault(); VertCoord *VCoord = VertCoord::getDefault(); @@ -1530,11 +1651,11 @@ void testBoundaryLayerLangmuir() { const Real ZDepth = LayerThickness * (K + 1.0_Real); const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Zeta = KPP::SURFACE_LAYER_EXTENT * ZDepth * VonKar * - TestB0 / (TestUStar * TestUStar * TestUStar); - const Real PhiInv = Kokkos::sqrt(1.0_Real - 16.0_Real * Zeta); - const Real WTurb = VonKar * TestUStar * PhiInv; - const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WTurb / 0.25_Real; + TestB0 / (TestUStar * TestUStar * TestUStar); + const Real PhiInv = Kokkos::sqrt(1.0_Real - 16.0_Real * Zeta); + const Real WTurb = VonKar * TestUStar * PhiInv; + const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * + TestN * WTurb / 0.25_Real; const Real TargetRi = K == 0 ? 0.0_Real : (K == 1 ? 0.1_Real : 0.26_Real); const Real DeltaRho = @@ -1591,15 +1712,15 @@ void testBoundaryLayerLangmuir() { const Real Enhancement = Kokkos::sqrt(3.0_Real); const Real DisabledZeta = KPP::SURFACE_LAYER_EXTENT * ZDepth * VonKar * TestB0 / (TestUStar * TestUStar * TestUStar); - const Real EnabledZeta = DisabledZeta * Enhancement; + const Real EnabledZeta = DisabledZeta * Enhancement; const Real DisabledWTurb = VonKar * TestUStar * Kokkos::sqrt(1.0_Real - 16.0_Real * DisabledZeta); const Real EnabledWTurb = VonKar * TestUStar * Kokkos::sqrt(1.0_Real - 16.0_Real * EnabledZeta); const Real ExpectedDisabledVt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * TestN * DisabledWTurb / 0.25_Real; - const Real ExpectedEnabledVt2 = 1.7_Real * UnresolvedShearConstant * - ZCenter * TestN * EnabledWTurb / 0.25_Real; + const Real ExpectedEnabledVt2 = 1.7_Real * UnresolvedShearConstant * + ZCenter * TestN * EnabledWTurb / 0.25_Real; const Real ExpectedEnabledRi = 0.26_Real * ExpectedDisabledVt2 / ExpectedEnabledVt2; @@ -1690,7 +1811,7 @@ void testBoundaryLayerSmoothing() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WTurb / 0.25_Real; + TestN * WTurb / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1958,6 +2079,7 @@ int main(int argc, char *argv[]) { if (TestGroup == "bld" || TestGroup == "all") { testBoundaryLayerDepth(); testBoundaryLayerNonuniformThickness(); + testSshOffsetInvariance(); testBoundaryLayerEdgeFallbacks(); testBoundaryLayerLangmuir(); testBoundaryLayerSmoothing(); From 0ba9f8b915bdf9dda1f3954545d9c2ea171e14bd Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Tue, 25 Aug 2026 14:28:49 -0700 Subject: [PATCH 28/36] Updates variable names and descriptions --- components/omega/configs/Default.yml | 8 +- components/omega/doc/design/KPPMix.md | 13 +- components/omega/doc/devGuide/HorzMesh.md | 9 + components/omega/doc/devGuide/KPPMix.md | 76 +- components/omega/doc/userGuide/KPPMix.md | 35 +- .../omega/doc/userGuide/TendencyTerms.md | 24 + components/omega/src/ocn/HorzMesh.h | 4 + components/omega/src/ocn/HorzOperators.h | 8 +- components/omega/src/ocn/KPPConstants.h | 405 +++--- components/omega/src/ocn/KPPMix.cpp | 1184 +++++++++-------- components/omega/src/ocn/KPPMix.h | 42 +- components/omega/src/ocn/VertMix.cpp | 2 +- components/omega/test/CMakeLists.txt | 8 + components/omega/test/ocn/KPPMixTest.cpp | 305 ++--- 14 files changed, 1095 insertions(+), 1028 deletions(-) diff --git a/components/omega/configs/Default.yml b/components/omega/configs/Default.yml index af0b4485cb52..f56e7c3228c2 100644 --- a/components/omega/configs/Default.yml +++ b/components/omega/configs/Default.yml @@ -74,6 +74,7 @@ Omega: VelVertMixTendencyEnable: true TracerVertMixTendencyEnable: true TracerNonLocalFluxTendencyEnable: true + TracerNonLocalDiagnosticsEnable: true ManufacturedSolution: WavelengthX: 5.0e6 WavelengthY: 4.33013e6 @@ -103,15 +104,20 @@ Omega: RiSmoothLoops: 2 KPP: Enable: true - UseNonLocalFlux: true UseBLDSmoothing: true + UseLangmuirCirculation: true CriticalBulkRichardsonNumber: 0.25 + StopOBLSearch: 1.0 + SurfaceLayerExtent: 0.1 + # SimpleShapes or MatchBoth MatchTechnique: SimpleShapes InterpType2: LMD94 UseEnhancedDiffusion: true IceFractionThresholdForLangmuir: 0.05 IceFractionThresholdForMinimumOBL: 0.15 MinimumOBLUnderSeaIce: 5.0 + BackgroundViscosity: 1.0e-4 + BackgroundDiffusivity: 1.0e-5 DebugDiagnostics: false IOStreams: HorzMeshIn: diff --git a/components/omega/doc/design/KPPMix.md b/components/omega/doc/design/KPPMix.md index 2541f8c43200..65d79f9df3e5 100644 --- a/components/omega/doc/design/KPPMix.md +++ b/components/omega/doc/design/KPPMix.md @@ -103,6 +103,16 @@ instead additive. In the latter case, the shape function greatly simplifies to For either shape function, enhanced diffusivity can be included near the boundary layer base. This can smooth boundary layer deepening. +The non-local tracer flux uses the same scalar shape function, scaled by the constant +$C_s$ from Eq. (20) of Large et al. (1994) rather than by $h\, w_s$: + +$$ +\gamma_s(\sigma) = C_s\, S_1(\sigma). +$$ + +Because a single $S_1$ drives both, $K_s$ and $\gamma_s$ cannot become inconsistent +with each other when the matching option changes. + ## 4 Design @@ -114,9 +124,8 @@ smooth boundary layer deepening. KPP is configured from the `VertMix: KPP` YAML group. Key parameters include: - `Enable` -- `UseNonLocalFlux` - `CriticalBulkRichardsonNumber` -- `MatchTechnique` +- `MatchTechnique` (`SimpleShapes` or `MatchBoth`) - `InterpType2` - `UseEnhancedDiffusion` - `IceFractionThresholdForLangmuir` diff --git a/components/omega/doc/devGuide/HorzMesh.md b/components/omega/doc/devGuide/HorzMesh.md index c61f45299aaf..4c1596a9ec69 100644 --- a/components/omega/doc/devGuide/HorzMesh.md +++ b/components/omega/doc/devGuide/HorzMesh.md @@ -47,6 +47,15 @@ OMEGA::parallelFor({HMesh->NCellsOwned,HMesh->MaxEdges}, } ``` +`MaxEdges` is read from the mesh file and is therefore only known at run time. +Kernels that need a fixed-size per-thread array indexed by edge should size it +with the compile-time bound instead: +``` +OMEGA::Real Weights[OMEGA::HorzMesh::MaxEdgesBound]; +``` +`MaxEdgesBound` is a single shared upper bound on `MaxEdges`; meshes exceeding +it are rejected. Do not introduce a local copy of this limit. + For member variables that are host arrays, variable names are appended with an `H`. Array variable names not ending in `H` are device arrays. diff --git a/components/omega/doc/devGuide/KPPMix.md b/components/omega/doc/devGuide/KPPMix.md index f0799e3aacc8..367e607c14c8 100644 --- a/components/omega/doc/devGuide/KPPMix.md +++ b/components/omega/doc/devGuide/KPPMix.md @@ -26,6 +26,64 @@ All KPP depths are measured downward from the free surface, not from the geoid. `SshCell(ICell) - GeomZ...(ICell, K)`. Layer thicknesses are differences of geometric heights and are unaffected by the sea surface height. +## Notation for Readers New to KPP + +KPP splits the water column at the ocean boundary layer (OBL) depth `h`, also +called the boundary layer depth (BLD). Inside the OBL, diffusivity and +viscosity are prescribed as a depth profile scaled by `h` and a turbulent +velocity scale; below it, only interior mixing applies. + +| Symbol | Code name | Units | Meaning | +| --- | --- | --- | --- | +| `h` | `HOBL`, `BoundaryLayerDepth` | m | OBL depth below the free surface | +| `d` | `ZDepth`, `ZCenter` | m | depth below the free surface | +| `sigma` | `Sigma` | - | normalized depth in `[-1,0]`, Omega sign convention: 0 at the surface, -1 at the OBL base | +| `sigma_mu` | `SigmaMu` | - | `-Sigma`, in `[0,1]`; the CVMix/Large et al. convention | +| `u*` | `UStar` | m/s | surface friction velocity from wind stress | +| `B_0` | `BuoyFlux` | m^2/s^3 | surface buoyancy flux; **negative is destabilizing** (convection) | +| `L` | `LMoninObukhov` | m | Monin-Obukhov length, `u*^3 / (kappa B_0)` | +| `zeta` | `Zeta` | - | stability coordinate `d/L`; negative is unstable | +| `kappa` | `VonKar` | - | von Karman constant | +| `epsilon` | `SurfaceLayerExtent` | - | surface layer as a fraction of `h` | +| `w_m`, `w_s` | `WMTurb`, `WSTurb` | m/s | turbulent velocity scales for momentum and scalars | +| `Vt^2` | `Vt2`, `UnresolvedShear` | m^2/s^2 | unresolved turbulent shear, Large et al. (1994) Eq. 23 | +| `Ri_b` | `RiBulk`, `BulkRichardsonNumber` | - | bulk Richardson number, Eq. 21 | +| `G(sigma)` | `kppShape*` | - | non-dimensional profile shapes | +| `gamma` | `VertNonLocalFlux` | - | non-local (counter-gradient) tracer flux coefficient | + +The OBL depth is the shallowest `d` at which `Ri_b(d)` reaches the critical +value; the search is done per cell in `computeOBLDepth`, and the crossing depth +is refined by a quadratic fit through the three nearest cell-center `Ri_b` +values. + +### Shape and stability functions + +The non-dimensional functions live in `src/ocn/KPPConstants.h` in namespace +`OMEGA::KPP`. All shape functions take `Sigma` in `[-1,0]` and convert to +`SigmaMu` internally, so callers never flip signs: + +- `kppShapeMomentum`, `kppShapeScalar` -- gradient shapes for viscosity and + diffusivity (`SimpleShapes`) +- `kppShapeMatched` -- gradient shape that additionally reaches a prescribed + value at the OBL base, used by `MatchBoth` +- the non-local flux has no shape function of its own; it reuses whichever + scalar shape is in effect, scaled by `C_s` instead of `h*w_s` +- `kppPhiInvMomentum`, `kppPhiInvScalar` -- **inverse** Monin-Obukhov stability + functions `phi^-1(zeta)`; they already return the reciprocal, so do not + invert them again at the call site + +### Constants and defaults + +`src/ocn/KPPConstants.h` is the single authoritative source for KPP default +values. The runtime-configurable members of `KPPMix` (`CriticalRichardson`, +`StopOBLSearchMult`, `SurfaceLayerExtent`, the two ice-fraction thresholds and +`MinimumOBLUnderSeaIce`) are initialized from those constants rather than from +inline literals, so a default is changed in exactly one place. + +Per-thread edge scratch arrays in `computeOBLDepth` are sized from +`HorzMesh::MaxEdgesBound`, the shared compile-time bound on edges per cell; +KPP does not define its own maximum. + ## Runtime Call Flow ### Tendency coupling @@ -100,14 +158,10 @@ Important keys and class members: - `CriticalBulkRichardsonNumber` -> `CriticalRichardson` - `StopOBLSearch` -> `StopOBLSearchMult` - `SurfaceLayerExtent` -> `SurfaceLayerExtent` -- `MatchTechnique` -> `MatchTechniqueStr` +- `MatchTechnique` -> `MatchTechnique` (a `KPPMatchType` enum, not a string) - `InterpType2` -> `InterpType2Str` - `UseEnhancedDiffusion` -> `UseEnhancedDiffusion` - `UseLangmuirCirculation` -> `UseLangmuirCirculation` -- `UseNonLocalFlux` -> `UseNonLocalFlux` (expert/debugging override only -- - non-local flux is on by default and required for physically correct - tracer transport; this key is intentionally omitted from the User Guide so - it is not disabled by non-experts) - `IceFractionThresholdForLangmuir` -> `IceFractionThresholdForLangmuir` - `IceFractionThresholdForMinimumOBL` -> `IceFractionThresholdForMinimumOBL` - `MinimumOBLUnderSeaIce` -> `MinimumOBLUnderSeaIce` @@ -137,10 +191,14 @@ behavior in experiments. ## Developer Notes -- `MatchGradient` is currently treated as deprecated/unused and remapped to - `SimpleShapes` at init. -- Unsupported `MatchTechnique` values are guarded and fall back to - `SimpleShapes` with a log message. +- `MatchTechnique` accepts only `SimpleShapes` and `MatchBoth`. Any other value + aborts at init rather than falling back, so a typo cannot silently change the + scheme being run. +- `MatchBoth` matches the interior coefficient *value* at the OBL base. The + shape derivative there is zero, so the gradient is not yet matched despite the + name. +- `MatchBoth` needs interior coefficients to be passed in; without them + `ShapeAtBase` is zero and it degenerates exactly to `SimpleShapes`. - When `DebugDiagnostics` is enabled in debug builds, targeted diagnostic logging is available; behavior is compile/build-mode aware. diff --git a/components/omega/doc/userGuide/KPPMix.md b/components/omega/doc/userGuide/KPPMix.md index e01122d09bde..37d3fc82357e 100644 --- a/components/omega/doc/userGuide/KPPMix.md +++ b/components/omega/doc/userGuide/KPPMix.md @@ -54,12 +54,18 @@ VertMix: KPP: Enable: true CriticalBulkRichardsonNumber: 0.25 + StopOBLSearch: 1.0 + SurfaceLayerExtent: 0.1 MatchTechnique: SimpleShapes InterpType2: LMD94 UseEnhancedDiffusion: true + UseBLDSmoothing: true + UseLangmuirCirculation: true IceFractionThresholdForLangmuir: 0.05 IceFractionThresholdForMinimumOBL: 0.15 MinimumOBLUnderSeaIce: 5.0 + BackgroundViscosity: 1.0e-4 + BackgroundDiffusivity: 1.0e-5 DebugDiagnostics: false ``` @@ -69,22 +75,23 @@ VertMix: |---|---|---| | `Enable` | Enable KPP mixing | `true` | | `CriticalBulkRichardsonNumber` | OBL depth criterion threshold | `0.25` | -| `MatchTechnique` | KPP profile matching mode | `SimpleShapes` | +| `StopOBLSearch` | Multiple of the critical Richardson number at which the OBL search stops descending | `1.0` | +| `SurfaceLayerExtent` | Surface layer thickness as a fraction of the OBL depth ($\epsilon$ in Large et al. 1994) | `0.1` | +| `MatchTechnique` | How the K profile meets interior mixing at the OBL base: `SimpleShapes` or `MatchBoth` | `SimpleShapes` | | `InterpType2` | Interpolation type used near OBL matching/base logic | `LMD94` | | `UseEnhancedDiffusion` | Enable enhanced diffusion treatment near OBL base | `true` | +| `UseBLDSmoothing` | Apply horizontal smoothing to the boundary layer depth | `true` | +| `UseLangmuirCirculation` | Apply Langmuir enhancement to the turbulent velocity scale | `true` | | `IceFractionThresholdForLangmuir` | Above this ice fraction, disable Langmuir enhancement | `0.05` | | `IceFractionThresholdForMinimumOBL` | Above this ice fraction, enforce minimum OBL depth | `0.15` | | `MinimumOBLUnderSeaIce` | Minimum OBL depth under sea ice (m) | `5.0` | +| `BackgroundViscosity` | Background viscosity below the OBL (m^2/s) | `1.0e-4` | +| `BackgroundDiffusivity` | Background diffusivity below the OBL (m^2/s) | `1.0e-5` | | `DebugDiagnostics` | Enable additional KPP diagnostics/logging in debug workflows | `false` | -KPP also uses background coefficients from: - -```yaml -VertMix: - Background: - Viscosity: 1.0e-4 - Diffusivity: 1.0e-5 -``` +Note that KPP reads its own `BackgroundViscosity` and `BackgroundDiffusivity` +from the `VertMix: KPP` group; these are separate from the `VertMix: Background` +values used by the other vertical mixing schemes. ## Output and Diagnostics @@ -112,10 +119,10 @@ To diagnose KPP, include KPP fields in output stream contents. Common fields: ## Practical Notes -- Non-local tracer transport is included by default and should be left - enabled; it is required for physically correct KPP boundary layer tracer - fluxes. An expert-only override exists for debugging and is documented in - the [Developer KPP document](../devGuide/KPPMix.md) -- it is not intended - for general use. +- Non-local tracer transport is required for physically correct KPP boundary + layer tracer fluxes. It is applied through the + `Tendencies: TracerNonLocalFluxTendencyEnable` flag, which is on by default + and should be left enabled; see + [Tendency Terms](./TendencyTerms.md). - Use `DebugDiagnostics` sparingly for troubleshooting targeted cases. - When studying sea-ice regions, review minimum-OBL and ice-threshold options. diff --git a/components/omega/doc/userGuide/TendencyTerms.md b/components/omega/doc/userGuide/TendencyTerms.md index c2cd4b3b4327..8293ea9362ac 100644 --- a/components/omega/doc/userGuide/TendencyTerms.md +++ b/components/omega/doc/userGuide/TendencyTerms.md @@ -63,6 +63,30 @@ the currently available tendency terms: | SfcTracerForcingOnCell | SfcTracerForcingTendencyEnable | enable/disable term | SurfaceTracerRestoringOnCell | SurfaceTracerRestoringEnable | enable/disable term +The vertical advection, pressure gradient and vertical mixing tendencies are not +implemented as the functors above, but they are enabled from the same +`Tendencies` section of the configuration file: + +| Parameter | Description +| ------------ | ------------ | +| ThicknessVertAdvTendencyEnable | enable/disable vertical advection of thickness +| VelocityVertAdvTendencyEnable | enable/disable vertical advection of velocity +| TracerVertAdvTendencyEnable | enable/disable vertical advection of tracers +| PressureGradTendencyEnable | enable/disable the pressure gradient tendency +| VelVertMixTendencyEnable | enable/disable vertical mixing of velocity; required when bottom drag uses `Implicit` mode +| TracerVertMixTendencyEnable | enable/disable vertical mixing of tracers +| TracerNonLocalFluxTendencyEnable | enable/disable the KPP non-local tracer flux; see [KPP Boundary Layer Mixing](./KPPMix.md) +| TracerNonLocalDiagnosticsEnable | enable/disable diagnostic output of the non-local flux tendency + +The non-local options are only read when velocity or tracer vertical mixing is +enabled. If omitted, `TracerNonLocalFluxTendencyEnable` defaults to disabled and +`TracerNonLocalDiagnosticsEnable` defaults to enabled. + +Two further flags control tendency sources rather than individual terms: +`UseCustomTendency` enables user-supplied tendencies and +`ManufacturedSolutionTendency` adds the forcing used by the manufactured +solution test case. + ## Second Order Horizontal Advection Algorithm The horizontal advection is done independently within each ocean layer diff --git a/components/omega/src/ocn/HorzMesh.h b/components/omega/src/ocn/HorzMesh.h index 66617f629fc7..77b4f8380bed 100644 --- a/components/omega/src/ocn/HorzMesh.h +++ b/components/omega/src/ocn/HorzMesh.h @@ -107,6 +107,10 @@ class HorzMesh { I4 MaxEdges2; ///< Max number of edges around a cell x2 I4 NEdgesGlobal; ///< Total number of edges in global non-decomposed mesh + /// Compile-time upper bound on MaxEdges, for sizing on-stack (per-thread) + /// arrays in device kernels. Meshes with more edges per cell are rejected. + static constexpr I4 MaxEdgesBound = 10; + Array1DI4 NVerticesHalo; ///< num cells owned+halo for halo layer HostArray1DI4 NVerticesHaloH; ///< num cells owned+halo for halo layer I4 NVerticesOwned; ///< Number of vertices owned by this task diff --git a/components/omega/src/ocn/HorzOperators.h b/components/omega/src/ocn/HorzOperators.h index 70260564d873..6f44fbb3d568 100644 --- a/components/omega/src/ocn/HorzOperators.h +++ b/components/omega/src/ocn/HorzOperators.h @@ -266,8 +266,8 @@ class SecondDerivativeOnCell { private: // MaxMaxEdges is used to dimention arrays that include ICell and the // neighbor cells, so it is technically one more than MaxEdges. - static const I4 MaxMaxEdges = 10; - static constexpr R8 Pii = 3.141592653589793_Real; + static constexpr I4 MaxMaxEdges = HorzMesh::MaxEdgesBound; + static constexpr R8 Pii = 3.141592653589793_Real; const bool OnSphere; const I4 NCellsAll; @@ -372,8 +372,8 @@ class SecondDerivativeOnCell { // angles from cell center to neighbor centers (thetav) const Real Thetav = sphere_angle(XC[0], YC[0], ZC[0], XC[Ip1], YC[Ip1], ZC[Ip1], XC[Ip2], YC[Ip2], ZC[Ip2]); - Real Dl_sphere = sphereRadius * arc_length(XC[0], YC[0], ZC[0], - XC[Ip1], YC[Ip1], ZC[Ip1]); + Real Dl_sphere = sphereRadius * arc_length(XC[0], YC[0], ZC[0], + XC[Ip1], YC[Ip1], ZC[Ip1]); Dl_sphere /= length_scale; // Thetat = 0. this defines the x direction, diff --git a/components/omega/src/ocn/KPPConstants.h b/components/omega/src/ocn/KPPConstants.h index 80de93ae0e0b..71d10f0f8c78 100644 --- a/components/omega/src/ocn/KPPConstants.h +++ b/components/omega/src/ocn/KPPConstants.h @@ -16,57 +16,42 @@ namespace OMEGA::KPP { -// ========================================================================== -// Physical Parameters for KPP -// ========================================================================== - -/// Critical bulk Richardson number for defining OBL (Large et al. 1994) -constexpr Real RICRIT = 0.3; - -/// Parameter for smoothing velocity shear profiles -constexpr Real ZETA_M_SCALE = VonKar; // Momentum scale (normalized) -constexpr Real ZETA_S_SCALE = 0.16; // Tracer/salt scale -constexpr Real ZETA_T_SCALE = 0.16; // Temperature scale - // ========================================================================== // Monin-Obukhov stability function parameters (Large et al. 1994, App. B) -// Transition thresholds between weakly and strongly unstable regimes +// +// The stability coordinate is zeta = d/L, where d is depth below the surface +// and L is the Monin-Obukhov length. zeta > 0 is stable (surface warming or +// salinification), zeta < 0 is unstable (convective). The values below set +// where the weakly-unstable branch hands off to the strongly-unstable branch. // ========================================================================== /// Transition zeta for momentum: below this value, strongly-unstable formula /// is used. Default: -0.2 (CVMix default). -constexpr Real ZETA_M = -0.2_Real; +constexpr Real ZetaM = -0.2_Real; /// Transition zeta for scalars: below this value, strongly-unstable formula /// is used. Default: -1.0 (CVMix default). -constexpr Real ZETA_S = -1.0_Real; +constexpr Real ZetaS = -1.0_Real; -/// Derived constants for phi_m^{-1} strongly-unstable branch (momentum). -/// a_m = (1-16*ZETA_M)^{-0.25} * (1 - 4*ZETA_M) -constexpr Real A_MO_M = 1.2573615702_Real; -/// c_m = (1-16*ZETA_M)^{-0.25} * 12 -constexpr Real C_MO_M = 8.3824104679_Real; +// The four constants below are fixed by requiring the strongly-unstable +// branch to match the weakly-unstable branch in value at the transition zeta. -/// Derived constants for phi_s^{-1} strongly-unstable branch (scalar). -/// a_s = sqrt(1-16*ZETA_S) * (1 + 8*ZETA_S) (can be negative) -constexpr Real A_MO_S = -28.8617393793_Real; -/// c_s = 24 * sqrt(1-16*ZETA_S) -constexpr Real C_MO_S = 98.9545350148_Real; +/// a_m = (1-16*ZetaM)^{-0.25} * (1 - 4*ZetaM) +constexpr Real AMoM = 1.2573615702_Real; +/// c_m = (1-16*ZetaM)^{-0.25} * 12 +constexpr Real CMoM = 8.3824104679_Real; -/// Surface mixing coefficients -constexpr Real HUON = 0.03; // Surface momentum mixing parameter -constexpr Real BD = 1.0; // Buoyancy parameter (dimensionless) -constexpr Real C1 = 0.112; // Langmuir circulation parameter +/// a_s = sqrt(1-16*ZetaS) * (1 + 8*ZetaS) (can be negative) +constexpr Real AMoS = -28.8617393793_Real; +/// c_s = 24 * sqrt(1-16*ZetaS) +constexpr Real CMoS = 98.9545350148_Real; -/// Langmuir enhancement factor parameters -constexpr Real PEC_LANGMUIR = 0.5; // Peclet number for Langmuir +/// Surface value of the momentum shape function (dimensionless) +constexpr Real HuOn = 0.03; -/// Minimum/maximum bounds on friction velocity values -constexpr Real MIN_USTAR = 1.0e-4; // Minimum friction velocity (m/s) -constexpr Real MAX_USTAR = 1.0; // Upper limit to clamp rare extremes - -/// Maximum vertical levels (for static allocations if needed) -constexpr I4 NLEV_MAX = 500; +/// Floor on friction velocity (m/s). Keeps the turbulent velocity scales and +/// the Monin-Obukhov length finite in near-calm conditions. +constexpr Real MinUStar = 1.0e-4; // ========================================================================== // OBL Depth Computation Parameters @@ -74,116 +59,70 @@ constexpr I4 NLEV_MAX = 500; /// Safety multiplier for OBL search (prevents searching too deep) /// Default: 1.0 (search to 1.0 * Ri_crit threshold) -constexpr Real STOP_OBL_SEARCH_MULT = 1.0; - -/// Minimum OBL depth (m) -constexpr Real MIN_OBL_DEPTH = 2.0; +constexpr Real StopOBLSearchMult = 1.0; -/// Minimum OBL under sea ice (m) when ice fraction > 0.15 -constexpr Real MIN_OBL_UNDER_ICE = 5.0; +/// Minimum OBL depth under sea ice (m), applied above IceSuppressThresh +constexpr Real MinOBLUnderIce = 5.0; -/// Ice fraction threshold below which OBL is fully computed -constexpr Real ICE_FRACTION_THRESHOLD = 0.05; +/// Ice fraction above which Langmuir enhancement is disabled +constexpr Real IceFracThresh = 0.05; -/// Ice fraction for triggering minimum OBL enforcement -constexpr Real ICE_SUPPRESSION_THRESHOLD = 0.15; +/// Ice fraction above which the minimum OBL depth is enforced +constexpr Real IceSuppressThresh = 0.15; // ========================================================================== // Surface Layer Parameters // ========================================================================== -/// Surface layer extent (fraction of OBL depth) -/// Used for averaging turbulent scales near surface -constexpr Real SURFACE_LAYER_EXTENT = 0.1; +/// Surface layer extent as a fraction of the trial OBL depth (epsilon in +/// Large et al. 1994). Reference values entering the bulk Richardson number +/// are averaged over the top SurfaceLayerExtent * d of the column. +constexpr Real SurfaceLayerExtent = 0.1; -/// Number of smoothing passes for Richardson number (reduce noise) -constexpr I4 RI_SMOOTH_LOOPS = 2; - -/// Prandtl number for converting momentum viscosity to tracer diffusivity -constexpr Real PRANDTL_NUMBER = 1.0; +/// Empirical convective velocity coefficient in the turbulent velocity scale +/// w_s = (u*^3 + ConvectiveVelFac * max(-B_0,0) * h)^(1/3) +constexpr Real ConvectiveVelFac = 0.35; // ========================================================================== -// KPP Profile Functions +// KPP Shape and Stability Functions +// +// Vertical position inside the OBL is expressed two ways: +// Sigma in [-1,0], the Omega convention: 0 at the surface, -1 at the +// OBL base (it follows the sign of the z coordinate). +// SigmaMu in [ 0,1], the CVMix/Large et al. convention: SigmaMu = -Sigma, +// so 0 at the surface and 1 at the OBL base. +// The shape functions below take Sigma and convert internally, so callers +// never need to flip signs. // ========================================================================== -/// @brief G(sigma) - Non-local flux profile function -/// Non-zero only within the OBL. Applied to surface tracer fluxes. -/// REFERENCES: Large et al. (1994) Eq. (12)-(13), Large et al. (1997) -/// -/// @param sigma Normalized vertical position (-z/h), 0 at surface, -1 at base -/// @return G(sigma) dimensionless profile value -KOKKOS_INLINE_FUNCTION -Real KPPProfileG(Real sigma) { - // Omega uses sigma in [-1,0]. Convert to CVMix sigma_mu in [0,1] - // where sigma_mu=0 at surface and sigma_mu=1 at OBL base. - sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); - - const Real sigma_mu = -sigma; - return sigma_mu * (1.0 - sigma_mu) * (1.0 - sigma_mu); -} - -/// @brief G_pnl(sigma) - Parabolic non-local flux profile -/// Used only for non-local tracer flux when matching option is -/// "ParabolicNonLocal". This must NOT be used for KPP viscosity/diffusivity. -/// -/// @param sigma Normalized vertical position (-z/h), 0 at surface, -1 at base -/// @return Dimensionless non-local profile value -KOKKOS_INLINE_FUNCTION -Real KPPProfileGParabolicNonLocal(Real sigma) { - sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); - - // Parabolic non-local option in CVMix is (1-sigma_mu)^2. - const Real sigma_mu = -sigma; - const Real one_minus = 1.0 - sigma_mu; - return one_minus * one_minus; -} - -/// @brief G_matchboth(sigma) - Cubic LMD-style non-local profile -/// Used for MatchBoth to distinguish it from SimpleShapes without relying on -/// external CVMix calls. In sigma_mu coordinates this is: -/// G = (1 - sigma_mu)^2 * (1 + 2*sigma_mu) -/// where sigma_mu in [0,1] is 0 at surface and 1 at OBL base. -/// -/// @param sigma Normalized vertical position (-z/h), 0 at surface, -1 at base -/// @return Dimensionless non-local profile value -KOKKOS_INLINE_FUNCTION -Real KPPProfileGMatchBoth(Real sigma) { - sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); - - const Real sigma_mu = -sigma; - const Real one_minus = 1.0 - sigma_mu; - return one_minus * one_minus * (1.0 + 2.0 * sigma_mu); -} - -/// @brief M1(sigma) - Momentum mixing profile function -/// Multiplies friction velocity and turbulent velocity scale +/// @brief Momentum gradient shape function, multiplied by h and the turbulent +/// velocity scale to give the KPP viscosity: Kx = h * w_m * G(sigma). /// REFERENCES: Large et al. (1994) Eq. (11) /// -/// @param sigma Normalized vertical position (-z/h) -/// @return K*w_s profile multiplier (dimensionless) +/// @param Sigma Normalized vertical position (-z/h) +/// @return Dimensionless shape value KOKKOS_INLINE_FUNCTION -Real KPPProfileM1(Real sigma) { - // CVMix simple gradient shape: sigma_mu*(1-sigma_mu)^2. - sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); +Real kppShapeMomentum(Real Sigma) { + Sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, Sigma)); - const Real sigma_mu = -sigma; - return sigma_mu * (1.0 - sigma_mu) * (1.0 - sigma_mu); + const Real SigmaMu = -Sigma; + return SigmaMu * (1.0 - SigmaMu) * (1.0 - SigmaMu); } -/// @brief Matched KPP gradient profile shape. +/// @brief Matched KPP gradient shape function. /// -/// Uses the SimpleShapes gradient profile plus a smooth correction that is +/// Uses the SimpleShapes gradient shape plus a smooth correction that is /// zero at the surface and equals ShapeAtBase at the OBL base. This lets /// MatchBoth profiles meet pre-existing interior mixing at the BLD base while /// preserving SimpleShapes behavior when ShapeAtBase is zero. KOKKOS_INLINE_FUNCTION -Real KPPProfileMatched(Real sigma, Real ShapeAtBase) { - sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); +Real kppShapeMatched(Real Sigma, Real ShapeAtBase) { + Sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, Sigma)); - const Real sigma_mu = -sigma; - const Real simple = sigma_mu * (1.0 - sigma_mu) * (1.0 - sigma_mu); - const Real smooth = sigma_mu * sigma_mu * (3.0 - 2.0 * sigma_mu); - return simple + ShapeAtBase * smooth; + const Real SigmaMu = -Sigma; + const Real Simple = SigmaMu * (1.0 - SigmaMu) * (1.0 - SigmaMu); + const Real Smooth = SigmaMu * SigmaMu * (3.0 - 2.0 * SigmaMu); + return Simple + ShapeAtBase * Smooth; } /// @brief phi_m^{-1}(zeta) - Inverse momentum Monin-Obukhov stability function @@ -191,56 +130,57 @@ Real KPPProfileMatched(Real sigma, Real ShapeAtBase) { /// momentum velocity scale: w_m = kappa * u* * phi_m^{-1}(zeta) /// Three-regime formulation per Large et al. (1994) Appendix B and CVMix. /// -/// @param zeta Monin-Obukhov stability coordinate (dimensionless) +/// @param Zeta Monin-Obukhov stability coordinate d/L (dimensionless) /// @return phi_m^{-1} (dimensionless, > 0) KOKKOS_INLINE_FUNCTION -Real KPPProfileM2(Real zeta) { - if (zeta >= 0.0_Real) { +Real kppPhiInvMomentum(Real Zeta) { + if (Zeta >= 0.0_Real) { // Stable regime - return 1.0_Real / (1.0_Real + 5.0_Real * zeta); - } else if (zeta >= ZETA_M) { + return 1.0_Real / (1.0_Real + 5.0_Real * Zeta); + } else if (Zeta >= ZetaM) { // Weakly unstable: (1 - 16*zeta)^{1/4} - return Kokkos::pow(1.0_Real - 16.0_Real * zeta, 0.25_Real); + return Kokkos::pow(1.0_Real - 16.0_Real * Zeta, 0.25_Real); } else { - // Strongly unstable: (a_m - c_m*zeta)^{1/3} - return Kokkos::pow(A_MO_M - C_MO_M * zeta, 1.0_Real / 3.0_Real); + // Strongly unstable (convective): (a_m - c_m*zeta)^{1/3} + return Kokkos::pow(AMoM - CMoM * Zeta, 1.0_Real / 3.0_Real); } } -/// @brief S1(sigma) - Tracer/scalar mixing profile function -/// Similar to momentum but computed separately for tracers -/// REFERENCES: Large et al. (1994) Eq. (11) +/// @brief Scalar gradient shape function, multiplied by h and the turbulent +/// velocity scale to give the KPP diffusivity: Kx = h * w_s * G(sigma). +/// The non-local flux reuses this same shape, scaled by C_s instead of h*w_s. +/// REFERENCES: Large et al. (1994) Eq. (11), Eq. (12)-(13), Large et al. (1997) /// -/// @param sigma Normalized vertical position -/// @return K*w_s profile multiplier for tracers (dimensionless) +/// @param Sigma Normalized vertical position (-z/h) +/// @return Dimensionless shape value KOKKOS_INLINE_FUNCTION -Real KPPProfileS1(Real sigma) { - // CVMix simple gradient shape for tracers: sigma_mu*(1-sigma_mu)^2. - sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, sigma)); +Real kppShapeScalar(Real Sigma) { + Sigma = Kokkos::fmax(-1.0, Kokkos::fmin(0.0, Sigma)); - const Real sigma_mu = -sigma; - return sigma_mu * (1.0 - sigma_mu) * (1.0 - sigma_mu); + const Real SigmaMu = -Sigma; + return SigmaMu * (1.0 - SigmaMu) * (1.0 - SigmaMu); } /// @brief phi_s^{-1}(zeta) - Inverse scalar Monin-Obukhov stability function /// Multiplied by von Karman constant and friction velocity to give turbulent /// scalar velocity scale: w_s = kappa * u* * phi_s^{-1}(zeta) /// Three-regime formulation per Large et al. (1994) Appendix B and CVMix. -/// Note: scalar and momentum exponents differ in the weakly-unstable regime. +/// Scalars mix more efficiently than momentum in unstable conditions, which +/// is why the weakly-unstable exponent is 1/2 here and 1/4 for momentum. /// -/// @param zeta Monin-Obukhov stability coordinate (dimensionless) +/// @param Zeta Monin-Obukhov stability coordinate d/L (dimensionless) /// @return phi_s^{-1} (dimensionless, > 0) KOKKOS_INLINE_FUNCTION -Real KPPProfileS2(Real zeta) { - if (zeta >= 0.0_Real) { +Real kppPhiInvScalar(Real Zeta) { + if (Zeta >= 0.0_Real) { // Stable regime - return 1.0_Real / (1.0_Real + 5.0_Real * zeta); - } else if (zeta >= ZETA_S) { - // Weakly unstable: (1 - 16*zeta)^{1/2} (scalar uses 1/2, not 1/4) - return Kokkos::sqrt(1.0_Real - 16.0_Real * zeta); + return 1.0_Real / (1.0_Real + 5.0_Real * Zeta); + } else if (Zeta >= ZetaS) { + // Weakly unstable: (1 - 16*zeta)^{1/2} + return Kokkos::sqrt(1.0_Real - 16.0_Real * Zeta); } else { - // Strongly unstable: (a_s - c_s*zeta)^{1/3} - return Kokkos::pow(A_MO_S - C_MO_S * zeta, 1.0_Real / 3.0_Real); + // Strongly unstable (convective): (a_s - c_s*zeta)^{1/3} + return Kokkos::pow(AMoS - CMoS * Zeta, 1.0_Real / 3.0_Real); } } @@ -248,86 +188,84 @@ Real KPPProfileS2(Real zeta) { /// Sets surface boundary condition for momentum mixing /// REFERENCES: Large et al. (1994) /// -/// @param sigma Normalized vertical position -/// @return Normalized profile value +/// @param Sigma Normalized vertical position +/// @return Normalized shape value KOKKOS_INLINE_FUNCTION -Real KPPHu(Real sigma) { - // At sigma=0 (surface), should return HUON=0.03 - // Simple linear decay: Hu(sigma) = HUON * (1 + sigma) - return HUON * (1.0 + sigma); +Real kppSurfaceMomentumScale(Real Sigma) { + // Linear decay from HuOn at the surface to zero at the OBL base + return HuOn * (1.0 + Sigma); } // ========================================================================== // Langmuir Enhancement Factor (Theory-based Wave Model) +// +// Langmuir circulations are wind-and-wave driven counter-rotating vortices +// that deepen and strengthen boundary layer mixing beyond what wind stress +// alone produces. Their strength is measured by the turbulent Langmuir +// number La = sqrt(u* / u_stokes): small La means wave forcing dominates. +// With no wave model coupled, the Stokes drift is estimated from the 10 m +// wind following Li et al. (2016). // ========================================================================== -/// @brief Estimate Stokes drift velocity scale from wind speed -/// Theory-based approach (no active wave data needed) +/// @brief Estimate the surface-layer Stokes drift velocity scale from wind /// REFERENCES: Li et al. 2016, cvmix_kpp_ustokes_SL_model /// -/// @param wind10m Wind speed at 10 m height (m/s) -/// @param h_bl Boundary layer depth (m) +/// TODO: this is a placeholder empirical fit, not the full surface-layer +/// averaged Stokes drift of the reference model, which needs the wave +/// spectrum (or a wave component) to evaluate +/// u_s,BL = (U10/362) * sqrt(2*alpha*Cd) * lambda/HBL +/// with alpha = 0.84, Cd ~ 1.2e-3 and lambda ~ 2*pi*g/omega^2. Until those +/// inputs are available, HBL is accepted but unused. +/// +/// @param Wind10m Wind speed at 10 m height (m/s) +/// @param HBL Boundary layer depth (m) /// @return Stokes drift velocity scale (m/s) KOKKOS_INLINE_FUNCTION -Real EstokesSLModel(Real wind10m, Real h_bl) { - // u_s,BL = (u_10/362.0) * sqrt(2*alpha*cd) * lambda/h_bl - // Simplified: alpha=0.84, cd ~ 1.2e-3, lambda ~ 2pi*g/w^2 - wind10m = Kokkos::fmax(0.0, wind10m); - h_bl = Kokkos::fmax(1.0, h_bl); +Real estimateStokesDriftSL(Real Wind10m, Real HBL) { + Wind10m = Kokkos::fmax(0.0, Wind10m); + HBL = Kokkos::fmax(1.0, HBL); - // Approximate relation from Li et al. - const Real C_drag = 1.2e-3; - const Real alpha_wave = 0.84; + const Real UStokes = 0.016 * Wind10m; - // Typical Stokes drift scale - Real u_s = 0.016 * wind10m; // Simplified; sqrt(2*alpha*C_d)*(wind/g) - - return Kokkos::fmax(0.0, u_s); + return Kokkos::fmax(0.0, UStokes); } -/// @brief Langmuir number from friction velocity and Stokes drift +/// @brief Turbulent Langmuir number from friction velocity and Stokes drift /// REFERENCES: Large et al. 2015 Eq. 6 /// -/// @param u_star Friction velocity (m/s) -/// @param u_stokes Stokes drift at surface (m/s) +/// @param UStar Friction velocity (m/s) +/// @param UStokes Stokes drift at surface (m/s) /// @return Langmuir number (dimensionless) KOKKOS_INLINE_FUNCTION -Real ComputeLangmuirNumber(Real u_star, Real u_stokes) { - u_star = Kokkos::fmax(MIN_USTAR, u_star); - u_stokes = Kokkos::fmax(1.0e-8, u_stokes); +Real computeLangmuirNumber(Real UStar, Real UStokes) { + UStar = Kokkos::fmax(MinUStar, UStar); + UStokes = Kokkos::fmax(1.0e-8, UStokes); - // La = sqrt(u_star / u_stokes) - return Kokkos::sqrt(u_star / u_stokes); + return Kokkos::sqrt(UStar / UStokes); } -/// @brief Langmuir enhancement factor for KPP from wind -/// Theory-based approach: depends on Langmuir number +/// @brief Langmuir enhancement factor applied to the KPP velocity scales /// REFERENCES: Li et al. (2016) Eq. 1-3 /// -/// @param wind10m Wind speed at 10 m (m/s) -/// @param u_star Friction velocity (m/s) -/// @param h_bl Boundary layer depth (m) +/// @param Wind10m Wind speed at 10 m (m/s) +/// @param UStar Friction velocity (m/s) +/// @param HBL Boundary layer depth (m) /// @return Enhancement factor R_L (dimensionless, > 1.0 enhances mixing) KOKKOS_INLINE_FUNCTION -Real ComputeEnhancementFactor(Real wind10m, Real u_star, Real h_bl) { - u_star = Kokkos::fmax(MIN_USTAR, u_star); - wind10m = Kokkos::fmax(0.0, wind10m); - h_bl = Kokkos::fmax(1.0, h_bl); - - // Estimate Stokes drift from wind - Real u_stokes = EstokesSLModel(wind10m, h_bl); +Real computeLangmuirEnhancement(Real Wind10m, Real UStar, Real HBL) { + UStar = Kokkos::fmax(MinUStar, UStar); + Wind10m = Kokkos::fmax(0.0, Wind10m); + HBL = Kokkos::fmax(1.0, HBL); - // Compute Langmuir number - Real la = ComputeLangmuirNumber(u_star, u_stokes); + const Real UStokes = estimateStokesDriftSL(Wind10m, HBL); + const Real La = computeLangmuirNumber(UStar, UStokes); - // Enhancement factor: R_L = sqrt(1 + 0.5 * (u_stokes/u_star)^2) - // Alternative form based on Langmuir number: - // R_L = sqrt(1 + 0.5 / La^2) for La > 0.5 - Real la_inv = 1.0 / Kokkos::fmax(0.5, la); - Real r_l = Kokkos::sqrt(1.0 + 0.5 * la_inv * la_inv); + // R_L = sqrt(1 + 0.5/La^2); La is floored at 0.5 so that the weak-wave + // limit returns the unenhanced scales rather than diverging. + const Real LaInv = 1.0 / Kokkos::fmax(0.5, La); + const Real RL = Kokkos::sqrt(1.0 + 0.5 * LaInv * LaInv); - // Clamp to reasonable range [1.0, 2.0] - return Kokkos::fmin(2.0, Kokkos::fmax(1.0, r_l)); + return Kokkos::fmin(2.0, Kokkos::fmax(1.0, RL)); } // ========================================================================== @@ -337,71 +275,66 @@ Real ComputeEnhancementFactor(Real wind10m, Real u_star, Real h_bl) { /// @brief Check if a point should be suppressed (e.g., under ice) /// Sets OBL to minimum if ice coverage or land ice present /// -/// @param ice_fraction Sea ice coverage (0-1) -/// @param land_ice_mask Land ice mask (0=ocean, non-zero=ice) +/// @param IceFrac Sea ice coverage (0-1) +/// @param LandIceMask Land ice mask (0=ocean, non-zero=ice) /// @return True if suppression applies KOKKOS_INLINE_FUNCTION -bool ShouldSuppressOBL(Real ice_fraction, I4 land_ice_mask) { - return (land_ice_mask != 0) || (ice_fraction > ICE_SUPPRESSION_THRESHOLD); +bool shouldSuppressOBL(Real IceFrac, I4 LandIceMask) { + return (LandIceMask != 0) || (IceFrac > IceSuppressThresh); } /// @brief Apply OBL depth constraints based on column properties /// -/// @param h_obl Current OBL depth (m) -/// @param layer_thickness Surface layer thickness (m) -/// @param water_depth Total water depth (m) -/// @param ice_fraction Sea ice coverage (0-1) +/// @param HOBL Current OBL depth (m) +/// @param LayerThickness Surface layer thickness (m) +/// @param WaterDepth Total water depth (m) +/// @param IceFrac Sea ice coverage (0-1) /// @return Constrained OBL depth (m) KOKKOS_INLINE_FUNCTION -Real ConstrainOBLDepth(Real h_obl, Real layer_thickness, Real water_depth, - Real ice_fraction) { +Real constrainOBLDepth(Real HOBL, Real LayerThickness, Real WaterDepth, + Real IceFrac) { // Lower bound: at least half the surface layer thickness - h_obl = Kokkos::fmax(h_obl, layer_thickness * 0.5); + HOBL = Kokkos::fmax(HOBL, LayerThickness * 0.5); // Enforce minimum under ice - if (ice_fraction > ICE_SUPPRESSION_THRESHOLD) { - h_obl = Kokkos::fmax(h_obl, MIN_OBL_UNDER_ICE); + if (IceFrac > IceSuppressThresh) { + HOBL = Kokkos::fmax(HOBL, MinOBLUnderIce); } // Upper bound: cannot exceed water depth - h_obl = Kokkos::fmin(h_obl, water_depth * 0.95); + HOBL = Kokkos::fmin(HOBL, WaterDepth * 0.95); - return h_obl; + return HOBL; } // ========================================================================== // Turbulent Velocity Scale Computation // ========================================================================== -/// @brief Compute turbulent velocity scale (w_s) -/// Combined velocity scale for momentum and buoyancy +/// @brief Compute the depth-independent turbulent velocity scale +/// Blends the shear-driven scale u* with the convective scale so that the +/// result stays finite in both the wind-driven and free-convection limits. /// REFERENCES: Large et al. (1994) Eq. (9)-(10) /// -/// @param u_star Friction velocity (m/s) -/// @param b0 Surface buoyancy flux (m²/s³) -/// @param h_obl Boundary layer depth (m) +/// @param UStar Friction velocity (m/s) +/// @param BuoyFlux Surface buoyancy flux (m^2/s^3), negative when convective +/// @param HOBL Boundary layer depth (m) /// @return Turbulent velocity scale w_s (m/s) KOKKOS_INLINE_FUNCTION -Real ComputeTurbulentVelocityScale(Real u_star, Real b0, Real h_obl) { - u_star = Kokkos::fmax(0.0_Real, u_star); - h_obl = Kokkos::fmax(0.0_Real, h_obl); - - // w_s = (u_star^3 + 0.35 * b0 * h_obl)^(1/3) - // Note: v_t = 0.35 in KPP (empirical constant) - const Real v_t = 0.35; +Real computeTurbVelocityScale(Real UStar, Real BuoyFlux, Real HOBL) { + UStar = Kokkos::fmax(0.0_Real, UStar); + HOBL = Kokkos::fmax(0.0_Real, HOBL); // Momentum contribution - const Real w_m = u_star * u_star * u_star; + const Real WMom = UStar * UStar * UStar; // Buoyancy contribution for unstable (cooling/densifying) forcing. - // In this sign convention, free convection corresponds to b0 < 0. - const Real w_b = v_t * Kokkos::fmax(0.0_Real, -b0) * h_obl; - - // Combined scale - const Real w_s = - Kokkos::pow(Kokkos::fmax(0.0_Real, w_m + w_b), 1.0_Real / 3.0_Real); + // In this sign convention, free convection corresponds to BuoyFlux < 0. + const Real WBuoy = + ConvectiveVelFac * Kokkos::fmax(0.0_Real, -BuoyFlux) * HOBL; - return w_s; + return Kokkos::pow(Kokkos::fmax(0.0_Real, WMom + WBuoy), + 1.0_Real / 3.0_Real); } } // namespace OMEGA::KPP diff --git a/components/omega/src/ocn/KPPMix.cpp b/components/omega/src/ocn/KPPMix.cpp index 9687b271e8d8..02367e3bd086 100644 --- a/components/omega/src/ocn/KPPMix.cpp +++ b/components/omega/src/ocn/KPPMix.cpp @@ -25,34 +25,59 @@ namespace OMEGA { // Singleton instance KPPMix *KPPMix::Instance = nullptr; +namespace { + +bool matchTypeFromString(const std::string &Name, KPPMatchType &Type) { + if (Name == "SimpleShapes") { + Type = KPPMatchType::SimpleShapes; + return true; + } + if (Name == "MatchBoth") { + Type = KPPMatchType::MatchBoth; + return true; + } + return false; +} + +const char *matchTypeName(KPPMatchType Type) { + switch (Type) { + case KPPMatchType::MatchBoth: + return "MatchBoth"; + default: + return "SimpleShapes"; + } +} + +} // anonymous namespace + /// Constructor for KPPMix -KPPMix::KPPMix(const std::string &Name_in, const HorzMesh *Mesh_in, - const VertCoord *VCoord_in) - : Name(Name_in), Mesh(Mesh_in), VCoord(VCoord_in) { +KPPMix::KPPMix(const std::string &InName, const HorzMesh *InMesh, + const VertCoord *InVCoord) + : Name(InName), Mesh(InMesh), VCoord(InVCoord) { // Allocate output arrays - VertDiff = Array2DReal("VertDiff", Mesh->NCellsAll, VCoord->NVertLayers + 1); - VertVisc = Array2DReal("VertVisc", Mesh->NCellsAll, VCoord->NVertLayers + 1); - BoundaryLayerDepth = Array1DReal("BoundaryLayerDepth", Mesh->NCellsAll); + VertDiff = Array2DReal("VertDiff", Mesh->NCellsSize, VCoord->NVertLayersP1); + VertVisc = Array2DReal("VertVisc", Mesh->NCellsSize, VCoord->NVertLayersP1); + BoundaryLayerDepth = Array1DReal("BoundaryLayerDepth", Mesh->NCellsSize); IndexBoundaryLayerDepth = - Array1DI4("IndexBoundaryLayerDepth", Mesh->NCellsAll); - VertNonLocalFlux = Array2DReal("VertNonLocalFlux", Mesh->NCellsAll, - VCoord->NVertLayers + 1); - BulkRichardsonNumber = Array2DReal("BulkRichardsonNumber", Mesh->NCellsAll, - VCoord->NVertLayers + 1); - BulkRichardsonShear = Array2DReal("BulkRichardsonShear", Mesh->NCellsAll, - VCoord->NVertLayers + 1); + Array1DI4("IndexBoundaryLayerDepth", Mesh->NCellsSize); + VertNonLocalFlux = + Array2DReal("VertNonLocalFlux", Mesh->NCellsSize, VCoord->NVertLayersP1); + BulkRichardsonNumber = Array2DReal("BulkRichardsonNumber", Mesh->NCellsSize, + VCoord->NVertLayersP1); + BulkRichardsonShear = Array2DReal("BulkRichardsonShear", Mesh->NCellsSize, + VCoord->NVertLayersP1); UnresolvedShear = - Array2DReal("UnresolvedShear", Mesh->NCellsAll, VCoord->NVertLayers + 1); + Array2DReal("UnresolvedShear", Mesh->NCellsSize, VCoord->NVertLayersP1); BuoyancyJump = - Array2DReal("BuoyancyJump", Mesh->NCellsAll, VCoord->NVertLayers + 1); + Array2DReal("BuoyancyJump", Mesh->NCellsSize, VCoord->NVertLayersP1); TurbulentVelocityScale = Array2DReal( - "TurbulentVelocityScale", Mesh->NCellsAll, VCoord->NVertLayers + 1); + "TurbulentVelocityScale", Mesh->NCellsSize, VCoord->NVertLayersP1); PotentialDensity = - Array2DReal("PotentialDensity", Mesh->NCellsAll, VCoord->NVertLayers); + Array2DReal("PotentialDensity", Mesh->NCellsSize, VCoord->NVertLayers); SurfaceFrictionVelocity = - Array1DReal("SurfaceFrictionVelocity", Mesh->NCellsAll); - SurfaceBuoyancyFlux = Array1DReal("SurfaceBuoyancyFlux", Mesh->NCellsAll); + Array1DReal("SurfaceFrictionVelocity", Mesh->NCellsSize); + SurfaceBuoyancyFlux = Array1DReal("SurfaceBuoyancyFlux", Mesh->NCellsSize); // Set field names VertDiffFldName = "VertDiff"; @@ -125,9 +150,9 @@ void KPPMix::init() { } // Read KPP parameters - bool enable = true; - Err += KPPConfig.get("Enable", enable); - DefKPPMix->Enabled = enable; + bool Enable = true; + Err += KPPConfig.get("Enable", Enable); + DefKPPMix->Enabled = Enable; Err += KPPConfig.get("CriticalBulkRichardsonNumber", DefKPPMix->CriticalRichardson); @@ -135,8 +160,8 @@ void KPPMix::init() { Err += KPPConfig.get("SurfaceLayerExtent", DefKPPMix->SurfaceLayerExtent); // KPP matching/profile semantics. - Error MatchErr = - KPPConfig.get("MatchTechnique", DefKPPMix->MatchTechniqueStr); + std::string MatchStr = "SimpleShapes"; + Error MatchErr = KPPConfig.get("MatchTechnique", MatchStr); if (!MatchErr.isSuccess()) { MatchErr.reset(); } @@ -155,25 +180,15 @@ void KPPMix::init() { BLDSmoothErr.reset(); } - // Keep active options focused on what is used in OMEGA. - if (DefKPPMix->MatchTechniqueStr == "MatchGradient") { - LOG_INFO("KPPMix::init: MatchGradient is deprecated/unused in OMEGA; " - "mapping to SimpleShapes"); - DefKPPMix->MatchTechniqueStr = "SimpleShapes"; - } - if (DefKPPMix->MatchTechniqueStr != "SimpleShapes" && - DefKPPMix->MatchTechniqueStr != "MatchBoth" && - DefKPPMix->MatchTechniqueStr != "ParabolicNonLocal") { - LOG_INFO( - "KPPMix::init: Unsupported MatchTechnique='{}', using SimpleShapes", - DefKPPMix->MatchTechniqueStr); - DefKPPMix->MatchTechniqueStr = "SimpleShapes"; + if (!matchTypeFromString(MatchStr, DefKPPMix->MatchTechnique)) { + ABORT_ERROR("KPPMix::init: Invalid MatchTechnique='{}', must be " + "SimpleShapes or MatchBoth", + MatchStr); } // Wave and flux options Err += KPPConfig.get("UseLangmuirCirculation", DefKPPMix->UseLangmuirCirculation); - Err += KPPConfig.get("UseNonLocalFlux", DefKPPMix->UseNonLocalFlux); Err += KPPConfig.get("IceFractionThresholdForLangmuir", DefKPPMix->IceFractionThresholdForLangmuir); Err += KPPConfig.get("IceFractionThresholdForMinimumOBL", @@ -197,7 +212,7 @@ void KPPMix::init() { LOG_WARN("KPPMix::init: KPP initialized enabled={} debugDiagnostics={} " "match={}", DefKPPMix->Enabled, DefKPPMix->DebugDiagnostics, - DefKPPMix->MatchTechniqueStr); + matchTypeName(DefKPPMix->MatchTechnique)); } /// Main computation routine @@ -268,52 +283,50 @@ void KPPMix::logDiagnostics(const Array2DReal &PotentialDensity, } // Domain-wide diagnostic to avoid misleading single-cell checks. - Real maxAbsB0 = 0.0_Real; - Real maxAbsUStar = 0.0_Real; - Real maxVertDiff = 0.0_Real; - Real maxVertVisc = 0.0_Real; - int maxAbsB0Cell = -1; - int maxAbsUStarCell = -1; - int maxVertDiffCell = -1; - int maxVertDiffK = -1; - int maxVertViscCell = -1; - int maxVertViscK = -1; + Real MaxAbsB0 = 0.0_Real; + Real MaxAbsUStar = 0.0_Real; + Real MaxVertDiff = 0.0_Real; + Real MaxVertVisc = 0.0_Real; + int MaxAbsB0Cell = -1; + int MaxAbsUStarCell = -1; + int MaxVertDiffCell = -1; + int MaxVertDiffK = -1; + int MaxVertViscCell = -1; + int MaxVertViscK = -1; for (int C = 0; C < NCellsAll; ++C) { - const Real b0c = B0H(C); - const Real usc = UStarH(C); - const Real ab0 = Kokkos::abs(b0c); - const Real aus = Kokkos::abs(usc); - if (ab0 > maxAbsB0) { - maxAbsB0 = ab0; - maxAbsB0Cell = C; + const Real AbsB0 = Kokkos::abs(B0H(C)); + const Real AbsUStar = Kokkos::abs(UStarH(C)); + if (AbsB0 > MaxAbsB0) { + MaxAbsB0 = AbsB0; + MaxAbsB0Cell = C; } - if (aus > maxAbsUStar) { - maxAbsUStar = aus; - maxAbsUStarCell = C; + if (AbsUStar > MaxAbsUStar) { + MaxAbsUStar = AbsUStar; + MaxAbsUStarCell = C; } const int KCMin = MinLayerCellH(C); const int KCMax = MaxLayerCellH(C) + 1; for (int K = KCMin; K <= KCMax; ++K) { - const Real diff = VertDiffH(C, K); - const Real visc = VertViscH(C, K); - if (diff > maxVertDiff) { - maxVertDiff = diff; - maxVertDiffCell = C; - maxVertDiffK = K; + const Real Diff = VertDiffH(C, K); + const Real Visc = VertViscH(C, K); + if (Diff > MaxVertDiff) { + MaxVertDiff = Diff; + MaxVertDiffCell = C; + MaxVertDiffK = K; } - if (visc > maxVertVisc) { - maxVertVisc = visc; - maxVertViscCell = C; - maxVertViscK = K; + if (Visc > MaxVertVisc) { + MaxVertVisc = Visc; + MaxVertViscCell = C; + MaxVertViscK = K; } } } LOG_WARN("KPP debug domain post-coeff: max|b0|={} at cell={} max|u*|={} " "at cell={} maxKPPDiff={} at cell={},k={} maxKPPVisc={} at " "cell={},k={}", - maxAbsB0, maxAbsB0Cell, maxAbsUStar, maxAbsUStarCell, maxVertDiff, - maxVertDiffCell, maxVertDiffK, maxVertVisc, maxVertViscCell, - maxVertViscK); + MaxAbsB0, MaxAbsB0Cell, MaxAbsUStar, MaxAbsUStarCell, MaxVertDiff, + MaxVertDiffCell, MaxVertDiffK, MaxVertVisc, MaxVertViscCell, + MaxVertViscK); const int ICell = 0; const int KMin = MinLayerCellH(ICell); @@ -324,25 +337,26 @@ void KPPMix::logDiagnostics(const Array2DReal &PotentialDensity, return; } - const int KSurf = Kokkos::min(KMin, NVertLayers - 1); - const Real rho_surf = DensityH(ICell, KSurf); - const Real u_star = UStarH(ICell); - const Real u_star_eff = Kokkos::fmax(KPP::MIN_USTAR, u_star); - const Real b0 = B0H(ICell); - Real u10 = 0.0_Real; + const int KSurf = Kokkos::min(KMin, NVertLayers - 1); + const Real RhoSurf = DensityH(ICell, KSurf); + const Real UStar = UStarH(ICell); + const Real UStarEff = Kokkos::fmax(KPP::MinUStar, UStar); + const Real BuoyFlux = B0H(ICell); + Real Wind10m = 0.0_Real; if (WindSpeed10m.extent(0) > 0) { const auto Wind10mH = createHostMirrorCopy(WindSpeed10m); - u10 = Wind10mH(ICell); + Wind10m = Wind10mH(ICell); } - const Real langmuir_factor = - UseLangmuirCirculation ? ComputeEnhancementFactor(u10, u_star_eff, 50.0) - : 1.0_Real; - const Real b0_eff = b0 * langmuir_factor; + const Real LangmuirFactor = + UseLangmuirCirculation + ? computeLangmuirEnhancement(Wind10m, UStarEff, 50.0) + : 1.0_Real; + const Real BuoyFluxEff = BuoyFlux * LangmuirFactor; LOG_WARN("KPP debug: cell={} h_obl={} m k_obl={} u*={} b0={} b0_eff={} " "langmuir={}", - ICell, OBLDepthH(ICell), OBLIndexH(ICell), u_star, b0, b0_eff, - langmuir_factor); + ICell, OBLDepthH(ICell), OBLIndexH(ICell), UStar, BuoyFlux, + BuoyFluxEff, LangmuirFactor); const int KOblIface = Kokkos::min( NVertLayers, Kokkos::max(KMin, static_cast(OBLIndexH(ICell)) + 1)); @@ -351,45 +365,46 @@ void KPPMix::logDiagnostics(const Array2DReal &PotentialDensity, ICell, OBLIndexH(ICell), KOblIface, VertDiffH(ICell, KOblIface), VertViscH(ICell, KOblIface)); - const int KTop = Kokkos::min(KMax, KMin + 3); - const int k_obl = OBLIndexH(ICell); - const Real h_obl = OBLDepthH(ICell); + const int KTop = Kokkos::min(KMax, KMin + 3); + const int KOBL = OBLIndexH(ICell); + const Real HOBL = OBLDepthH(ICell); for (int K = KMin; K <= KTop; ++K) { - const int kCell = Kokkos::min(K, NVertLayers - 1); - const int kInt = Kokkos::min(K + 1, NVertLayers); - const Real z_depth = SshCellH(ICell) - ZInterfaceH(ICell, kInt); - - const Real rho_k = DensityH(ICell, kCell); - const Real delta_rho = rho_k - rho_surf; - const Real delta_b = Gravity * delta_rho / RhoSw; - const Real w_turb = - ComputeTurbulentVelocityScale(u_star_eff, b0_eff, z_depth); - const Real ri_b = delta_b * z_depth / (w_turb * w_turb + 1.0e-12_Real); - - Real sigma = 0.0_Real; - if (K <= k_obl) { - sigma = -1.0_Real * static_cast(K - KMin) / - static_cast(k_obl - KMin + 1); - sigma = Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, sigma)); + const int KCell = Kokkos::min(K, NVertLayers - 1); + const int KIface = Kokkos::min(K + 1, NVertLayers); + const Real ZDepth = SshCellH(ICell) - ZInterfaceH(ICell, KIface); + + const Real RhoK = DensityH(ICell, KCell); + const Real DeltaRho = RhoK - RhoSurf; + const Real DeltaB = Gravity * DeltaRho / RhoSw; + const Real WTurb = + computeTurbVelocityScale(UStarEff, BuoyFluxEff, ZDepth); + const Real RiBulk = DeltaB * ZDepth / (WTurb * WTurb + 1.0e-12_Real); + + Real Sigma = 0.0_Real; + if (K <= KOBL) { + Sigma = -1.0_Real * static_cast(K - KMin) / + static_cast(KOBL - KMin + 1); + Sigma = Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, Sigma)); } - const Real z_local = -sigma * h_obl; - Real zeta = 0.0_Real; - const Real denom = VonKar * b0; - if (Kokkos::abs(denom) > 1.0e-16_Real) { - const Real l_mo = (u_star_eff * u_star_eff * u_star_eff) / denom; - if (Kokkos::abs(l_mo) > 1.0e-16_Real) { - zeta = z_local / l_mo; + // Monin-Obukhov coordinate zeta = d/L at this depth + const Real ZLocal = -Sigma * HOBL; + Real Zeta = 0.0_Real; + const Real Denom = VonKar * BuoyFlux; + if (Kokkos::abs(Denom) > 1.0e-16_Real) { + const Real LMoninObukhov = (UStarEff * UStarEff * UStarEff) / Denom; + if (Kokkos::abs(LMoninObukhov) > 1.0e-16_Real) { + Zeta = ZLocal / LMoninObukhov; } } - const Real phi_m = KPP::KPPProfileM2(zeta); - const Real phi_s = KPP::KPPProfileS2(zeta); + const Real PhiInvM = KPP::kppPhiInvMomentum(Zeta); + const Real PhiInvS = KPP::kppPhiInvScalar(Zeta); LOG_WARN( "KPP debug top: cell={} k={} z={} ri_b={} zeta={} phi_m={} phi_s={}", - ICell, K, z_depth, ri_b, zeta, phi_m, phi_s); + ICell, K, ZDepth, RiBulk, Zeta, PhiInvM, PhiInvS); } } @@ -412,20 +427,21 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, // ======================================================================= // Compute Langmuir enhancement factors if wind speed is available // ======================================================================= - Array1DReal LangmuirFactor("LangmuirFactor", Mesh->NCellsAll); + Array1DReal LangmuirFactor("LangmuirFactor", Mesh->NCellsSize); const bool LocUseLangmuirCirculation = UseLangmuirCirculation; const Real LocSurfaceLayerExtent = SurfaceLayerExtent; const Real LocCriticalRichardson = CriticalRichardson; const Real LocIceFracThresholdForLangmuir = IceFractionThresholdForLangmuir; parallelFor( "KPP-Langmuir", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { - const Real iceFrac = IceFraction(ICell); + const Real IceFrac = IceFraction(ICell); if (LocUseLangmuirCirculation && - iceFrac < LocIceFracThresholdForLangmuir) { - const Real uStar = SurfaceFrictionVelocity(ICell); - const Real u10 = + IceFrac < LocIceFracThresholdForLangmuir) { + const Real UStar = SurfaceFrictionVelocity(ICell); + const Real Wind10m = (WindSpeed10m.extent(0) > 0) ? WindSpeed10m(ICell) : 0.0_Real; - LangmuirFactor(ICell) = ComputeEnhancementFactor(u10, uStar, 50.0); + LangmuirFactor(ICell) = + computeLangmuirEnhancement(Wind10m, UStar, 50.0); } else { LangmuirFactor(ICell) = 1.0; } @@ -470,372 +486,390 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, deepCopy(UnresolvedShear, 0.0_Real); deepCopy(BuoyancyJump, 0.0_Real); - // Maximum edges around a cell (matches MaxMaxEdges in HorzOperators.h) - constexpr I4 MAX_EDGES_ON_CELL = 10; + // Compile-time bound for the per-thread edge scratch arrays below + constexpr I4 MaxEdgesBound = HorzMesh::MaxEdgesBound; parallelFor( "KPP-OBLDepth", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { using namespace KPP; - const Real u_star = + const Real UStar = Kokkos::fmax(0.0_Real, SurfaceFrictionVelocity(ICell)); - const Real b0 = SurfaceBuoyancyFlux(ICell); + const Real BuoyFlux = SurfaceBuoyancyFlux(ICell); const I4 KMin = MinLayerCell(ICell); const I4 KMax = MaxLayerCell(ICell); const I4 KIntTop = Kokkos::min(KMin + 1, NVertLayers); const I4 KIntDeep = Kokkos::min(KMax + 1, NVertLayers); - const Real iceFrac = LocIceFraction(ICell); + const Real IceFrac = LocIceFraction(ICell); // KPP depths are measured below the free surface, so geometric // heights must be offset by the sea surface height. const Real Ssh = LocSshCell(ICell); - Real obl_depth = Ssh - ZInterface(ICell, KIntDeep); - I4 k_cross = -1; - const Real ri_crit = LocCriticalRichardson; - const Real ri_stop_crit = - Kokkos::max(1.0e-6_Real, LocStopOBLSearchMult) * ri_crit; - const Real ri_scaling = 1.0_Real - 0.5_Real * LocSurfaceLayerExtent; - const Real b0_eff = b0 * LocLangmuirFactor(ICell); - - // CVMix default unresolved-shear constants. - const Real c_s_unres = 24.0_Real * Kokkos::sqrt(17.0_Real); - const Real vtc = - Kokkos::sqrt(0.2_Real / - Kokkos::max(1.0e-12_Real, - c_s_unres * LocSurfaceLayerExtent)) / + // Default to the full water column; overwritten if Ri crosses. + Real OBLDepth = Ssh - ZInterface(ICell, KIntDeep); + I4 KCross = -1; + const Real RiCritical = LocCriticalRichardson; + const Real RiStopCrit = + Kokkos::max(1.0e-6_Real, LocStopOBLSearchMult) * RiCritical; + // Ri is evaluated at cell centers while the reference average spans + // the top epsilon*d; this factor corrects for that offset. + const Real RiScaling = 1.0_Real - 0.5_Real * LocSurfaceLayerExtent; + const Real BuoyFluxEff = BuoyFlux * LocLangmuirFactor(ICell); + + // Unresolved shear coefficient, Large et al. (1994) Eq. (23): + // Vt^2 = Cv * sqrt(-beta_T/(c_s*eps)) / (kappa^2 * Ri_crit) * d*N*w_s + // CSUnres is c_s for the strongly-unstable scalar branch and VtCoef + // collects the constant prefactor. + const Real CSUnres = 24.0_Real * Kokkos::sqrt(17.0_Real); + const Real VtCoef = + Kokkos::sqrt( + 0.2_Real / + Kokkos::max(1.0e-12_Real, CSUnres * LocSurfaceLayerExtent)) / (VonKar * VonKar); // ------------------------------------------------------------------- - // Initialize per-edge running sums for surface-layer velocity - // averages + // Velocities live on edges (C-grid), so the shear entering Ri is a + // weighted average over the cell's edges. Weights are the MPAS kite + // areas (0.25*dc*dv) normalized by the cell area. // ------------------------------------------------------------------- - const I4 nEdges = NEdgesOnCell(ICell); - // MPAS-style area fractions for edge averaging. - // Use edge kite area divided by cell area. - const I4 nEdgesEff = Kokkos::min(nEdges, MAX_EDGES_ON_CELL); - bool edge_valid[MAX_EDGES_ON_CELL] = {}; - Real edge_weights[MAX_EDGES_ON_CELL] = {}; - const Real inv_area_cell = + const I4 NEdges = NEdgesOnCell(ICell); + const I4 NEdgesEff = Kokkos::min(NEdges, MaxEdgesBound); + bool EdgeValid[MaxEdgesBound] = {}; + Real EdgeWeights[MaxEdgesBound] = {}; + const Real InvAreaCell = 1.0_Real / Kokkos::max(AreaCell(ICell), 1.0e-20_Real); - for (I4 J = 0; J < nEdgesEff; ++J) { + for (I4 J = 0; J < NEdgesEff; ++J) { const I4 IEdge = EdgesOnCell(ICell, J); const I4 KEMin = MinLayerEdgeBot(IEdge); const I4 KEMax = MaxLayerEdgeTop(IEdge); - edge_valid[J] = + EdgeValid[J] = (KEMax >= KEMin && KEMin >= 0 && KEMin < NVertLayers); - if (edge_valid[J]) { - edge_weights[J] = - 0.25_Real * DcEdge(IEdge) * DvEdge(IEdge) * inv_area_cell; + if (EdgeValid[J]) { + EdgeWeights[J] = + 0.25_Real * DcEdge(IEdge) * DvEdge(IEdge) * InvAreaCell; } } - if (nEdgesEff > 0) { - Real sum_w = 0.0_Real; - for (I4 J = 0; J < nEdgesEff; ++J) { - if (edge_valid[J]) { - sum_w += edge_weights[J]; + if (NEdgesEff > 0) { + Real SumW = 0.0_Real; + for (I4 J = 0; J < NEdgesEff; ++J) { + if (EdgeValid[J]) { + SumW += EdgeWeights[J]; } } - if (sum_w < 1.0e-20_Real) { - I4 n_edges_valid = 0; - for (I4 J = 0; J < nEdgesEff; ++J) { - if (edge_valid[J]) { - ++n_edges_valid; + if (SumW < 1.0e-20_Real) { + // Degenerate kite areas: fall back to equal weighting. + I4 NEdgesValid = 0; + for (I4 J = 0; J < NEdgesEff; ++J) { + if (EdgeValid[J]) { + ++NEdgesValid; } } - if (n_edges_valid > 0) { - const Real equal_w = - 1.0_Real / static_cast(n_edges_valid); - for (I4 J = 0; J < nEdgesEff; ++J) { - edge_weights[J] = edge_valid[J] ? equal_w : 0.0_Real; + if (NEdgesValid > 0) { + const Real EqualW = + 1.0_Real / static_cast(NEdgesValid); + for (I4 J = 0; J < NEdgesEff; ++J) { + EdgeWeights[J] = EdgeValid[J] ? EqualW : 0.0_Real; } } } else { - const Real inv_sum_w = 1.0_Real / sum_w; - for (I4 J = 0; J < nEdgesEff; ++J) { - if (edge_valid[J]) { - edge_weights[J] *= inv_sum_w; + const Real InvSumW = 1.0_Real / SumW; + for (I4 J = 0; J < NEdgesEff; ++J) { + if (EdgeValid[J]) { + EdgeWeights[J] *= InvSumW; } } } } // ------------------------------------------------------------------- - // Cell surface-layer running sums for density - // MOVED INSIDE K-LOOP TO RESET EACH ITERATION (FIX FOR PROGRESSIVE - // ACCUMULATION BUG) + // Bulk Richardson search, Large et al. (1994) Eq. (21): + // Ri_b(d) = (B_r - B(d)) d / (|V_r - V(d)|^2 + Vt^2(d)) + // where the reference values B_r, V_r are averaged over the top + // epsilon*d of the column. Because the reference average depends on + // the trial depth d, it is rebuilt from the surface on every k. + // The OBL base is the first d at which Ri_b reaches RiStopCrit. // ------------------------------------------------------------------- - for (I4 k = KMin; k <= KMax; ++k) { - // Initialize fresh surface layer averages for this candidate OBL - // depth - I4 k_surface_avg = KMin; - const Real thick_top = Kokkos::abs(ZInterface(ICell, KMin + 1) - - ZInterface(ICell, KMin)); - Real sum_thickness = Kokkos::max(thick_top, 1.0e-12_Real); - Real sum_rho = LocPotentialDensity(ICell, KMin) * sum_thickness; - - // Initialize fresh per-edge surface layer averages for this - // candidate OBL depth - I4 k_surf_e[MAX_EDGES_ON_CELL] = {}; - Real sum_thick_e[MAX_EDGES_ON_CELL] = {}; - Real sum_un_e[MAX_EDGES_ON_CELL] = {}; - Real sum_vt_e[MAX_EDGES_ON_CELL] = {}; - - for (I4 J = 0; J < nEdgesEff; ++J) { - if (!edge_valid[J]) { + for (I4 K = KMin; K <= KMax; ++K) { + // Fresh cell surface-layer density average for this trial depth + I4 KSurfaceAvg = KMin; + const Real ThickTop = Kokkos::abs(ZInterface(ICell, KMin + 1) - + ZInterface(ICell, KMin)); + Real SumThickness = Kokkos::max(ThickTop, 1.0e-12_Real); + Real SumRho = LocPotentialDensity(ICell, KMin) * SumThickness; + + // Fresh per-edge surface-layer velocity averages + I4 KSurfE[MaxEdgesBound] = {}; + Real SumThickE[MaxEdgesBound] = {}; + Real SumUnE[MaxEdgesBound] = {}; + Real SumVtE[MaxEdgesBound] = {}; + + for (I4 J = 0; J < NEdgesEff; ++J) { + if (!EdgeValid[J]) { continue; } const I4 IEdge = EdgesOnCell(ICell, J); const I4 KEMin = MinLayerEdgeBot(IEdge); - k_surf_e[J] = KEMin; - const I4 kInt0 = Kokkos::min(KEMin + 1, NVertLayers); - const Real thick0 = Kokkos::abs(ZInterface(ICell, kInt0) - + KSurfE[J] = KEMin; + const I4 KIntE0 = Kokkos::min(KEMin + 1, NVertLayers); + const Real Thick0 = Kokkos::abs(ZInterface(ICell, KIntE0) - ZInterface(ICell, KEMin)); - sum_thick_e[J] = Kokkos::max(thick0, 1.0e-12_Real); - const I4 ke0 = Kokkos::min(KEMin, NVertLayers - 1); - sum_un_e[J] = LocNormalVelocity(IEdge, ke0) * sum_thick_e[J]; - sum_vt_e[J] = - LocTangentialVelocity(IEdge, ke0) * sum_thick_e[J]; + SumThickE[J] = Kokkos::max(Thick0, 1.0e-12_Real); + const I4 KE0 = Kokkos::min(KEMin, NVertLayers - 1); + SumUnE[J] = LocNormalVelocity(IEdge, KE0) * SumThickE[J]; + SumVtE[J] = LocTangentialVelocity(IEdge, KE0) * SumThickE[J]; } - const I4 kCell = Kokkos::min(k, NVertLayers - 1); - const I4 kInt = Kokkos::min(k + 1, NVertLayers); - const Real z_depth = Ssh - ZInterface(ICell, kInt); - const Real z_center = Ssh - ZMid(ICell, kCell); - if (z_depth < 1.0e-12) + const I4 KCell = Kokkos::min(K, NVertLayers - 1); + const I4 KInt = Kokkos::min(K + 1, NVertLayers); + const Real ZDepth = Ssh - ZInterface(ICell, KInt); + const Real ZCenter = Ssh - ZMid(ICell, KCell); + if (ZDepth < 1.0e-12) continue; - const Real surf_layer_depth = LocSurfaceLayerExtent * z_depth; + const Real SurfLayerDepth = LocSurfaceLayerExtent * ZDepth; // Advance cell surface average for density - while (k_surface_avg < k && - (Ssh - ZInterface(ICell, k_surface_avg + 1)) < - surf_layer_depth) { - ++k_surface_avg; - const I4 ksa = Kokkos::min(k_surface_avg, NVertLayers - 1); - const Real dk = - Kokkos::abs(ZInterface(ICell, k_surface_avg + 1) - - ZInterface(ICell, k_surface_avg)); - const Real thick_k = Kokkos::max(dk, 1.0e-12_Real); - sum_thickness += thick_k; - sum_rho += LocPotentialDensity(ICell, ksa) * thick_k; + while (KSurfaceAvg < K && + (Ssh - ZInterface(ICell, KSurfaceAvg + 1)) < + SurfLayerDepth) { + ++KSurfaceAvg; + const I4 KSA = Kokkos::min(KSurfaceAvg, NVertLayers - 1); + const Real DZ = Kokkos::abs(ZInterface(ICell, KSurfaceAvg + 1) - + ZInterface(ICell, KSurfaceAvg)); + const Real ThickK = Kokkos::max(DZ, 1.0e-12_Real); + SumThickness += ThickK; + SumRho += LocPotentialDensity(ICell, KSA) * ThickK; } // Advance per-edge surface averages for velocity - for (I4 J = 0; J < nEdgesEff; ++J) { - if (!edge_valid[J]) { + for (I4 J = 0; J < NEdgesEff; ++J) { + if (!EdgeValid[J]) { continue; } const I4 IEdge = EdgesOnCell(ICell, J); const I4 KEMax = MaxLayerEdgeTop(IEdge); - while (k_surf_e[J] < k && - (Ssh - ZInterface(ICell, k_surf_e[J] + 1)) < - surf_layer_depth) { - ++k_surf_e[J]; - const I4 ke = Kokkos::min( - Kokkos::max(k_surf_e[J], MinLayerEdgeBot(IEdge)), KEMax); - const Real dk = - Kokkos::abs(ZInterface(ICell, k_surf_e[J] + 1) - - ZInterface(ICell, k_surf_e[J])); - const Real thick_k = Kokkos::max(dk, 1.0e-12_Real); - sum_thick_e[J] += thick_k; - sum_un_e[J] += LocNormalVelocity(IEdge, ke) * thick_k; - sum_vt_e[J] += LocTangentialVelocity(IEdge, ke) * thick_k; + while (KSurfE[J] < K && + (Ssh - ZInterface(ICell, KSurfE[J] + 1)) < + SurfLayerDepth) { + ++KSurfE[J]; + const I4 KE = Kokkos::min( + Kokkos::max(KSurfE[J], MinLayerEdgeBot(IEdge)), KEMax); + const Real DZ = + Kokkos::abs(ZInterface(ICell, KSurfE[J] + 1) - + ZInterface(ICell, KSurfE[J])); + const Real ThickK = Kokkos::max(DZ, 1.0e-12_Real); + SumThickE[J] += ThickK; + SumUnE[J] += LocNormalVelocity(IEdge, KE) * ThickK; + SumVtE[J] += LocTangentialVelocity(IEdge, KE) * ThickK; } } - const Real inv_sum_thickness = - 1.0_Real / Kokkos::max(sum_thickness, 1.0e-12_Real); - const Real rho_avg_surf = sum_rho * inv_sum_thickness; - - const Real rho_k = LocPotentialDensity(ICell, kCell); - const Real delta_rho = rho_k - rho_avg_surf; - const Real delta_b = Gravity * delta_rho / RhoSw; - LocBuoyancyJump(ICell, kInt) = delta_b; - - // Edge-based velocity shear: average deltaV^2 over cell edges - Real deltaVsq = 0.0_Real; - if (nEdges > 0) { - for (I4 J = 0; J < nEdgesEff; ++J) { - if (!edge_valid[J]) { + const Real InvSumThickness = + 1.0_Real / Kokkos::max(SumThickness, 1.0e-12_Real); + const Real RhoAvgSurf = SumRho * InvSumThickness; + + // Buoyancy jump B_r - B(d), positive for stable stratification + const Real RhoK = LocPotentialDensity(ICell, KCell); + const Real DeltaRho = RhoK - RhoAvgSurf; + const Real DeltaB = Gravity * DeltaRho / RhoSw; + LocBuoyancyJump(ICell, KInt) = DeltaB; + + // Resolved shear |V_r - V(d)|^2, averaged over the cell edges + Real DeltaVSq = 0.0_Real; + if (NEdges > 0) { + for (I4 J = 0; J < NEdgesEff; ++J) { + if (!EdgeValid[J]) { continue; } const I4 IEdge = EdgesOnCell(ICell, J); const I4 KEMin = MinLayerEdgeBot(IEdge); const I4 KEMax = MaxLayerEdgeTop(IEdge); - const I4 k_e = Kokkos::min(Kokkos::max(k, KEMin), KEMax); - const Real inv_thick_e = - 1.0_Real / Kokkos::max(sum_thick_e[J], 1.0e-12_Real); - const Real un_avg = sum_un_e[J] * inv_thick_e; - const Real vt_avg = sum_vt_e[J] * inv_thick_e; - const Real un_k = LocNormalVelocity(IEdge, k_e); - const Real vt_k = LocTangentialVelocity(IEdge, k_e); - const Real dun = un_k - un_avg; - const Real dvt = vt_k - vt_avg; - deltaVsq += edge_weights[J] * (dun * dun + dvt * dvt); + const I4 KE = Kokkos::min(Kokkos::max(K, KEMin), KEMax); + const Real InvThickE = + 1.0_Real / Kokkos::max(SumThickE[J], 1.0e-12_Real); + const Real UnAvg = SumUnE[J] * InvThickE; + const Real VtAvg = SumVtE[J] * InvThickE; + const Real UnK = LocNormalVelocity(IEdge, KE); + const Real VtK = LocTangentialVelocity(IEdge, KE); + const Real DUn = UnK - UnAvg; + const Real DVt = VtK - VtAvg; + DeltaVSq += EdgeWeights[J] * (DUn * DUn + DVt * DVt); } } - LocBulkRichardsonShear(ICell, kInt) = - Kokkos::max(deltaVsq, 1.0e-15_Real); + LocBulkRichardsonShear(ICell, KInt) = + Kokkos::max(DeltaVSq, 1.0e-15_Real); - const Real sigma_loc = Kokkos::fmin( + const Real SigmaLoc = Kokkos::fmin( 1.0_Real, Kokkos::fmax(0.0_Real, LocSurfaceLayerExtent)); - Real w_turb = 0.0_Real; - if (u_star > 1.0e-12_Real) { - const Real u3 = u_star * u_star * u_star; - const Real zeta = sigma_loc * z_depth * VonKar * b0_eff / - Kokkos::max(u3, 1.0e-20_Real); - const Real phi_inv_s = KPP::KPPProfileS2(zeta); - w_turb = VonKar * u_star * Kokkos::max(phi_inv_s, 0.0_Real); - } else if (b0_eff < 0.0_Real) { - const Real c_s = KPP::C_MO_S; - const Real ws3 = -c_s * sigma_loc * z_depth * VonKar * b0_eff; - w_turb = VonKar * Kokkos::pow(Kokkos::max(ws3, 0.0_Real), - 1.0_Real / 3.0_Real); + // Turbulent scalar velocity scale w_s at the surface-layer depth + Real WTurb = 0.0_Real; + if (UStar > 1.0e-12_Real) { + const Real U3 = UStar * UStar * UStar; + const Real Zeta = SigmaLoc * ZDepth * VonKar * BuoyFluxEff / + Kokkos::max(U3, 1.0e-20_Real); + const Real PhiInvS = KPP::kppPhiInvScalar(Zeta); + WTurb = VonKar * UStar * Kokkos::max(PhiInvS, 0.0_Real); + } else if (BuoyFluxEff < 0.0_Real) { + // Free convection limit: u* drops out and w_s ~ (c_s d B_0)^1/3 + const Real CS = KPP::CMoS; + const Real WS3 = -CS * SigmaLoc * ZDepth * VonKar * BuoyFluxEff; + WTurb = VonKar * Kokkos::pow(Kokkos::max(WS3, 0.0_Real), + 1.0_Real / 3.0_Real); } - const Real n_cntr = Kokkos::sqrt( - Kokkos::max(0.0_Real, LocBruntVaisalaFreqSq(ICell, kInt))); - const Real cv = (n_cntr < 0.002_Real) - ? (2.1_Real - 200.0_Real * n_cntr) + + // Unresolved turbulent shear Vt^2 (m^2/s^2), Large et al. Eq. + // (23). Cv ramps from 2.1 to 1.7 as stratification strengthens. + const Real NCntr = Kokkos::sqrt( + Kokkos::max(0.0_Real, LocBruntVaisalaFreqSq(ICell, KInt))); + const Real Cv = (NCntr < 0.002_Real) + ? (2.1_Real - 200.0_Real * NCntr) : 1.7_Real; - const Real vt2 = Kokkos::max( - 1.0e-10_Real, cv * vtc * z_center * n_cntr * w_turb / - Kokkos::max(ri_crit, 1.0e-12_Real)); - LocUnresolvedShear(ICell, kInt) = vt2; + const Real Vt2 = Kokkos::max( + 1.0e-10_Real, Cv * VtCoef * ZCenter * NCntr * WTurb / + Kokkos::max(RiCritical, 1.0e-12_Real)); + LocUnresolvedShear(ICell, KInt) = Vt2; - const Real vel_scale2 = deltaVsq + vt2; + const Real VelScaleSq = DeltaVSq + Vt2; - const Real ri_b = ri_scaling * delta_b * z_center / - Kokkos::max(vel_scale2, 1.0e-12_Real); - LocBulkRichardson(ICell, kInt) = ri_b; + const Real RiBulk = RiScaling * DeltaB * ZCenter / + Kokkos::max(VelScaleSq, 1.0e-12_Real); + LocBulkRichardson(ICell, KInt) = RiBulk; - if (k_cross < 0 && ri_b > ri_stop_crit) { - k_cross = k; + if (KCross < 0 && RiBulk > RiStopCrit) { + KCross = K; } } - if (k_cross >= KMin) { - if (k_cross > KMin) { + if (KCross >= KMin) { + if (KCross > KMin) { // Ri values are defined at cell centers, so interpolate on // center depths to keep the abscissa consistent. - const I4 kAbove = Kokkos::max(KMin, k_cross - 1); - const I4 kBelow = Kokkos::min(k_cross, NVertLayers - 1); - const I4 kAboveRi = Kokkos::min(kAbove + 1, NVertLayers); - const I4 kBelowRi = Kokkos::min(kBelow + 1, NVertLayers); - const Real z_above = Ssh - ZMid(ICell, kAbove); - const Real z_below = Ssh - ZMid(ICell, kBelow); - const Real ri_above = LocBulkRichardson(ICell, kAboveRi); - const Real ri_below = LocBulkRichardson(ICell, kBelowRi); - - const Real h = z_below - z_above; - if (h > 1.0e-12_Real) { + const I4 KAbove = Kokkos::max(KMin, KCross - 1); + const I4 KBelow = Kokkos::min(KCross, NVertLayers - 1); + const I4 KAboveRi = Kokkos::min(KAbove + 1, NVertLayers); + const I4 KBelowRi = Kokkos::min(KBelow + 1, NVertLayers); + const Real ZAbove = Ssh - ZMid(ICell, KAbove); + const Real ZBelow = Ssh - ZMid(ICell, KBelow); + const Real RiAbove = LocBulkRichardson(ICell, KAboveRi); + const Real RiBelow = LocBulkRichardson(ICell, KBelowRi); + + const Real H = ZBelow - ZAbove; + if (H > 1.0e-12_Real) { // CVMix-style QUAD interpolation for OBL crossing: // - first interior crossing uses zero slope at top point // - deeper crossings use upstream slope - Real slope_above = 0.0_Real; - if (k_cross > KMin + 1) { - const I4 kPrev = Kokkos::max(KMin, kAbove - 1); - const I4 kPrevRi = Kokkos::min(kPrev + 1, NVertLayers); - const Real z_prev = Ssh - ZMid(ICell, kPrev); - const Real ri_prev = LocBulkRichardson(ICell, kPrevRi); - const Real dz_prev = z_above - z_prev; - if (Kokkos::abs(dz_prev) > 1.0e-12_Real) { - slope_above = (ri_above - ri_prev) / dz_prev; + Real SlopeAbove = 0.0_Real; + if (KCross > KMin + 1) { + const I4 KPrev = Kokkos::max(KMin, KAbove - 1); + const I4 KPrevRi = Kokkos::min(KPrev + 1, NVertLayers); + const Real ZPrev = Ssh - ZMid(ICell, KPrev); + const Real RiPrev = LocBulkRichardson(ICell, KPrevRi); + const Real DZPrev = ZAbove - ZPrev; + if (Kokkos::abs(DZPrev) > 1.0e-12_Real) { + SlopeAbove = (RiAbove - RiPrev) / DZPrev; } } - // In local coordinate t = z - z_above: - // Ri(t) = A t^2 + slope_above t + ri_above - const Real A = - (ri_below - ri_above - slope_above * h) / (h * h); - const Real C = ri_above - ri_stop_crit; + // In local coordinate T = z - ZAbove: + // Ri(T) = QuadA T^2 + SlopeAbove T + RiAbove, with QuadA + // fixed by requiring Ri(H) = RiBelow. The OBL base is the + // root of Ri(T) = RiStopCrit. + const Real QuadA = + (RiBelow - RiAbove - SlopeAbove * H) / (H * H); + const Real QuadC = RiAbove - RiStopCrit; - Real t_cross = h; - if (Kokkos::abs(A) < 1.0e-14_Real) { + Real TCross = H; + if (Kokkos::abs(QuadA) < 1.0e-14_Real) { // Degenerate quadratic -> linear fallback. - const Real d_ri = ri_below - ri_above; - if (Kokkos::abs(d_ri) > 1.0e-12_Real) { - const Real frac = Kokkos::fmax( + const Real DRi = RiBelow - RiAbove; + if (Kokkos::abs(DRi) > 1.0e-12_Real) { + const Real Frac = Kokkos::fmax( 0.0_Real, Kokkos::fmin(1.0_Real, - (ri_stop_crit - ri_above) / d_ri)); - t_cross = frac * h; + (RiStopCrit - RiAbove) / DRi)); + TCross = Frac * H; } } else { - const Real disc = - slope_above * slope_above - 4.0_Real * A * C; - if (disc >= 0.0_Real) { - const Real sqrt_disc = Kokkos::sqrt(disc); - const Real t1 = - (-slope_above + sqrt_disc) / (2.0_Real * A); - const Real t2 = - (-slope_above - sqrt_disc) / (2.0_Real * A); - - const bool t1_ok = (t1 >= 0.0_Real && t1 <= h); - const bool t2_ok = (t2 >= 0.0_Real && t2 <= h); - if (t1_ok && t2_ok) { - const Real mid = 0.5_Real * h; - t_cross = - (Kokkos::abs(t1 - mid) <= Kokkos::abs(t2 - mid)) - ? t1 - : t2; - } else if (t1_ok) { - t_cross = t1; - } else if (t2_ok) { - t_cross = t2; + const Real Disc = + SlopeAbove * SlopeAbove - 4.0_Real * QuadA * QuadC; + if (Disc >= 0.0_Real) { + const Real SqrtDisc = Kokkos::sqrt(Disc); + const Real T1 = + (-SlopeAbove + SqrtDisc) / (2.0_Real * QuadA); + const Real T2 = + (-SlopeAbove - SqrtDisc) / (2.0_Real * QuadA); + + const bool T1Ok = (T1 >= 0.0_Real && T1 <= H); + const bool T2Ok = (T2 >= 0.0_Real && T2 <= H); + if (T1Ok && T2Ok) { + // Both roots lie in the interval; prefer the one + // nearest mid-interval, as CVMix does. + const Real Mid = 0.5_Real * H; + TCross = + (Kokkos::abs(T1 - Mid) <= Kokkos::abs(T2 - Mid)) + ? T1 + : T2; + } else if (T1Ok) { + TCross = T1; + } else if (T2Ok) { + TCross = T2; } else { - t_cross = h; + TCross = H; } } } - t_cross = Kokkos::fmax(0.0_Real, Kokkos::fmin(h, t_cross)); - obl_depth = z_above + t_cross; + TCross = Kokkos::fmax(0.0_Real, Kokkos::fmin(H, TCross)); + OBLDepth = ZAbove + TCross; } else { - obl_depth = z_below; + OBLDepth = ZBelow; } } else { // Match center-based OBL convention when crossing occurs in // the top interval. - obl_depth = Ssh - ZMid(ICell, KMin); + OBLDepth = Ssh - ZMid(ICell, KMin); } } else { - obl_depth = Ssh - ZInterface(ICell, KIntDeep); + OBLDepth = Ssh - ZInterface(ICell, KIntDeep); } - const Real top_layer_thickness = + const Real TopLayerThickness = Kokkos::abs(ZInterface(ICell, KIntTop) - ZInterface(ICell, KMin)); - const Real min_obl_depth = 0.5_Real * top_layer_thickness; - const Real max_obl_depth = Ssh - ZMid(ICell, KMax); - obl_depth = Kokkos::fmax(obl_depth, min_obl_depth); - if (iceFrac > LocIceFracThresholdForMinOBL) { - obl_depth = Kokkos::fmax(obl_depth, LocMinimumOBLUnderSeaIce); + const Real MinOBLDepth = 0.5_Real * TopLayerThickness; + const Real MaxOBLDepth = Ssh - ZMid(ICell, KMax); + OBLDepth = Kokkos::fmax(OBLDepth, MinOBLDepth); + if (IceFrac > LocIceFracThresholdForMinOBL) { + OBLDepth = Kokkos::fmax(OBLDepth, LocMinimumOBLUnderSeaIce); } - obl_depth = Kokkos::fmin(obl_depth, max_obl_depth); - - I4 k_final = KMax; - for (I4 k = KMin; k < KMax; ++k) { - const Real z_above = Ssh - ZInterface(ICell, k); - const Real z_below = Ssh - ZInterface(ICell, k + 1); - if (obl_depth >= z_above && obl_depth <= z_below) { - k_final = k; + OBLDepth = Kokkos::fmin(OBLDepth, MaxOBLDepth); + + I4 KFinal = KMax; + for (I4 K = KMin; K < KMax; ++K) { + const Real ZAbove = Ssh - ZInterface(ICell, K); + const Real ZBelow = Ssh - ZInterface(ICell, K + 1); + if (OBLDepth >= ZAbove && OBLDepth <= ZBelow) { + KFinal = K; break; } } - LocBoundaryLayerDepth(ICell) = obl_depth; - LocIndexBoundaryLayerDepth(ICell) = k_final; + LocBoundaryLayerDepth(ICell) = OBLDepth; + LocIndexBoundaryLayerDepth(ICell) = KFinal; }); if (LocUseBLDSmoothing) { Array1DReal BoundaryLayerDepthSmooth("BoundaryLayerDepthSmooth", - Mesh->NCellsAll); + Mesh->NCellsSize); OMEGA_SCOPE(LocBoundaryLayerDepthSmooth, BoundaryLayerDepthSmooth); OMEGA_SCOPE(LocNCellsAll, Mesh->NCellsAll); + // Area-weighted smoothing of the BLD over each cell and its neighbors + // (MPAS-Ocean cvmix convention). This suppresses the grid-scale noise + // that the discrete Ri crossing search can introduce. parallelFor( "KPP-OBLDepth-Smooth", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { const I4 KMin = MinLayerCell(ICell); @@ -845,12 +879,12 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, return; } - const I4 nEdges = NEdgesOnCell(ICell); - Real area_sum = 0.0_Real; - Real bld_sum = 0.0_Real; - I4 edge_count = 0; + const I4 NEdges = NEdgesOnCell(ICell); + Real AreaSum = 0.0_Real; + Real BLDSum = 0.0_Real; + I4 EdgeCount = 0; - for (I4 J = 0; J < nEdges; ++J) { + for (I4 J = 0; J < NEdges; ++J) { const I4 INeighbor = CellsOnCell(ICell, J); if (INeighbor == LocNCellsAll) { continue; @@ -861,22 +895,21 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, continue; } - const Real nbr_area = AreaCell(INeighbor); - bld_sum += - 2.0_Real * nbr_area * LocBoundaryLayerDepth(INeighbor); - area_sum += 2.0_Real * nbr_area; - ++edge_count; + const Real NbrArea = AreaCell(INeighbor); + BLDSum += 2.0_Real * NbrArea * LocBoundaryLayerDepth(INeighbor); + AreaSum += 2.0_Real * NbrArea; + ++EdgeCount; } - if (edge_count > 0) { - const Real self_area = AreaCell(ICell); - bld_sum += LocBoundaryLayerDepth(ICell) * - static_cast(edge_count) * self_area; - area_sum += static_cast(edge_count) * self_area; + if (EdgeCount > 0) { + const Real SelfArea = AreaCell(ICell); + BLDSum += LocBoundaryLayerDepth(ICell) * + static_cast(EdgeCount) * SelfArea; + AreaSum += static_cast(EdgeCount) * SelfArea; } - if (area_sum > 0.0_Real) { - LocBoundaryLayerDepthSmooth(ICell) = bld_sum / area_sum; + if (AreaSum > 0.0_Real) { + LocBoundaryLayerDepthSmooth(ICell) = BLDSum / AreaSum; } else { LocBoundaryLayerDepthSmooth(ICell) = LocBoundaryLayerDepth(ICell); @@ -894,28 +927,28 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, const Real Ssh = LocSshCell(ICell); - const I4 KIntTop = Kokkos::min(KMin + 1, NVertLayers); - const Real top_layer_thickness = Kokkos::abs( + const I4 KIntTop = Kokkos::min(KMin + 1, NVertLayers); + const Real TopLayerThickness = Kokkos::abs( ZInterface(ICell, KIntTop) - ZInterface(ICell, KMin)); - const Real min_obl_depth = 0.5_Real * top_layer_thickness; - const Real max_obl_depth = Ssh - ZMid(ICell, KMax); - - Real obl_depth = LocBoundaryLayerDepthSmooth(ICell); - obl_depth = Kokkos::fmax(obl_depth, min_obl_depth); - obl_depth = Kokkos::fmin(obl_depth, max_obl_depth); - - I4 k_final = KMax; - for (I4 k = KMin; k < KMax; ++k) { - const Real z_above = Ssh - ZInterface(ICell, k); - const Real z_below = Ssh - ZInterface(ICell, k + 1); - if (obl_depth >= z_above && obl_depth <= z_below) { - k_final = k; + const Real MinOBLDepth = 0.5_Real * TopLayerThickness; + const Real MaxOBLDepth = Ssh - ZMid(ICell, KMax); + + Real OBLDepth = LocBoundaryLayerDepthSmooth(ICell); + OBLDepth = Kokkos::fmax(OBLDepth, MinOBLDepth); + OBLDepth = Kokkos::fmin(OBLDepth, MaxOBLDepth); + + I4 KFinal = KMax; + for (I4 K = KMin; K < KMax; ++K) { + const Real ZAbove = Ssh - ZInterface(ICell, K); + const Real ZBelow = Ssh - ZInterface(ICell, K + 1); + if (OBLDepth >= ZAbove && OBLDepth <= ZBelow) { + KFinal = K; break; } } - LocBoundaryLayerDepth(ICell) = obl_depth; - LocIndexBoundaryLayerDepth(ICell) = k_final; + LocBoundaryLayerDepth(ICell) = OBLDepth; + LocIndexBoundaryLayerDepth(ICell) = KFinal; }); } @@ -955,21 +988,15 @@ void KPPMix::computeMixingCoefficients( OMEGA_SCOPE(LocInteriorVertVisc, InteriorVertVisc); // Capture member variables for use in lambda - bool LocUseNonLocalFlux = UseNonLocalFlux; const Real LocSurfaceLayerExtent = SurfaceLayerExtent; - I4 LocMatchTechnique = 0; // 0=SimpleShapes, 1=MatchBoth, 2=ParabolicNonLocal - if (MatchTechniqueStr == "MatchBoth") { - LocMatchTechnique = 1; - } else if (MatchTechniqueStr == "ParabolicNonLocal") { - LocMatchTechnique = 2; - } + const KPPMatchType LocMatch = MatchTechnique; // Non-local flux normalization constant from Large et al. (1994) eq. 20: // C_s = C* * kappa * (c_s * kappa * epsilon)^(1/3) - // where C* = 10, c_s = C_MO_S = 98.9545, kappa = VonKar, epsilon = + // where C* = 10, c_s = CMoS = 98.9545, kappa = VonKar, epsilon = // SurfaceLayerExtent const Real LocNonLocalCs = 10.0_Real * VonKar * - Kokkos::pow(KPP::C_MO_S * VonKar * LocSurfaceLayerExtent, + Kokkos::pow(KPP::CMoS * VonKar * LocSurfaceLayerExtent, 1.0_Real / 3.0_Real); bool LocUseEnhancedDiffusion = UseEnhancedDiffusion; const Real LocKappa = VonKar; @@ -998,7 +1025,7 @@ void KPPMix::computeMixingCoefficients( parallelFor( "KPP-MixingCoeffs", {Mesh->NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { // Get OBL information for this cell - Real h_obl = LocBoundaryLayerDepth(ICell); + Real HOBL = LocBoundaryLayerDepth(ICell); const I4 KMin = MinLayerCell(ICell); const I4 KMax = MaxLayerCell(ICell); @@ -1015,229 +1042,226 @@ void KPPMix::computeMixingCoefficients( // ============================================================= // Compute turbulent velocity scales // ============================================================= - Real u_star = LocSurfaceFrictionVelocity(ICell); - Real b0 = LocSurfaceBuoyancyFlux(ICell); + Real UStar = LocSurfaceFrictionVelocity(ICell); + Real BuoyFlux = LocSurfaceBuoyancyFlux(ICell); // ============================================================= // Compute mixing coefficients at each interface // ============================================================= - for (I4 k = KMin; k <= KMax + 1; ++k) { - const I4 k_iface = Kokkos::min(Kokkos::max(k, 0), NVertLayers); - const Real z_depth = Ssh - ZInterface(ICell, k_iface); + for (I4 K = KMin; K <= KMax + 1; ++K) { + const I4 KIface = Kokkos::min(Kokkos::max(K, 0), NVertLayers); + const Real ZDepth = Ssh - ZInterface(ICell, KIface); // Check if within OBL using depth below the free surface. - if (z_depth <= h_obl && h_obl > 0.0_Real) { + if (ZDepth <= HOBL && HOBL > 0.0_Real) { // Normalized depth in Omega sign convention: sigma in [-1,0]. - Real sigma = -z_depth / h_obl; - sigma = Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, sigma)); + Real Sigma = -ZDepth / HOBL; + Sigma = Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, Sigma)); // CVMix-style turbulent scales: w = kappa*u*/phi in general, - // with explicit free-convection limits when u*=0. - const Real sigma_coord = -sigma; // [0,1] - const Real sigma_loc = Kokkos::fmin( - LocSurfaceLayerExtent, Kokkos::fmax(0.0_Real, sigma_coord)); - - Real zeta = 0.0_Real; - Real w_m_turb = 0.0_Real; - Real w_s_turb = 0.0_Real; - - if (u_star > 0.0_Real) { - const Real u3 = u_star * u_star * u_star; - zeta = sigma_loc * h_obl * b0 * LocKappa / - Kokkos::max(u3, 1.0e-20_Real); - - // KPPProfileM2/S2 return phi^{-1}; do not invert again. - const Real phi_inv_m = KPP::KPPProfileM2(zeta); - const Real phi_inv_s = KPP::KPPProfileS2(zeta); - - w_m_turb = - LocKappa * u_star * Kokkos::max(phi_inv_m, 0.0_Real); - w_s_turb = - LocKappa * u_star * Kokkos::max(phi_inv_s, 0.0_Real); - } else if (b0 < 0.0_Real) { + // with explicit free-convection limits when u*=0. The scales + // are frozen at the surface-layer depth below the surface + // layer, so SigmaLoc is capped at SurfaceLayerExtent. + const Real SigmaCoord = -Sigma; // [0,1] + const Real SigmaLoc = Kokkos::fmin( + LocSurfaceLayerExtent, Kokkos::fmax(0.0_Real, SigmaCoord)); + + Real Zeta = 0.0_Real; + Real WMTurb = 0.0_Real; + Real WSTurb = 0.0_Real; + + if (UStar > 0.0_Real) { + const Real U3 = UStar * UStar * UStar; + Zeta = SigmaLoc * HOBL * BuoyFlux * LocKappa / + Kokkos::max(U3, 1.0e-20_Real); + + // These return phi^{-1}; do not invert again. + const Real PhiInvM = KPP::kppPhiInvMomentum(Zeta); + const Real PhiInvS = KPP::kppPhiInvScalar(Zeta); + + WMTurb = LocKappa * UStar * Kokkos::max(PhiInvM, 0.0_Real); + WSTurb = LocKappa * UStar * Kokkos::max(PhiInvS, 0.0_Real); + } else if (BuoyFlux < 0.0_Real) { // Free-convection edge case (u*=0, unstable forcing). - const Real c_m = KPP::C_MO_M; - const Real c_s = KPP::C_MO_S; - const Real wm3 = -c_m * sigma_loc * h_obl * LocKappa * b0; - const Real ws3 = -c_s * sigma_loc * h_obl * LocKappa * b0; - w_m_turb = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, wm3), - 1.0_Real / 3.0_Real); - w_s_turb = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, ws3), - 1.0_Real / 3.0_Real); + const Real CM = KPP::CMoM; + const Real CS = KPP::CMoS; + const Real WM3 = -CM * SigmaLoc * HOBL * LocKappa * BuoyFlux; + const Real WS3 = -CS * SigmaLoc * HOBL * LocKappa * BuoyFlux; + WMTurb = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, WM3), + 1.0_Real / 3.0_Real); + WSTurb = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, WS3), + 1.0_Real / 3.0_Real); } - const Real match_visc_shape = - (LocUseInteriorMix && LocMatchTechnique == 1 && - h_obl > 0.0_Real && w_m_turb > 0.0_Real) + // For MatchBoth, the shape value the KPP profile must reach at + // the OBL base so that it joins the interior coefficient there. + const Real MatchViscShape = + (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth && + HOBL > 0.0_Real && WMTurb > 0.0_Real) ? LocInteriorVertVisc(ICell, KMatch) / - Kokkos::max(h_obl * w_m_turb, 1.0e-20_Real) + Kokkos::max(HOBL * WMTurb, 1.0e-20_Real) : 0.0_Real; - const Real match_diff_shape = - (LocUseInteriorMix && LocMatchTechnique == 1 && - h_obl > 0.0_Real && w_s_turb > 0.0_Real) + const Real MatchDiffShape = + (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth && + HOBL > 0.0_Real && WSTurb > 0.0_Real) ? LocInteriorVertDiff(ICell, KMatch) / - Kokkos::max(h_obl * w_s_turb, 1.0e-20_Real) + Kokkos::max(HOBL * WSTurb, 1.0e-20_Real) : 0.0_Real; // ======================================================== // Momentum mixing contribution. // ======================================================== - Real m1 = (LocUseInteriorMix && LocMatchTechnique == 1) - ? KPP::KPPProfileMatched(sigma, match_visc_shape) - : KPP::KPPProfileM1(sigma); - LocVertVisc(ICell, k) = h_obl * w_m_turb * m1; + Real ShapeM = + (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth) + ? KPP::kppShapeMatched(Sigma, MatchViscShape) + : KPP::kppShapeMomentum(Sigma); + LocVertVisc(ICell, K) = HOBL * WMTurb * ShapeM; // ======================================================== // Tracer mixing contribution. // ======================================================== - Real s1 = (LocUseInteriorMix && LocMatchTechnique == 1) - ? KPP::KPPProfileMatched(sigma, match_diff_shape) - : KPP::KPPProfileS1(sigma); - LocVertDiff(ICell, k) = h_obl * w_s_turb * s1; - LocTurbulentVelocityScale(ICell, k) = w_s_turb; + Real ShapeS = + (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth) + ? KPP::kppShapeMatched(Sigma, MatchDiffShape) + : KPP::kppShapeScalar(Sigma); + LocVertDiff(ICell, K) = HOBL * WSTurb * ShapeS; + LocTurbulentVelocityScale(ICell, K) = WSTurb; // ======================================================== - // Non-local flux: C_s * G(σ) + // Non-local flux: C_s * G(sigma), reusing the scalar + // diffusivity shape so gamma and K share one profile. // C_s = C* * kappa * (c_s * kappa * epsilon)^(1/3) // per Large et al. (1994) eq. 20 (~6.33 with default constants) // ======================================================== // Match CVMix behavior: apply non-local term only when // surface buoyancy forcing is unstable/neutral. - if (LocUseNonLocalFlux && b0 <= 0.0_Real) { - Real g_sigma = 0.0_Real; - if (LocMatchTechnique == 2) { - g_sigma = KPP::KPPProfileGParabolicNonLocal(sigma); - } else if (LocMatchTechnique == 1) { - g_sigma = KPP::KPPProfileGMatchBoth(sigma); - } else { - g_sigma = KPP::KPPProfileG(sigma); - } - LocVertNonLocalFlux(ICell, k) = LocNonLocalCs * g_sigma; + if (BuoyFlux <= 0.0_Real) { + LocVertNonLocalFlux(ICell, K) = LocNonLocalCs * ShapeS; } else { - LocVertNonLocalFlux(ICell, k) = 0.0; + LocVertNonLocalFlux(ICell, K) = 0.0; } } else { // Below OBL: preserve interior values for MatchBoth, otherwise // no KPP contribution. - LocVertDiff(ICell, k) = LocUseInteriorMix - ? LocInteriorVertDiff(ICell, k) - : 0.0_Real; - LocVertVisc(ICell, k) = LocUseInteriorMix - ? LocInteriorVertVisc(ICell, k) - : 0.0_Real; - LocVertNonLocalFlux(ICell, k) = 0.0; - LocTurbulentVelocityScale(ICell, k) = 0.0; + LocVertDiff(ICell, K) = LocUseInteriorMix + ? LocInteriorVertDiff(ICell, K) + : 0.0_Real; + LocVertVisc(ICell, K) = LocUseInteriorMix + ? LocInteriorVertVisc(ICell, K) + : 0.0_Real; + LocVertNonLocalFlux(ICell, K) = 0.0; + LocTurbulentVelocityScale(ICell, K) = 0.0; } } // Optional enhanced diffusion/viscosity treatment at OBL base. - // Match CVMix Appendix D weighting at the interface nearest h_obl. - if (LocUseEnhancedDiffusion && h_obl > 0.0_Real) { - const I4 k_obl = Kokkos::max( + // Match CVMix Appendix D weighting at the interface nearest HOBL: + // the OBL base rarely lands on an interface, so the coefficient at + // the neighboring interface KTarget is replaced by a quadratic blend + // of the KPP value extrapolated from KKtup and the value already + // there, weighted by where HOBL falls between the two cell centers. + if (LocUseEnhancedDiffusion && HOBL > 0.0_Real) { + const I4 KOBL = Kokkos::max( KMin, Kokkos::min(LocIndexBoundaryLayerDepth(ICell), KMax)); - const Real z_mid_obl = Ssh - ZMid(ICell, k_obl); - - const bool target_outside_obl = h_obl >= z_mid_obl; - const I4 k_ktup = - target_outside_obl ? k_obl : Kokkos::max(KMin, k_obl - 1); - const I4 k_target = target_outside_obl - ? Kokkos::min(k_obl + 1, KMax + 1) - : Kokkos::max(KMin + 1, k_obl); - - const Real z_ktup = Ssh - ZMid(ICell, k_ktup); - const Real z_next = (k_ktup < KMax) - ? (Ssh - ZMid(ICell, k_ktup + 1)) - : (Ssh - ZInterface(ICell, k_ktup + 1)); - const Real delta = Kokkos::fmax( + const Real ZMidOBL = Ssh - ZMid(ICell, KOBL); + + const bool TargetOutsideOBL = HOBL >= ZMidOBL; + const I4 KKtup = + TargetOutsideOBL ? KOBL : Kokkos::max(KMin, KOBL - 1); + const I4 KTarget = TargetOutsideOBL + ? Kokkos::min(KOBL + 1, KMax + 1) + : Kokkos::max(KMin + 1, KOBL); + + const Real ZKtup = Ssh - ZMid(ICell, KKtup); + const Real ZNext = (KKtup < KMax) + ? (Ssh - ZMid(ICell, KKtup + 1)) + : (Ssh - ZInterface(ICell, KKtup + 1)); + const Real Delta = Kokkos::fmax( 0.0_Real, Kokkos::fmin(1.0_Real, - (h_obl - z_ktup) / - Kokkos::max(z_next - z_ktup, 1.0e-12_Real))); - const Real one_minus_delta = 1.0_Real - delta; - - Real sigma_ktup = -z_ktup / h_obl; - sigma_ktup = - Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, sigma_ktup)); - const Real sigma_coord = -sigma_ktup; - const Real sigma_loc = Kokkos::fmin( - LocSurfaceLayerExtent, Kokkos::fmax(0.0_Real, sigma_coord)); - - Real w_m_ktup = 0.0_Real; - Real w_s_ktup = 0.0_Real; - if (u_star > 0.0_Real) { - const Real u3 = u_star * u_star * u_star; - const Real zeta = sigma_loc * h_obl * b0 * LocKappa / - Kokkos::max(u3, 1.0e-20_Real); - w_m_ktup = LocKappa * u_star * - Kokkos::max(KPP::KPPProfileM2(zeta), 0.0_Real); - w_s_ktup = LocKappa * u_star * - Kokkos::max(KPP::KPPProfileS2(zeta), 0.0_Real); - } else if (b0 < 0.0_Real) { - const Real wm3 = - -KPP::C_MO_M * sigma_loc * h_obl * LocKappa * b0; - const Real ws3 = - -KPP::C_MO_S * sigma_loc * h_obl * LocKappa * b0; - w_m_ktup = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, wm3), - 1.0_Real / 3.0_Real); - w_s_ktup = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, ws3), - 1.0_Real / 3.0_Real); + (HOBL - ZKtup) / + Kokkos::max(ZNext - ZKtup, 1.0e-12_Real))); + const Real OneMinusDelta = 1.0_Real - Delta; + + Real SigmaKtup = -ZKtup / HOBL; + SigmaKtup = + Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, SigmaKtup)); + const Real SigmaCoord = -SigmaKtup; + const Real SigmaLoc = Kokkos::fmin( + LocSurfaceLayerExtent, Kokkos::fmax(0.0_Real, SigmaCoord)); + + Real WMKtup = 0.0_Real; + Real WSKtup = 0.0_Real; + if (UStar > 0.0_Real) { + const Real U3 = UStar * UStar * UStar; + const Real Zeta = SigmaLoc * HOBL * BuoyFlux * LocKappa / + Kokkos::max(U3, 1.0e-20_Real); + WMKtup = LocKappa * UStar * + Kokkos::max(KPP::kppPhiInvMomentum(Zeta), 0.0_Real); + WSKtup = LocKappa * UStar * + Kokkos::max(KPP::kppPhiInvScalar(Zeta), 0.0_Real); + } else if (BuoyFlux < 0.0_Real) { + const Real WM3 = + -KPP::CMoM * SigmaLoc * HOBL * LocKappa * BuoyFlux; + const Real WS3 = + -KPP::CMoS * SigmaLoc * HOBL * LocKappa * BuoyFlux; + WMKtup = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, WM3), + 1.0_Real / 3.0_Real); + WSKtup = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, WS3), + 1.0_Real / 3.0_Real); } - const Real match_visc_shape = - (LocUseInteriorMix && LocMatchTechnique == 1 && - h_obl > 0.0_Real && w_m_ktup > 0.0_Real) + const Real MatchViscShape = + (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth && + HOBL > 0.0_Real && WMKtup > 0.0_Real) ? LocInteriorVertVisc(ICell, KMatch) / - Kokkos::max(h_obl * w_m_ktup, 1.0e-20_Real) + Kokkos::max(HOBL * WMKtup, 1.0e-20_Real) : 0.0_Real; - const Real match_diff_shape = - (LocUseInteriorMix && LocMatchTechnique == 1 && - h_obl > 0.0_Real && w_s_ktup > 0.0_Real) + const Real MatchDiffShape = + (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth && + HOBL > 0.0_Real && WSKtup > 0.0_Real) ? LocInteriorVertDiff(ICell, KMatch) / - Kokkos::max(h_obl * w_s_ktup, 1.0e-20_Real) + Kokkos::max(HOBL * WSKtup, 1.0e-20_Real) : 0.0_Real; - const Real visc_ktup = - h_obl * w_m_ktup * - ((LocUseInteriorMix && LocMatchTechnique == 1) - ? KPP::KPPProfileMatched(sigma_ktup, match_visc_shape) - : KPP::KPPProfileM1(sigma_ktup)); - const Real diff_ktup = - h_obl * w_s_ktup * - ((LocUseInteriorMix && LocMatchTechnique == 1) - ? KPP::KPPProfileMatched(sigma_ktup, match_diff_shape) - : KPP::KPPProfileS1(sigma_ktup)); - - const Real visc_profile = LocVertVisc(ICell, k_target); - const Real diff_profile = LocVertDiff(ICell, k_target); - - const Real enh_visc = - one_minus_delta * one_minus_delta * visc_ktup + - delta * delta * visc_profile; - const Real enh_diff = - one_minus_delta * one_minus_delta * diff_ktup + - delta * delta * diff_profile; - - const Real old_visc = LocUseInteriorMix - ? LocInteriorVertVisc(ICell, k_target) - : 0.0_Real; - const Real old_diff = LocUseInteriorMix - ? LocInteriorVertDiff(ICell, k_target) - : 0.0_Real; - const Real new_visc = - one_minus_delta * old_visc + delta * enh_visc; - const Real new_diff = - one_minus_delta * old_diff + delta * enh_diff; - - LocVertVisc(ICell, k_target) = new_visc; - LocVertDiff(ICell, k_target) = new_diff; - - if (!target_outside_obl && diff_profile != 0.0_Real) { - LocVertNonLocalFlux(ICell, k_target) = - LocVertNonLocalFlux(ICell, k_target) * new_diff / - diff_profile; - } else if (!target_outside_obl) { - LocVertNonLocalFlux(ICell, k_target) = 0.0_Real; + const Real ViscKtup = + HOBL * WMKtup * + ((LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth) + ? KPP::kppShapeMatched(SigmaKtup, MatchViscShape) + : KPP::kppShapeMomentum(SigmaKtup)); + const Real DiffKtup = + HOBL * WSKtup * + ((LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth) + ? KPP::kppShapeMatched(SigmaKtup, MatchDiffShape) + : KPP::kppShapeScalar(SigmaKtup)); + + const Real ViscProfile = LocVertVisc(ICell, KTarget); + const Real DiffProfile = LocVertDiff(ICell, KTarget); + + const Real EnhVisc = OneMinusDelta * OneMinusDelta * ViscKtup + + Delta * Delta * ViscProfile; + const Real EnhDiff = OneMinusDelta * OneMinusDelta * DiffKtup + + Delta * Delta * DiffProfile; + + const Real OldVisc = LocUseInteriorMix + ? LocInteriorVertVisc(ICell, KTarget) + : 0.0_Real; + const Real OldDiff = LocUseInteriorMix + ? LocInteriorVertDiff(ICell, KTarget) + : 0.0_Real; + const Real NewVisc = OneMinusDelta * OldVisc + Delta * EnhVisc; + const Real NewDiff = OneMinusDelta * OldDiff + Delta * EnhDiff; + + LocVertVisc(ICell, KTarget) = NewVisc; + LocVertDiff(ICell, KTarget) = NewDiff; + + // Keep the non-local term consistent with the rescaled diffusivity + if (!TargetOutsideOBL && DiffProfile != 0.0_Real) { + LocVertNonLocalFlux(ICell, KTarget) = + LocVertNonLocalFlux(ICell, KTarget) * NewDiff / DiffProfile; + } else if (!TargetOutsideOBL) { + LocVertNonLocalFlux(ICell, KTarget) = 0.0_Real; } } }); diff --git a/components/omega/src/ocn/KPPMix.h b/components/omega/src/ocn/KPPMix.h index 6729a993c9fd..4adb6a211430 100644 --- a/components/omega/src/ocn/KPPMix.h +++ b/components/omega/src/ocn/KPPMix.h @@ -26,6 +26,14 @@ namespace OMEGA { +/// @brief How the KPP profile is matched to interior mixing at the OBL base. +/// Also selects the shape used for the non-local flux, which follows the +/// scalar diffusivity profile. +enum class KPPMatchType : I4 { + SimpleShapes = 0, ///< Unmatched Large et al. (1994) cubic shapes + MatchBoth = 1 ///< Match the interior coefficient at the OBL base +}; + /// @brief KPP Boundary Layer Mixing Scheme /// /// Implements the K-Profile Parameterization following Large et al. (1994) @@ -53,8 +61,8 @@ class KPPMix { /// Output arrays are computed in-place. void computeKPPMix( const Array2DReal - &PotentialDensity, ///< Density (kg/m³) [NCells×NLevels] - const Array2DReal &NormalVelocity, ///< Normal vel on edges (m/s) + &PotentialDensity, ///< Density (kg/m³) [NCells×NLevels] + const Array2DReal &NormalVelocity, ///< Normal vel on edges (m/s) const Array2DReal &TangentialVelocity, ///< Tangential vel on edges (m/s) const Array1DReal &SurfaceFrictionVelocity, ///< u* (m/s) const Array1DReal &SurfaceBuoyancyFlux, ///< B_0 (m²/s³) @@ -128,28 +136,30 @@ class KPPMix { bool Enabled = true; ///< Enable/disable KPP mixing - Real CriticalRichardson = 0.3; ///< Ri_crit for OBL criterion - Real StopOBLSearchMult = 1.0; ///< Safety multiplier for search - Real SurfaceLayerExtent = 0.1; ///< Surface layer fraction of OBL + // Defaults below may be overridden from the Config file; where a value also + // appears in KPPConstants.h, that is the authoritative default. + Real CriticalRichardson = 0.25; ///< Ri_crit for OBL base + Real StopOBLSearchMult = KPP::StopOBLSearchMult; ///< Search safety mult + Real SurfaceLayerExtent = KPP::SurfaceLayerExtent; ///< Frac of OBL depth bool UseLangmuirCirculation = true; ///< Apply wave enhancement - bool UseNonLocalFlux = true; ///< Apply non-local tracer flux bool DebugDiagnostics = false; ///< Print per-step KPP diagnostics // Ice/Langmuir controls (kept configurable to match reference semantics) - Real IceFractionThresholdForLangmuir = 0.05; ///< Disable Langmuir above this - Real IceFractionThresholdForMinimumOBL = 0.15; ///< Apply min OBL above this - Real MinimumOBLUnderSeaIce = 5.0; ///< Min OBL depth under sea ice (m) + /// Disable Langmuir above this ice fraction + Real IceFractionThresholdForLangmuir = KPP::IceFracThresh; + /// Apply minimum OBL depth above this ice fraction + Real IceFractionThresholdForMinimumOBL = KPP::IceSuppressThresh; + /// Min OBL depth under sea ice (m) + Real MinimumOBLUnderSeaIce = KPP::MinOBLUnderIce; Real BackgroundVisc = 1.0e-4; ///< Background viscosity below OBL (m²/s) Real BackgroundDiff = 1.0e-5; ///< Background diffusivity below OBL (m²/s) // KPP matching/profile controls (CVMix-style semantics) - std::string MatchTechniqueStr = - "SimpleShapes"; ///< SimpleShapes, MatchGradient, MatchBoth, - ///< ParabolicNonLocal - std::string InterpType2Str = "LMD94"; ///< Linear, Quadratic, Cubic, LMD94 - bool UseEnhancedDiffusion = true; ///< Apply enhanced mixing at OBL base + KPPMatchType MatchTechnique = KPPMatchType::SimpleShapes; + std::string InterpType2Str = "LMD94"; ///< Linear, Quadratic, Cubic, LMD94 + bool UseEnhancedDiffusion = true; ///< Apply enhanced mixing at OBL base bool UseBLDSmoothing = true; ///< Apply MPAS-style BLD horizontal smoothing // Field names for I/O @@ -170,8 +180,8 @@ class KPPMix { private: /// @brief Private constructor for singleton pattern - KPPMix(const std::string &Name_in, const HorzMesh *Mesh_in, - const VertCoord *VCoord_in); + KPPMix(const std::string &InName, const HorzMesh *InMesh, + const VertCoord *InVCoord); /// @brief Private destructor ~KPPMix(); diff --git a/components/omega/src/ocn/VertMix.cpp b/components/omega/src/ocn/VertMix.cpp index 207b6279970b..914543851b46 100644 --- a/components/omega/src/ocn/VertMix.cpp +++ b/components/omega/src/ocn/VertMix.cpp @@ -328,7 +328,7 @@ void VertMix::computeVertMix(const Array2DReal &NormalVelocity, if (LocKPPEnabled) { const I4 NVertLayers = VCoord->NVertLayers; I4 KPPMergeMode = 0; // 0=additive profile, 1=matched coefficients - if (KPPInstance->MatchTechniqueStr == "MatchBoth") { + if (KPPInstance->MatchTechnique == KPPMatchType::MatchBoth) { KPPMergeMode = 1; } diff --git a/components/omega/test/CMakeLists.txt b/components/omega/test/CMakeLists.txt index 14afabb71607..d8d00b057484 100644 --- a/components/omega/test/CMakeLists.txt +++ b/components/omega/test/CMakeLists.txt @@ -613,8 +613,16 @@ add_omega_test( add_omega_ctest(KPP_BLD_TEST testKPPMix.exe "-n;1" bld) add_omega_ctest(KPP_VMIX_COEFF_TEST testKPPMix.exe "-n;1" vmix) add_omega_ctest(KPP_INTEGRATION_TEST testKPPMix.exe "-n;1" integration) +# Each injects a rejected MatchTechnique and must abort in KPPMix::init. add_omega_ctest(KPP_CONFIG_GRADIENT_TEST testKPPMix.exe "-n;1" config-gradient) add_omega_ctest(KPP_CONFIG_UNSUPPORTED_TEST testKPPMix.exe "-n;1" config-unsupported) +add_omega_ctest(KPP_CONFIG_PARABOLIC_TEST testKPPMix.exe "-n;1" config-parabolic) +set_tests_properties( + KPP_CONFIG_GRADIENT_TEST + KPP_CONFIG_UNSUPPORTED_TEST + KPP_CONFIG_PARABOLIC_TEST + PROPERTIES WILL_FAIL TRUE +) ################## # VAdv test diff --git a/components/omega/test/ocn/KPPMixTest.cpp b/components/omega/test/ocn/KPPMixTest.cpp index d6067ac9c2b9..01a70d67f266 100644 --- a/components/omega/test/ocn/KPPMixTest.cpp +++ b/components/omega/test/ocn/KPPMixTest.cpp @@ -58,7 +58,10 @@ void initKPPMixTest(const std::string &TestGroup) { Config("Omega"); Config::readAll("omega.yml"); - if (TestGroup == "config-gradient" || TestGroup == "config-unsupported") { + // These groups inject a rejected MatchTechnique; KPPMix::init must abort, + // so the ctest entries for them are registered as expected failures. + if (TestGroup == "config-gradient" || TestGroup == "config-unsupported" || + TestGroup == "config-parabolic") { Config VertMixConfig("VertMix"); Config KPPConfig("KPP"); Error Err; @@ -66,7 +69,9 @@ void initKPPMixTest(const std::string &TestGroup) { Err += VertMixConfig.get(KPPConfig); CHECK_ERROR_ABORT(Err, "KPPMixTest: unable to access KPP configuration"); const std::string MatchTechnique = - TestGroup == "config-gradient" ? "MatchGradient" : "NotAKPPMode"; + TestGroup == "config-gradient" ? "MatchGradient" + : TestGroup == "config-parabolic" ? "ParabolicNonLocal" + : "NotAKPPMode"; KPPConfig.set("MatchTechnique", MatchTechnique); } IO::init(DefComm); @@ -128,16 +133,16 @@ void testStabilityFunctions() { Zeta = -0.1_Real; break; case 4: - Zeta = KPP::ZETA_M; + Zeta = KPP::ZetaM; break; case 5: - Zeta = KPP::ZETA_M - 1.0e-4_Real; + Zeta = KPP::ZetaM - 1.0e-4_Real; break; case 6: - Zeta = KPP::ZETA_S; + Zeta = KPP::ZetaS; break; case 7: - Zeta = KPP::ZETA_S - 1.0e-4_Real; + Zeta = KPP::ZetaS - 1.0e-4_Real; break; default: Zeta = -10.0_Real; @@ -147,25 +152,25 @@ void testStabilityFunctions() { Real ExpectedM; if (Zeta >= 0.0_Real) { ExpectedM = 1.0_Real / (1.0_Real + 5.0_Real * Zeta); - } else if (Zeta >= KPP::ZETA_M) { + } else if (Zeta >= KPP::ZetaM) { ExpectedM = Kokkos::pow(1.0_Real - 16.0_Real * Zeta, 0.25_Real); } else { - ExpectedM = Kokkos::pow(KPP::A_MO_M - KPP::C_MO_M * Zeta, - 1.0_Real / 3.0_Real); + ExpectedM = + Kokkos::pow(KPP::AMoM - KPP::CMoM * Zeta, 1.0_Real / 3.0_Real); } Real ExpectedS; if (Zeta >= 0.0_Real) { ExpectedS = 1.0_Real / (1.0_Real + 5.0_Real * Zeta); - } else if (Zeta >= KPP::ZETA_S) { + } else if (Zeta >= KPP::ZetaS) { ExpectedS = Kokkos::sqrt(1.0_Real - 16.0_Real * Zeta); } else { - ExpectedS = Kokkos::pow(KPP::A_MO_S - KPP::C_MO_S * Zeta, - 1.0_Real / 3.0_Real); + ExpectedS = + Kokkos::pow(KPP::AMoS - KPP::CMoS * Zeta, 1.0_Real / 3.0_Real); } - const Real ActualM = KPP::KPPProfileM2(Zeta); - const Real ActualS = KPP::KPPProfileS2(Zeta); + const Real ActualM = KPP::kppPhiInvMomentum(Zeta); + const Real ActualS = KPP::kppPhiInvScalar(Zeta); if (!isApprox(ActualM, ExpectedM, RTol, ATol) || ActualM <= 0.0_Real) ++ErrorCount; if (!isApprox(ActualS, ExpectedS, RTol, ATol) || ActualS <= 0.0_Real) @@ -179,14 +184,14 @@ void testStabilityFunctions() { parallelReduce( "KPPMixTest-StabilityContinuity", {2}, KOKKOS_LAMBDA(int ITest, int &ErrorCount) { - const Real Transition = ITest == 0 ? KPP::ZETA_M : KPP::ZETA_S; + const Real Transition = ITest == 0 ? KPP::ZetaM : KPP::ZetaS; const Real Epsilon = 1.0e-6_Real; - const Real Above = ITest == 0 - ? KPP::KPPProfileM2(Transition + Epsilon) - : KPP::KPPProfileS2(Transition + Epsilon); - const Real Below = ITest == 0 - ? KPP::KPPProfileM2(Transition - Epsilon) - : KPP::KPPProfileS2(Transition - Epsilon); + const Real Above = ITest == 0 + ? KPP::kppPhiInvMomentum(Transition + Epsilon) + : KPP::kppPhiInvScalar(Transition + Epsilon); + const Real Below = ITest == 0 + ? KPP::kppPhiInvMomentum(Transition - Epsilon) + : KPP::kppPhiInvScalar(Transition - Epsilon); if (!isApprox(Above, Below, 2.0e-5_Real, 2.0e-5_Real)) ++ErrorCount; }, @@ -228,36 +233,26 @@ void testShapeFunctions() { const Real SigmaClamped = Kokkos::fmax(-1.0_Real, Kokkos::fmin(0.0_Real, Sigma)); - const Real SigmaMu = -SigmaClamped; - const Real OneMinus = 1.0_Real - SigmaMu; - const Real ExpectedSimple = SigmaMu * OneMinus * OneMinus; - const Real ExpectedParabolic = OneMinus * OneMinus; - const Real ExpectedMatchBoth = - OneMinus * OneMinus * (1.0_Real + 2.0_Real * SigmaMu); + const Real SigmaMu = -SigmaClamped; + const Real OneMinus = 1.0_Real - SigmaMu; + const Real ExpectedSimple = SigmaMu * OneMinus * OneMinus; constexpr Real ShapeAtBase = 0.125_Real; const Real Smooth = SigmaMu * SigmaMu * (3.0_Real - 2.0_Real * SigmaMu); - if (!isApprox(KPP::KPPProfileG(Sigma), ExpectedSimple, RTol, ATol)) - ++ErrorCount; - if (!isApprox(KPP::KPPProfileM1(Sigma), ExpectedSimple, RTol, ATol)) - ++ErrorCount; - if (!isApprox(KPP::KPPProfileS1(Sigma), ExpectedSimple, RTol, ATol)) - ++ErrorCount; - if (!isApprox(KPP::KPPProfileGParabolicNonLocal(Sigma), - ExpectedParabolic, RTol, ATol)) + if (!isApprox(KPP::kppShapeMomentum(Sigma), ExpectedSimple, RTol, + ATol)) ++ErrorCount; - if (!isApprox(KPP::KPPProfileGMatchBoth(Sigma), ExpectedMatchBoth, - RTol, ATol)) + if (!isApprox(KPP::kppShapeScalar(Sigma), ExpectedSimple, RTol, ATol)) ++ErrorCount; - if (!isApprox(KPP::KPPProfileMatched(Sigma, ShapeAtBase), + if (!isApprox(KPP::kppShapeMatched(Sigma, ShapeAtBase), ExpectedSimple + ShapeAtBase * Smooth, RTol, ATol)) ++ErrorCount; - if (!isApprox(KPP::KPPProfileMatched(Sigma, 0.0_Real), ExpectedSimple, + if (!isApprox(KPP::kppShapeMatched(Sigma, 0.0_Real), ExpectedSimple, RTol, ATol)) ++ErrorCount; - if (!isApprox(KPP::KPPHu(Sigma), KPP::HUON * (1.0_Real + Sigma), RTol, - ATol)) + if (!isApprox(KPP::kppSurfaceMomentumScale(Sigma), + KPP::HuOn * (1.0_Real + Sigma), RTol, ATol)) ++ErrorCount; }, NumErrors); @@ -278,7 +273,7 @@ void testLangmuirFunctions() { const Real UStar = ITest < 2 ? 0.0_Real : 0.01_Real; const Real WindClamped = Kokkos::fmax(0.0_Real, Wind); const Real ExpectedStokes = 0.016_Real * WindClamped; - const Real UStarClamped = Kokkos::fmax(KPP::MIN_USTAR, UStar); + const Real UStarClamped = Kokkos::fmax(KPP::MinUStar, UStar); const Real StokesClamped = Kokkos::fmax(1.0e-8_Real, ExpectedStokes); const Real ExpectedLa = Kokkos::sqrt(UStarClamped / StokesClamped); const Real LaInv = 1.0_Real / Kokkos::fmax(0.5_Real, ExpectedLa); @@ -287,10 +282,10 @@ void testLangmuirFunctions() { Kokkos::fmax(1.0_Real, Kokkos::sqrt(1.0_Real + 0.5_Real * LaInv * LaInv))); - const Real Stokes = KPP::EstokesSLModel(Wind, 50.0_Real); - const Real La = KPP::ComputeLangmuirNumber(UStar, Stokes); + const Real Stokes = KPP::estimateStokesDriftSL(Wind, 50.0_Real); + const Real La = KPP::computeLangmuirNumber(UStar, Stokes); const Real Enhancement = - KPP::ComputeEnhancementFactor(Wind, UStar, 50.0_Real); + KPP::computeLangmuirEnhancement(Wind, UStar, 50.0_Real); if (!isApprox(Stokes, ExpectedStokes, RTol, ATol)) ++ErrorCount; if (!isApprox(La, ExpectedLa, RTol, ATol)) @@ -310,15 +305,15 @@ void testOBLUtilities() { parallelReduce( "KPPMixTest-OBLUtilities", {4}, KOKKOS_LAMBDA(int ITest, int &ErrorCount) { - const Real IceFraction = - ITest == 0 ? 0.0_Real - : ITest == 1 ? KPP::ICE_SUPPRESSION_THRESHOLD - : ITest == 2 ? KPP::ICE_SUPPRESSION_THRESHOLD + 0.01_Real - : 0.0_Real; - const I4 LandIceMask = ITest == 3 ? 1 : 0; + const Real IceFraction = ITest == 0 ? 0.0_Real + : ITest == 1 ? KPP::IceSuppressThresh + : ITest == 2 + ? KPP::IceSuppressThresh + 0.01_Real + : 0.0_Real; + const I4 LandIceMask = ITest == 3 ? 1 : 0; const bool ExpectedSuppression = - LandIceMask != 0 || IceFraction > KPP::ICE_SUPPRESSION_THRESHOLD; - if (KPP::ShouldSuppressOBL(IceFraction, LandIceMask) != + LandIceMask != 0 || IceFraction > KPP::IceSuppressThresh; + if (KPP::shouldSuppressOBL(IceFraction, LandIceMask) != ExpectedSuppression) ++ErrorCount; @@ -327,11 +322,10 @@ void testOBLUtilities() { : ITest == 2 ? 1.0_Real : 200.0_Real; Real ExpectedDepth = Kokkos::fmax(InputDepth, 2.0_Real); - if (IceFraction > KPP::ICE_SUPPRESSION_THRESHOLD) - ExpectedDepth = - Kokkos::fmax(ExpectedDepth, KPP::MIN_OBL_UNDER_ICE); + if (IceFraction > KPP::IceSuppressThresh) + ExpectedDepth = Kokkos::fmax(ExpectedDepth, KPP::MinOBLUnderIce); ExpectedDepth = Kokkos::fmin(ExpectedDepth, 95.0_Real); - if (!isApprox(KPP::ConstrainOBLDepth(InputDepth, 4.0_Real, 100.0_Real, + if (!isApprox(KPP::constrainOBLDepth(InputDepth, 4.0_Real, 100.0_Real, IceFraction), ExpectedDepth, RTol, ATol)) ++ErrorCount; @@ -362,10 +356,10 @@ void testTurbulentVelocityScale() { const Real HClamped = Kokkos::fmax(0.0_Real, H); const Real Momentum = UStarClamped * UStarClamped * UStarClamped; const Real Buoyancy = - 0.35_Real * Kokkos::fmax(0.0_Real, -B0) * HClamped; + KPP::ConvectiveVelFac * Kokkos::fmax(0.0_Real, -B0) * HClamped; const Real Expected = Kokkos::pow(Momentum + Buoyancy, 1.0_Real / 3.0_Real); - const Real Actual = KPP::ComputeTurbulentVelocityScale(UStar, B0, H); + const Real Actual = KPP::computeTurbVelocityScale(UStar, B0, H); if (!isApprox(Actual, Expected, RTol, ATol) || Actual < 0.0_Real) ++ErrorCount; }, @@ -408,7 +402,7 @@ void setCoefficientTestGeometry(Real Ssh = 0.0_Real) { Real nonLocalNormalization() { return 10.0_Real * VonKar * - Kokkos::pow(KPP::C_MO_S * VonKar * KPP::SURFACE_LAYER_EXTENT, + Kokkos::pow(KPP::CMoS * VonKar * KPP::SurfaceLayerExtent, 1.0_Real / 3.0_Real); } @@ -427,8 +421,7 @@ void testWindOnlyCoefficients() { deepCopy(B0, 0.0_Real); KPPInstance->UseEnhancedDiffusion = false; - KPPInstance->UseNonLocalFlux = true; - KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; KPPInstance->computeMixingCoefficients(Density, UStar, B0); const auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); @@ -450,7 +443,7 @@ void testWindOnlyCoefficients() { !isApprox(NonLocalH(ICell, 2), ExpectedNonLocal, RTol, ATol)) { ++NumErrors; } - if (!isApprox(KPP::KPPProfileM1(Sigma), Shape, RTol, ATol) || + if (!isApprox(KPP::kppShapeMomentum(Sigma), Shape, RTol, ATol) || VertDiffH(ICell, 0) != 0.0_Real || VertViscH(ICell, 0) != 0.0_Real || VertDiffH(ICell, 4) != 0.0_Real || VertViscH(ICell, 4) != 0.0_Real || VertDiffH(ICell, 5) != 0.0_Real || NonLocalH(ICell, 5) != 0.0_Real) { @@ -475,19 +468,18 @@ void testConvectionOnlyCoefficients() { deepCopy(B0, -1.0e-7_Real); KPPInstance->UseEnhancedDiffusion = false; - KPPInstance->UseNonLocalFlux = true; - KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; KPPInstance->computeMixingCoefficients(Density, UStar, B0); const auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); const auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); const auto TurbVelH = createHostMirrorCopy(KPPInstance->TurbulentVelocityScale); - const Real SigmaLoc = KPP::SURFACE_LAYER_EXTENT; - const Real WM = VonKar * Kokkos::pow(KPP::C_MO_M * SigmaLoc * TestOBLDepth * + const Real SigmaLoc = KPP::SurfaceLayerExtent; + const Real WM = VonKar * Kokkos::pow(KPP::CMoM * SigmaLoc * TestOBLDepth * VonKar * 1.0e-7_Real, 1.0_Real / 3.0_Real); - const Real WS = VonKar * Kokkos::pow(KPP::C_MO_S * SigmaLoc * TestOBLDepth * + const Real WS = VonKar * Kokkos::pow(KPP::CMoS * SigmaLoc * TestOBLDepth * VonKar * 1.0e-7_Real, 1.0_Real / 3.0_Real); constexpr Real Shape = 0.125_Real; @@ -521,43 +513,37 @@ void testNonLocalProfileModes() { deepCopy(B0, 0.0_Real); KPPInstance->UseEnhancedDiffusion = false; - KPPInstance->UseNonLocalFlux = true; const Real Normalization = nonLocalNormalization(); int NumErrors = 0; - KPPInstance->MatchTechniqueStr = "ParabolicNonLocal"; + // The non-local flux follows the scalar diffusivity shape, so at sigma=-0.5 + // it is 0.125 * C_s and it vanishes at the surface and at the OBL base. + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; KPPInstance->computeMixingCoefficients(Density, UStar, B0); auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { - if (!isApprox(NonLocalH(ICell, 0), Normalization, RTol, ATol) || - !isApprox(NonLocalH(ICell, 2), 0.25_Real * Normalization, RTol, + if (NonLocalH(ICell, 0) != 0.0_Real || + !isApprox(NonLocalH(ICell, 2), 0.125_Real * Normalization, RTol, ATol) || NonLocalH(ICell, 4) != 0.0_Real) { ++NumErrors; } } - KPPInstance->MatchTechniqueStr = "MatchBoth"; + // Without interior coefficients there is nothing to match, so MatchBoth + // must reduce exactly to SimpleShapes. + const auto SimpleShapesH = NonLocalH; + KPPInstance->MatchTechnique = KPPMatchType::MatchBoth; KPPInstance->computeMixingCoefficients(Density, UStar, B0); NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { - if (!isApprox(NonLocalH(ICell, 0), Normalization, RTol, ATol) || - !isApprox(NonLocalH(ICell, 2), 0.5_Real * Normalization, RTol, - ATol) || - NonLocalH(ICell, 4) != 0.0_Real) { - ++NumErrors; + for (I4 K = 0; K <= VCoord->NVertLayers; ++K) { + if (NonLocalH(ICell, K) != SimpleShapesH(ICell, K)) { + ++NumErrors; + } } } - KPPInstance->UseNonLocalFlux = false; - KPPInstance->MatchTechniqueStr = "SimpleShapes"; - KPPInstance->computeMixingCoefficients(Density, UStar, B0); - NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); - for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { - if (NonLocalH(ICell, 0) != 0.0_Real || NonLocalH(ICell, 2) != 0.0_Real) { - ++NumErrors; - } - } checkResult("non-local profile modes", NumErrors); } @@ -585,8 +571,7 @@ void testMatchBothInteriorCoefficients() { deepCopy(InteriorVisc, ExpectedInteriorVisc); KPPInstance->UseEnhancedDiffusion = false; - KPPInstance->UseNonLocalFlux = true; - KPPInstance->MatchTechniqueStr = "MatchBoth"; + KPPInstance->MatchTechnique = KPPMatchType::MatchBoth; KPPInstance->computeMixingCoefficients(Density, UStar, B0, InteriorDiff, InteriorVisc); @@ -598,11 +583,12 @@ void testMatchBothInteriorCoefficients() { constexpr Real SmoothAtSigma = 0.5_Real; const Real TurbVel = VonKar * 0.02_Real; const Real ExpectedDiffMid = TestOBLDepth * TurbVel * SimpleShape + - SmoothAtSigma * ExpectedInteriorDiff; - const Real ExpectedViscMid = TestOBLDepth * TurbVel * SimpleShape + - SmoothAtSigma * ExpectedInteriorVisc; + SmoothAtSigma * ExpectedInteriorDiff; + const Real ExpectedViscMid = TestOBLDepth * TurbVel * SimpleShape + + SmoothAtSigma * ExpectedInteriorVisc; + const Real MatchDiffShape = ExpectedInteriorDiff / (TestOBLDepth * TurbVel); const Real ExpectedNonLocal = - nonLocalNormalization() * KPP::KPPProfileGMatchBoth(Sigma); + nonLocalNormalization() * KPP::kppShapeMatched(Sigma, MatchDiffShape); int NumErrors = 0; for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { @@ -635,8 +621,7 @@ void testEnhancedDiffusion() { deepCopy(UStar, 0.02_Real); deepCopy(B0, 0.0_Real); - KPPInstance->UseNonLocalFlux = true; - KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; KPPInstance->UseEnhancedDiffusion = false; KPPInstance->computeMixingCoefficients(Density, UStar, B0); auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); @@ -655,7 +640,7 @@ void testEnhancedDiffusion() { constexpr Real OutsideDelta = 0.5_Real; const Real OutsideSigma = -35.0_Real / TestOBLDepth; const Real OutsideProfile = - TestOBLDepth * VonKar * 0.02_Real * KPP::KPPProfileS1(OutsideSigma); + TestOBLDepth * VonKar * 0.02_Real * KPP::kppShapeScalar(OutsideSigma); const Real ExpectedOutside = OutsideDelta * (1.0_Real - OutsideDelta) * (1.0_Real - OutsideDelta) * OutsideProfile; for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { @@ -679,14 +664,14 @@ void testEnhancedDiffusion() { const Real KtupSigma = -25.0_Real / InsideOBLDepth; const Real TargetSigma = -30.0_Real / InsideOBLDepth; const Real KtupProfile = - InsideOBLDepth * VonKar * 0.02_Real * KPP::KPPProfileS1(KtupSigma); + InsideOBLDepth * VonKar * 0.02_Real * KPP::kppShapeScalar(KtupSigma); const Real TargetProfile = - InsideOBLDepth * VonKar * 0.02_Real * KPP::KPPProfileS1(TargetSigma); + InsideOBLDepth * VonKar * 0.02_Real * KPP::kppShapeScalar(TargetSigma); const Real ExpectedInside = InsideDelta * (OneMinusInsideDelta * OneMinusInsideDelta * KtupProfile + InsideDelta * InsideDelta * TargetProfile); const Real ExpectedInsideNonLocal = nonLocalNormalization() * - KPP::KPPProfileG(TargetSigma) * + KPP::kppShapeScalar(TargetSigma) * ExpectedInside / TargetProfile; for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { if (!isApprox(VertDiffH(ICell, 3), ExpectedInside, RTol, ATol) || @@ -708,7 +693,7 @@ void testEnhancedDiffusion() { deepCopy(B0, 0.0_Real); deepCopy(KPPInstance->BoundaryLayerDepth, TestOBLDepth); deepCopy(KPPInstance->IndexBoundaryLayerDepth, TestOBLIndex); - KPPInstance->MatchTechniqueStr = "MatchBoth"; + KPPInstance->MatchTechnique = KPPMatchType::MatchBoth; KPPInstance->computeMixingCoefficients(Density, UStar, B0, InteriorDiff, InteriorVisc); VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); @@ -720,10 +705,10 @@ void testEnhancedDiffusion() { InteriorViscValue / (TestOBLDepth * VonKar * 0.02_Real); const Real DiffKtup = TestOBLDepth * VonKar * 0.02_Real * - KPP::KPPProfileMatched(InteriorKtupSigma, DiffMatchShape); + KPP::kppShapeMatched(InteriorKtupSigma, DiffMatchShape); const Real ViscKtup = TestOBLDepth * VonKar * 0.02_Real * - KPP::KPPProfileMatched(InteriorKtupSigma, ViscMatchShape); + KPP::kppShapeMatched(InteriorKtupSigma, ViscMatchShape); const Real ExpectedInteriorEnhancedDiff = 0.625_Real * InteriorDiffValue + 0.125_Real * DiffKtup; const Real ExpectedInteriorEnhancedVisc = @@ -739,7 +724,7 @@ void testEnhancedDiffusion() { deepCopy(UStar, 0.0_Real); deepCopy(B0, 0.0_Real); - KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; deepCopy(KPPInstance->BoundaryLayerDepth, InsideOBLDepth); deepCopy(KPPInstance->IndexBoundaryLayerDepth, TestOBLIndex); KPPInstance->computeMixingCoefficients(Density, UStar, B0); @@ -770,8 +755,7 @@ void testStableAndZeroForcing() { deepCopy(B0, 1.0e-7_Real); KPPInstance->UseEnhancedDiffusion = false; - KPPInstance->UseNonLocalFlux = true; - KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; KPPInstance->computeMixingCoefficients(Density, UStar, B0); auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); @@ -785,7 +769,6 @@ void testStableAndZeroForcing() { deepCopy(UStar, 0.0_Real); deepCopy(B0, 0.0_Real); - KPPInstance->UseNonLocalFlux = false; KPPInstance->computeMixingCoefficients(Density, UStar, B0); VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); const auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); @@ -825,14 +808,13 @@ void testCoefficientVerticalDomainEdges() { }); KPPInstance->UseEnhancedDiffusion = false; - KPPInstance->UseNonLocalFlux = true; - KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; KPPInstance->computeMixingCoefficients(Density, UStar, B0); auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); auto VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); auto NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); const Real ExpectedPartial = - 40.0_Real * VonKar * 0.02_Real * KPP::KPPProfileS1(-0.5_Real); + 40.0_Real * VonKar * 0.02_Real * KPP::kppShapeScalar(-0.5_Real); int NumErrors = 0; for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { if (VertDiffH(ICell, 0) != 0.0_Real || VertDiffH(ICell, 1) != 0.0_Real || @@ -857,7 +839,7 @@ void testCoefficientVerticalDomainEdges() { VertViscH = createHostMirrorCopy(KPPInstance->VertVisc); NonLocalH = createHostMirrorCopy(KPPInstance->VertNonLocalFlux); const Real ExpectedOneLayer = - 25.0_Real * VonKar * 0.02_Real * KPP::KPPProfileS1(-0.8_Real); + 25.0_Real * VonKar * 0.02_Real * KPP::kppShapeScalar(-0.8_Real); for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { if (!isApprox(VertDiffH(ICell, 2), ExpectedOneLayer, RTol, ATol) || !isApprox(VertViscH(ICell, 2), ExpectedOneLayer, RTol, ATol) || @@ -900,8 +882,7 @@ void testCoefficientInvalidWetBounds() { }); KPPInstance->UseEnhancedDiffusion = false; - KPPInstance->UseNonLocalFlux = true; - KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; KPPInstance->computeMixingCoefficients(Density, UStar, B0); const auto VertDiffH = createHostMirrorCopy(KPPInstance->VertDiff); @@ -925,13 +906,6 @@ void testCoefficientInvalidWetBounds() { checkResult("coefficient invalid wet bounds", NumErrors); } -void testConfigurationNormalization() { - KPPMix *KPPInstance = KPPMix::getInstance(); - const int NumErrors = - KPPInstance->MatchTechniqueStr == "SimpleShapes" ? 0 : 1; - checkResult("configuration match normalization", NumErrors); -} - void testConfiguredValues() { Config VertMixConfig("VertMix"); Config KPPConfig("KPP"); @@ -940,7 +914,6 @@ void testConfiguredValues() { Err += VertMixConfig.get(KPPConfig); bool ExpectedEnabled = false; - bool ExpectedNonLocal = false; bool ExpectedSmoothing = false; bool ExpectedEnhanced = false; bool ExpectedDebug = true; @@ -951,7 +924,6 @@ void testConfiguredValues() { std::string ExpectedMatch; std::string ExpectedInterp; Err += KPPConfig.get("Enable", ExpectedEnabled); - Err += KPPConfig.get("UseNonLocalFlux", ExpectedNonLocal); Err += KPPConfig.get("UseBLDSmoothing", ExpectedSmoothing); Err += KPPConfig.get("UseEnhancedDiffusion", ExpectedEnhanced); Err += KPPConfig.get("DebugDiagnostics", ExpectedDebug); @@ -964,14 +936,17 @@ void testConfiguredValues() { Err += KPPConfig.get("InterpType2", ExpectedInterp); CHECK_ERROR_ABORT(Err, "KPPMixTest: unable to read configured KPP values"); + const KPPMatchType ExpectedMatchType = ExpectedMatch == "MatchBoth" + ? KPPMatchType::MatchBoth + : KPPMatchType::SimpleShapes; + const KPPMix *KPPInstance = KPPMix::getInstance(); int NumErrors = 0; if (KPPInstance->Enabled != ExpectedEnabled || - KPPInstance->UseNonLocalFlux != ExpectedNonLocal || KPPInstance->UseBLDSmoothing != ExpectedSmoothing || KPPInstance->UseEnhancedDiffusion != ExpectedEnhanced || KPPInstance->DebugDiagnostics != ExpectedDebug || - KPPInstance->MatchTechniqueStr != ExpectedMatch || + KPPInstance->MatchTechnique != ExpectedMatchType || KPPInstance->InterpType2Str != ExpectedInterp || !isApprox(KPPInstance->CriticalRichardson, ExpectedCriticalRi, RTol, ATol) || @@ -1017,10 +992,10 @@ void testBoundaryLayerDepth() { deepCopy(BVF, 1.0_Real); deepCopy(IceFraction, 0.0_Real); - constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SurfaceLayerExtent; constexpr Real TestN = 1.0_Real; const Real UnresolvedShearConstant = - Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + Kokkos::sqrt(0.2_Real / (KPP::CMoS * KPP::SurfaceLayerExtent)) / (VonKar * VonKar); const Real WindTurbulentScale = VonKar * 0.02_Real; parallelFor( @@ -1034,7 +1009,7 @@ void testBoundaryLayerDepth() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1042,7 +1017,7 @@ void testBoundaryLayerDepth() { KPPInstance->CriticalRichardson = 0.25_Real; KPPInstance->StopOBLSearchMult = 1.0_Real; - KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, @@ -1066,8 +1041,8 @@ void testBoundaryLayerDepth() { Slope * Slope - 4.0_Real * Quadratic * (RiAbove - 0.25_Real); const Real ExpectedBLD = ZAbove + (-Slope + Kokkos::sqrt(Discriminant)) / (2.0_Real * Quadratic); - const Real ExpectedVt2 = 1.7_Real * UnresolvedShearConstant * 25.0_Real * - TestN * WindTurbulentScale / 0.25_Real; + const Real ExpectedVt2 = 1.7_Real * UnresolvedShearConstant * 25.0_Real * + TestN * WindTurbulentScale / 0.25_Real; const Real ExpectedDeltaB = 0.4_Real * ExpectedVt2 / (RiScaling * 25.0_Real); int NumErrors = 0; @@ -1093,7 +1068,7 @@ void testBoundaryLayerDepth() { : 0.3_Real; const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1169,7 +1144,7 @@ void testBoundaryLayerDepth() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1227,7 +1202,7 @@ void testBoundaryLayerDepth() { const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real TargetRi = K == 0 ? 0.0_Real : 1.0_Real; const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1274,11 +1249,11 @@ void testBoundaryLayerNonuniformThickness() { OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); OMEGA_SCOPE(MaxLayerCell, VCoord->MaxLayerCell); - constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SurfaceLayerExtent; constexpr Real TestN = 1.0_Real; constexpr Real TestUStar = 0.02_Real; const Real UnresolvedShearConstant = - Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + Kokkos::sqrt(0.2_Real / (KPP::CMoS * KPP::SurfaceLayerExtent)) / (VonKar * VonKar); const Real WindTurbulentScale = VonKar * TestUStar; @@ -1353,9 +1328,9 @@ void testBoundaryLayerNonuniformThickness() { 0.10_Real * Vt2Layer2 * RhoSw / (RiScaling * Gravity * 6.5_Real); const Real DeltaRho3 = 0.40_Real * (Shear3 + Vt2Layer3) * RhoSw / (RiScaling * Gravity * 17.5_Real); - Density(ICell, 0) = RhoSw; - Density(ICell, 1) = RhoSw + DeltaRho1; - Density(ICell, 2) = RhoSw + DeltaRho2; + Density(ICell, 0) = RhoSw; + Density(ICell, 1) = RhoSw + DeltaRho1; + Density(ICell, 2) = RhoSw + DeltaRho2; // At k=3, the 2.5 m surface layer contains the unequal 1 m and // 2 m layers. Construct rho(3) relative to that weighted mean. @@ -1367,7 +1342,7 @@ void testBoundaryLayerNonuniformThickness() { KPPInstance->CriticalRichardson = 0.25_Real; KPPInstance->StopOBLSearchMult = 1.0_Real; - KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, @@ -1445,10 +1420,10 @@ void testSshOffsetInvariance() { deepCopy(BVF, 1.0_Real); deepCopy(IceFraction, 0.0_Real); - constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SurfaceLayerExtent; constexpr Real TestN = 1.0_Real; const Real UnresolvedShearConstant = - Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + Kokkos::sqrt(0.2_Real / (KPP::CMoS * KPP::SurfaceLayerExtent)) / (VonKar * VonKar); const Real WindTurbulentScale = VonKar * 0.02_Real; parallelFor( @@ -1462,7 +1437,7 @@ void testSshOffsetInvariance() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WindTurbulentScale / 0.25_Real; + TestN * WindTurbulentScale / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1470,12 +1445,11 @@ void testSshOffsetInvariance() { KPPInstance->CriticalRichardson = 0.25_Real; KPPInstance->StopOBLSearchMult = 1.0_Real; - KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; - KPPInstance->UseNonLocalFlux = true; KPPInstance->UseEnhancedDiffusion = true; - KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; auto runWithSsh = [&](Real Ssh) { setCoefficientTestGeometry(Ssh); @@ -1572,7 +1546,7 @@ void testBoundaryLayerEdgeFallbacks() { KPPInstance->CriticalRichardson = 0.25_Real; KPPInstance->StopOBLSearchMult = 1.0_Real; - KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; @@ -1633,9 +1607,9 @@ void testBoundaryLayerLangmuir() { constexpr Real TestUStar = 0.02_Real; constexpr Real TestB0 = -1.0e-7_Real; constexpr Real TestN = 1.0_Real; - constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SurfaceLayerExtent; const Real UnresolvedShearConstant = - Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + Kokkos::sqrt(0.2_Real / (KPP::CMoS * KPP::SurfaceLayerExtent)) / (VonKar * VonKar); deepCopy(NormalVelocity, 0.0_Real); @@ -1650,12 +1624,12 @@ void testBoundaryLayerLangmuir() { KOKKOS_LAMBDA(I4 ICell, I4 K) { const Real ZDepth = LayerThickness * (K + 1.0_Real); const Real ZCenter = LayerThickness * (K + 0.5_Real); - const Real Zeta = KPP::SURFACE_LAYER_EXTENT * ZDepth * VonKar * - TestB0 / (TestUStar * TestUStar * TestUStar); + const Real Zeta = KPP::SurfaceLayerExtent * ZDepth * VonKar * TestB0 / + (TestUStar * TestUStar * TestUStar); const Real PhiInv = Kokkos::sqrt(1.0_Real - 16.0_Real * Zeta); const Real WTurb = VonKar * TestUStar * PhiInv; const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WTurb / 0.25_Real; + TestN * WTurb / 0.25_Real; const Real TargetRi = K == 0 ? 0.0_Real : (K == 1 ? 0.1_Real : 0.26_Real); const Real DeltaRho = @@ -1665,7 +1639,7 @@ void testBoundaryLayerLangmuir() { KPPInstance->CriticalRichardson = 0.25_Real; KPPInstance->StopOBLSearchMult = 1.0_Real; - KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseBLDSmoothing = false; KPPInstance->UseLangmuirCirculation = false; KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, @@ -1710,17 +1684,17 @@ void testBoundaryLayerLangmuir() { constexpr Real ZDepth = 30.0_Real; constexpr Real ZCenter = 25.0_Real; const Real Enhancement = Kokkos::sqrt(3.0_Real); - const Real DisabledZeta = KPP::SURFACE_LAYER_EXTENT * ZDepth * VonKar * + const Real DisabledZeta = KPP::SurfaceLayerExtent * ZDepth * VonKar * TestB0 / (TestUStar * TestUStar * TestUStar); - const Real EnabledZeta = DisabledZeta * Enhancement; + const Real EnabledZeta = DisabledZeta * Enhancement; const Real DisabledWTurb = VonKar * TestUStar * Kokkos::sqrt(1.0_Real - 16.0_Real * DisabledZeta); const Real EnabledWTurb = VonKar * TestUStar * Kokkos::sqrt(1.0_Real - 16.0_Real * EnabledZeta); const Real ExpectedDisabledVt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * TestN * DisabledWTurb / 0.25_Real; - const Real ExpectedEnabledVt2 = 1.7_Real * UnresolvedShearConstant * - ZCenter * TestN * EnabledWTurb / 0.25_Real; + const Real ExpectedEnabledVt2 = 1.7_Real * UnresolvedShearConstant * + ZCenter * TestN * EnabledWTurb / 0.25_Real; const Real ExpectedEnabledRi = 0.26_Real * ExpectedDisabledVt2 / ExpectedEnabledVt2; @@ -1786,9 +1760,9 @@ void testBoundaryLayerSmoothing() { constexpr Real TestUStar = 0.02_Real; constexpr Real TestN = 1.0_Real; - constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SURFACE_LAYER_EXTENT; + constexpr Real RiScaling = 1.0_Real - 0.5_Real * KPP::SurfaceLayerExtent; const Real UnresolvedShearConstant = - Kokkos::sqrt(0.2_Real / (KPP::C_MO_S * KPP::SURFACE_LAYER_EXTENT)) / + Kokkos::sqrt(0.2_Real / (KPP::CMoS * KPP::SurfaceLayerExtent)) / (VonKar * VonKar); const Real WTurb = VonKar * TestUStar; @@ -1811,7 +1785,7 @@ void testBoundaryLayerSmoothing() { } const Real ZCenter = LayerThickness * (K + 0.5_Real); const Real Vt2 = 1.7_Real * UnresolvedShearConstant * ZCenter * - TestN * WTurb / 0.25_Real; + TestN * WTurb / 0.25_Real; const Real DeltaRho = TargetRi * Vt2 * RhoSw / (RiScaling * Gravity * ZCenter); Density(ICell, K) = RhoSw + DeltaRho; @@ -1819,7 +1793,7 @@ void testBoundaryLayerSmoothing() { KPPInstance->CriticalRichardson = 0.25_Real; KPPInstance->StopOBLSearchMult = 1.0_Real; - KPPInstance->SurfaceLayerExtent = KPP::SURFACE_LAYER_EXTENT; + KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, @@ -1926,8 +1900,7 @@ void testEnabledFullCall() { KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; KPPInstance->UseEnhancedDiffusion = false; - KPPInstance->UseNonLocalFlux = true; - KPPInstance->MatchTechniqueStr = "SimpleShapes"; + KPPInstance->MatchTechnique = KPPMatchType::SimpleShapes; KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, UStar, B0, BVF, IceFraction, Wind); KPPInstance->computeMixingCoefficients(Density, UStar, B0); @@ -2099,12 +2072,14 @@ int main(int argc, char *argv[]) { testEnabledFullCall(); testDisabledFullCall(); } - if (TestGroup == "config-gradient" || TestGroup == "config-unsupported") { - testConfigurationNormalization(); + if (TestGroup == "config-gradient" || TestGroup == "config-unsupported" || + TestGroup == "config-parabolic") { + ABORT_ERROR("KPPMixTest: KPPMix::init should have rejected the injected " + "MatchTechnique for group '{}'", + TestGroup); } if (TestGroup != "profiles" && TestGroup != "bld" && TestGroup != "vmix" && - TestGroup != "integration" && TestGroup != "config-gradient" && - TestGroup != "config-unsupported" && TestGroup != "all") { + TestGroup != "integration" && TestGroup != "all") { ABORT_ERROR("KPPMixTest: unknown test group '{}'", TestGroup); } From 5bc36af51fcc8b6b27ec3f0d554ba1e5a0682277 Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Tue, 25 Aug 2026 14:42:03 -0700 Subject: [PATCH 29/36] Add review suggestions to documentation --- components/omega/doc/devGuide/KPPMix.md | 9 +++++++++ components/omega/doc/userGuide/KPPMix.md | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/components/omega/doc/devGuide/KPPMix.md b/components/omega/doc/devGuide/KPPMix.md index 367e607c14c8..6ab0ebc366be 100644 --- a/components/omega/doc/devGuide/KPPMix.md +++ b/components/omega/doc/devGuide/KPPMix.md @@ -8,6 +8,15 @@ call flow, and developer test strategy. ## Implementation Overview +The Omega implementation of KPP follows directly from the MPAS-Ocean implementation. +Notably, by default it does not match diffusivity and viscosity from interior mixing +(below the ocean surface boundary layer) sources at the base of the boundary +layer. Instead these separate sources are added directly to the KPP diagnosed +diffusivity and viscosity. Matching can be enabled via the `MatchTechnique` parameter to +`MatchBoth` in the Omega yaml file. Boundary layer depth is computed as the depth +where the bulk Richardson number exceeds a critical value. It is then smoothed horizontally. +KPP diffusivity, viscosity, and non local fluxes are computed based on this boundary layer. + Omega KPP is implemented in `KPPMix` as a singleton with two major compute phases: diff --git a/components/omega/doc/userGuide/KPPMix.md b/components/omega/doc/userGuide/KPPMix.md index 37d3fc82357e..be1dc3aa803c 100644 --- a/components/omega/doc/userGuide/KPPMix.md +++ b/components/omega/doc/userGuide/KPPMix.md @@ -2,8 +2,9 @@ # KPP Boundary Layer Mixing -This page explains how to enable, configure, and use OMEGA K Profile -Parameterization (KPP) boundary layer mixing in runs. +This page explains how to enable, configure, and use Omega K-Profile +Parameterization (KPP) boundary layer mixing in runs. The implementation follows +directly from the MPAS-Ocean implementation. Related pages: - KPP design/theory: [Design KPP document](../design/KPPMix.md) From 01cad808997c80e88aec616240bf9af9e17fc824 Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Tue, 25 Aug 2026 15:22:16 -0700 Subject: [PATCH 30/36] Remove redundant KPP StopOBLSearch parameter StopOBLSearch only rescaled the bulk Richardson crossing threshold, it never terminated the OBL search. At its default of 1.0 the threshold equals CriticalBulkRichardsonNumber exactly, so the option duplicated an existing knob. Decoupling the two is also physically inconsistent: RiCritical normalizes the unresolved shear Vt^2 in Large et al. (1994) Eq. 23, which is derived assuming the same critical Richardson number used for the crossing test. Use CriticalBulkRichardsonNumber to adjust the OBL depth criterion instead. This is answer preserving since every configuration used the default value. --- components/omega/configs/Default.yml | 1 - components/omega/doc/devGuide/KPPMix.md | 9 ++++----- components/omega/doc/userGuide/KPPMix.md | 2 -- components/omega/src/ocn/KPPConstants.h | 4 ---- components/omega/src/ocn/KPPMix.cpp | 14 +++++--------- components/omega/src/ocn/KPPMix.h | 1 - components/omega/test/ocn/KPPMixTest.cpp | 7 ------- 7 files changed, 9 insertions(+), 29 deletions(-) diff --git a/components/omega/configs/Default.yml b/components/omega/configs/Default.yml index f56e7c3228c2..bd30d4b2a781 100644 --- a/components/omega/configs/Default.yml +++ b/components/omega/configs/Default.yml @@ -107,7 +107,6 @@ Omega: UseBLDSmoothing: true UseLangmuirCirculation: true CriticalBulkRichardsonNumber: 0.25 - StopOBLSearch: 1.0 SurfaceLayerExtent: 0.1 # SimpleShapes or MatchBoth MatchTechnique: SimpleShapes diff --git a/components/omega/doc/devGuide/KPPMix.md b/components/omega/doc/devGuide/KPPMix.md index 6ab0ebc366be..36ba9de574b2 100644 --- a/components/omega/doc/devGuide/KPPMix.md +++ b/components/omega/doc/devGuide/KPPMix.md @@ -84,10 +84,10 @@ The non-dimensional functions live in `src/ocn/KPPConstants.h` in namespace ### Constants and defaults `src/ocn/KPPConstants.h` is the single authoritative source for KPP default -values. The runtime-configurable members of `KPPMix` (`CriticalRichardson`, -`StopOBLSearchMult`, `SurfaceLayerExtent`, the two ice-fraction thresholds and -`MinimumOBLUnderSeaIce`) are initialized from those constants rather than from -inline literals, so a default is changed in exactly one place. +values. The runtime-configurable members of `KPPMix` (`SurfaceLayerExtent`, the +two ice-fraction thresholds and `MinimumOBLUnderSeaIce`) are initialized from +those constants rather than from inline literals, so a default is changed in +exactly one place. Per-thread edge scratch arrays in `computeOBLDepth` are sized from `HorzMesh::MaxEdgesBound`, the shared compile-time bound on edges per cell; @@ -165,7 +165,6 @@ Important keys and class members: - `Enable` -> `Enabled` - `CriticalBulkRichardsonNumber` -> `CriticalRichardson` -- `StopOBLSearch` -> `StopOBLSearchMult` - `SurfaceLayerExtent` -> `SurfaceLayerExtent` - `MatchTechnique` -> `MatchTechnique` (a `KPPMatchType` enum, not a string) - `InterpType2` -> `InterpType2Str` diff --git a/components/omega/doc/userGuide/KPPMix.md b/components/omega/doc/userGuide/KPPMix.md index be1dc3aa803c..afce2cdfe21f 100644 --- a/components/omega/doc/userGuide/KPPMix.md +++ b/components/omega/doc/userGuide/KPPMix.md @@ -55,7 +55,6 @@ VertMix: KPP: Enable: true CriticalBulkRichardsonNumber: 0.25 - StopOBLSearch: 1.0 SurfaceLayerExtent: 0.1 MatchTechnique: SimpleShapes InterpType2: LMD94 @@ -76,7 +75,6 @@ VertMix: |---|---|---| | `Enable` | Enable KPP mixing | `true` | | `CriticalBulkRichardsonNumber` | OBL depth criterion threshold | `0.25` | -| `StopOBLSearch` | Multiple of the critical Richardson number at which the OBL search stops descending | `1.0` | | `SurfaceLayerExtent` | Surface layer thickness as a fraction of the OBL depth ($\epsilon$ in Large et al. 1994) | `0.1` | | `MatchTechnique` | How the K profile meets interior mixing at the OBL base: `SimpleShapes` or `MatchBoth` | `SimpleShapes` | | `InterpType2` | Interpolation type used near OBL matching/base logic | `LMD94` | diff --git a/components/omega/src/ocn/KPPConstants.h b/components/omega/src/ocn/KPPConstants.h index 71d10f0f8c78..4f51e02e20fc 100644 --- a/components/omega/src/ocn/KPPConstants.h +++ b/components/omega/src/ocn/KPPConstants.h @@ -57,10 +57,6 @@ constexpr Real MinUStar = 1.0e-4; // OBL Depth Computation Parameters // ========================================================================== -/// Safety multiplier for OBL search (prevents searching too deep) -/// Default: 1.0 (search to 1.0 * Ri_crit threshold) -constexpr Real StopOBLSearchMult = 1.0; - /// Minimum OBL depth under sea ice (m), applied above IceSuppressThresh constexpr Real MinOBLUnderIce = 5.0; diff --git a/components/omega/src/ocn/KPPMix.cpp b/components/omega/src/ocn/KPPMix.cpp index 02367e3bd086..83dfa792735f 100644 --- a/components/omega/src/ocn/KPPMix.cpp +++ b/components/omega/src/ocn/KPPMix.cpp @@ -156,7 +156,6 @@ void KPPMix::init() { Err += KPPConfig.get("CriticalBulkRichardsonNumber", DefKPPMix->CriticalRichardson); - Err += KPPConfig.get("StopOBLSearch", DefKPPMix->StopOBLSearchMult); Err += KPPConfig.get("SurfaceLayerExtent", DefKPPMix->SurfaceLayerExtent); // KPP matching/profile semantics. @@ -479,7 +478,6 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, const bool LocUseBLDSmoothing = UseBLDSmoothing; const Real LocIceFracThresholdForMinOBL = IceFractionThresholdForMinimumOBL; const Real LocMinimumOBLUnderSeaIce = MinimumOBLUnderSeaIce; - const Real LocStopOBLSearchMult = StopOBLSearchMult; deepCopy(BulkRichardsonNumber, 0.0_Real); deepCopy(BulkRichardsonShear, 0.0_Real); @@ -512,8 +510,6 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, Real OBLDepth = Ssh - ZInterface(ICell, KIntDeep); I4 KCross = -1; const Real RiCritical = LocCriticalRichardson; - const Real RiStopCrit = - Kokkos::max(1.0e-6_Real, LocStopOBLSearchMult) * RiCritical; // Ri is evaluated at cell centers while the reference average spans // the top epsilon*d; this factor corrects for that offset. const Real RiScaling = 1.0_Real - 0.5_Real * LocSurfaceLayerExtent; @@ -590,7 +586,7 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, // where the reference values B_r, V_r are averaged over the top // epsilon*d of the column. Because the reference average depends on // the trial depth d, it is rebuilt from the surface on every k. - // The OBL base is the first d at which Ri_b reaches RiStopCrit. + // The OBL base is the first d at which Ri_b reaches RiCritical. // ------------------------------------------------------------------- for (I4 K = KMin; K <= KMax; ++K) { @@ -739,7 +735,7 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, Kokkos::max(VelScaleSq, 1.0e-12_Real); LocBulkRichardson(ICell, KInt) = RiBulk; - if (KCross < 0 && RiBulk > RiStopCrit) { + if (KCross < 0 && RiBulk > RiCritical) { KCross = K; } } @@ -777,10 +773,10 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, // In local coordinate T = z - ZAbove: // Ri(T) = QuadA T^2 + SlopeAbove T + RiAbove, with QuadA // fixed by requiring Ri(H) = RiBelow. The OBL base is the - // root of Ri(T) = RiStopCrit. + // root of Ri(T) = RiCritical. const Real QuadA = (RiBelow - RiAbove - SlopeAbove * H) / (H * H); - const Real QuadC = RiAbove - RiStopCrit; + const Real QuadC = RiAbove - RiCritical; Real TCross = H; if (Kokkos::abs(QuadA) < 1.0e-14_Real) { @@ -790,7 +786,7 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, const Real Frac = Kokkos::fmax( 0.0_Real, Kokkos::fmin(1.0_Real, - (RiStopCrit - RiAbove) / DRi)); + (RiCritical - RiAbove) / DRi)); TCross = Frac * H; } } else { diff --git a/components/omega/src/ocn/KPPMix.h b/components/omega/src/ocn/KPPMix.h index 4adb6a211430..6042451cc5af 100644 --- a/components/omega/src/ocn/KPPMix.h +++ b/components/omega/src/ocn/KPPMix.h @@ -139,7 +139,6 @@ class KPPMix { // Defaults below may be overridden from the Config file; where a value also // appears in KPPConstants.h, that is the authoritative default. Real CriticalRichardson = 0.25; ///< Ri_crit for OBL base - Real StopOBLSearchMult = KPP::StopOBLSearchMult; ///< Search safety mult Real SurfaceLayerExtent = KPP::SurfaceLayerExtent; ///< Frac of OBL depth bool UseLangmuirCirculation = true; ///< Apply wave enhancement diff --git a/components/omega/test/ocn/KPPMixTest.cpp b/components/omega/test/ocn/KPPMixTest.cpp index 01a70d67f266..0dd46875dd87 100644 --- a/components/omega/test/ocn/KPPMixTest.cpp +++ b/components/omega/test/ocn/KPPMixTest.cpp @@ -956,7 +956,6 @@ void testConfiguredValues() { ExpectedMinimumOBLIce, RTol, ATol) || !isApprox(KPPInstance->MinimumOBLUnderSeaIce, ExpectedMinimumOBL, RTol, ATol) || - !isApprox(KPPInstance->StopOBLSearchMult, 1.0_Real, RTol, ATol) || !isApprox(KPPInstance->SurfaceLayerExtent, 0.1_Real, RTol, ATol) || !KPPInstance->UseLangmuirCirculation || !isApprox(KPPInstance->BackgroundVisc, 1.0e-4_Real, RTol, ATol) || @@ -1016,7 +1015,6 @@ void testBoundaryLayerDepth() { }); KPPInstance->CriticalRichardson = 0.25_Real; - KPPInstance->StopOBLSearchMult = 1.0_Real; KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; @@ -1341,7 +1339,6 @@ void testBoundaryLayerNonuniformThickness() { VCoord->minMaxLayerEdge(Halo::getDefault()); KPPInstance->CriticalRichardson = 0.25_Real; - KPPInstance->StopOBLSearchMult = 1.0_Real; KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; @@ -1444,7 +1441,6 @@ void testSshOffsetInvariance() { }); KPPInstance->CriticalRichardson = 0.25_Real; - KPPInstance->StopOBLSearchMult = 1.0_Real; KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; @@ -1545,7 +1541,6 @@ void testBoundaryLayerEdgeFallbacks() { }); KPPInstance->CriticalRichardson = 0.25_Real; - KPPInstance->StopOBLSearchMult = 1.0_Real; KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; @@ -1638,7 +1633,6 @@ void testBoundaryLayerLangmuir() { }); KPPInstance->CriticalRichardson = 0.25_Real; - KPPInstance->StopOBLSearchMult = 1.0_Real; KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseBLDSmoothing = false; KPPInstance->UseLangmuirCirculation = false; @@ -1792,7 +1786,6 @@ void testBoundaryLayerSmoothing() { }); KPPInstance->CriticalRichardson = 0.25_Real; - KPPInstance->StopOBLSearchMult = 1.0_Real; KPPInstance->SurfaceLayerExtent = KPP::SurfaceLayerExtent; KPPInstance->UseLangmuirCirculation = false; KPPInstance->UseBLDSmoothing = false; From 3437975dbaaccc13d6efa093fb5efaeeacb344aa Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Tue, 25 Aug 2026 15:24:38 -0700 Subject: [PATCH 31/36] Carry KPP surface-layer averages across trial depths The bulk Richardson search reset the surface-layer reference averages to the top of the column at every trial depth and then replayed the accumulation downward, making the search O(K^2) per column, and O(K^2*nEdges) for the per-edge velocity averages. The averaging window spans the top epsilon*d, which grows monotonically with the trial depth, so the window end only ever moves downward. Hoist the running sums and their level pointers out of the trial-depth loop so the accumulation becomes a two-pointer scan that is amortized O(1) per level. This is bit for bit identical since the accumulation order from the surface is unchanged, it is simply no longer repeated. --- components/omega/src/ocn/KPPMix.cpp | 61 +++++++++++++++-------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/components/omega/src/ocn/KPPMix.cpp b/components/omega/src/ocn/KPPMix.cpp index 83dfa792735f..8e98d2d55813 100644 --- a/components/omega/src/ocn/KPPMix.cpp +++ b/components/omega/src/ocn/KPPMix.cpp @@ -584,40 +584,43 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, // Bulk Richardson search, Large et al. (1994) Eq. (21): // Ri_b(d) = (B_r - B(d)) d / (|V_r - V(d)|^2 + Vt^2(d)) // where the reference values B_r, V_r are averaged over the top - // epsilon*d of the column. Because the reference average depends on - // the trial depth d, it is rebuilt from the surface on every k. + // epsilon*d of the column. Since epsilon*d grows monotonically with + // the trial depth, the averaging window only ever extends downward, + // so the running sums are carried across trial depths rather than + // rebuilt from the surface at each one. // The OBL base is the first d at which Ri_b reaches RiCritical. // ------------------------------------------------------------------- - for (I4 K = KMin; K <= KMax; ++K) { - // Fresh cell surface-layer density average for this trial depth - I4 KSurfaceAvg = KMin; - const Real ThickTop = Kokkos::abs(ZInterface(ICell, KMin + 1) - - ZInterface(ICell, KMin)); - Real SumThickness = Kokkos::max(ThickTop, 1.0e-12_Real); - Real SumRho = LocPotentialDensity(ICell, KMin) * SumThickness; - - // Fresh per-edge surface-layer velocity averages - I4 KSurfE[MaxEdgesBound] = {}; - Real SumThickE[MaxEdgesBound] = {}; - Real SumUnE[MaxEdgesBound] = {}; - Real SumVtE[MaxEdgesBound] = {}; + // Cell surface-layer density average + I4 KSurfaceAvg = KMin; + const Real ThickTop = Kokkos::abs(ZInterface(ICell, KMin + 1) - + ZInterface(ICell, KMin)); + Real SumThickness = Kokkos::max(ThickTop, 1.0e-12_Real); + Real SumRho = LocPotentialDensity(ICell, KMin) * SumThickness; - for (I4 J = 0; J < NEdgesEff; ++J) { - if (!EdgeValid[J]) { - continue; - } - const I4 IEdge = EdgesOnCell(ICell, J); - const I4 KEMin = MinLayerEdgeBot(IEdge); - KSurfE[J] = KEMin; - const I4 KIntE0 = Kokkos::min(KEMin + 1, NVertLayers); - const Real Thick0 = Kokkos::abs(ZInterface(ICell, KIntE0) - - ZInterface(ICell, KEMin)); - SumThickE[J] = Kokkos::max(Thick0, 1.0e-12_Real); - const I4 KE0 = Kokkos::min(KEMin, NVertLayers - 1); - SumUnE[J] = LocNormalVelocity(IEdge, KE0) * SumThickE[J]; - SumVtE[J] = LocTangentialVelocity(IEdge, KE0) * SumThickE[J]; + // Per-edge surface-layer velocity averages + I4 KSurfE[MaxEdgesBound] = {}; + Real SumThickE[MaxEdgesBound] = {}; + Real SumUnE[MaxEdgesBound] = {}; + Real SumVtE[MaxEdgesBound] = {}; + + for (I4 J = 0; J < NEdgesEff; ++J) { + if (!EdgeValid[J]) { + continue; } + const I4 IEdge = EdgesOnCell(ICell, J); + const I4 KEMin = MinLayerEdgeBot(IEdge); + KSurfE[J] = KEMin; + const I4 KIntE0 = Kokkos::min(KEMin + 1, NVertLayers); + const Real Thick0 = Kokkos::abs(ZInterface(ICell, KIntE0) - + ZInterface(ICell, KEMin)); + SumThickE[J] = Kokkos::max(Thick0, 1.0e-12_Real); + const I4 KE0 = Kokkos::min(KEMin, NVertLayers - 1); + SumUnE[J] = LocNormalVelocity(IEdge, KE0) * SumThickE[J]; + SumVtE[J] = LocTangentialVelocity(IEdge, KE0) * SumThickE[J]; + } + + for (I4 K = KMin; K <= KMax; ++K) { const I4 KCell = Kokkos::min(K, NVertLayers - 1); const I4 KInt = Kokkos::min(K + 1, NVertLayers); const Real ZDepth = Ssh - ZInterface(ICell, KInt); From 38056c377fddb555cfc7d2a89bd4767432020bdd Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Tue, 25 Aug 2026 15:26:01 -0700 Subject: [PATCH 32/36] Stop the KPP boundary layer search at the crossing The bulk Richardson search traversed every model layer even after the crossing was found. Only the crossing level and the two above it feed the quadratic refinement of the boundary layer depth, so the remaining levels contributed nothing to the diagnosed depth or to any mixing coefficient. Break out of the loop once the crossing is latched. The four Ri diagnostic fields written inside the loop, BulkRichardsonNumber, BulkRichardsonShear, UnresolvedShear and BuoyancyJump, are consequently zero below the boundary layer base; they are zero filled before the kernel, so the values remain well defined. Setting DebugDiagnostics suppresses the early exit and restores the full water column profiles for those fields. BoundaryLayerDepth and all mixing coefficients are unchanged. --- components/omega/doc/devGuide/KPPMix.md | 10 ++++++++++ components/omega/doc/userGuide/KPPMix.md | 9 ++++++++- components/omega/src/ocn/KPPMix.cpp | 8 +++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/components/omega/doc/devGuide/KPPMix.md b/components/omega/doc/devGuide/KPPMix.md index 36ba9de574b2..bf62281639de 100644 --- a/components/omega/doc/devGuide/KPPMix.md +++ b/components/omega/doc/devGuide/KPPMix.md @@ -65,6 +65,16 @@ value; the search is done per cell in `computeOBLDepth`, and the crossing depth is refined by a quadratic fit through the three nearest cell-center `Ri_b` values. +Two properties of that search are worth knowing before editing it. The +surface-layer reference averages span the top `epsilon*d`, which grows +monotonically with the trial depth, so the running sums are carried across trial +depths in a two-pointer scan rather than rebuilt from the surface at each one; +resetting them inside the loop would make the search `O(K^2)`. The loop also +stops at the crossing, since only the crossing level and the two above it feed +the refinement. `DebugDiagnostics` suppresses that early exit so the `Ri_b` +diagnostic profiles are filled over the whole column; it has no effect on +`BoundaryLayerDepth` or on any mixing coefficient. + ### Shape and stability functions The non-dimensional functions live in `src/ocn/KPPConstants.h` in namespace diff --git a/components/omega/doc/userGuide/KPPMix.md b/components/omega/doc/userGuide/KPPMix.md index afce2cdfe21f..648d05a1c5d8 100644 --- a/components/omega/doc/userGuide/KPPMix.md +++ b/components/omega/doc/userGuide/KPPMix.md @@ -86,7 +86,7 @@ VertMix: | `MinimumOBLUnderSeaIce` | Minimum OBL depth under sea ice (m) | `5.0` | | `BackgroundViscosity` | Background viscosity below the OBL (m^2/s) | `1.0e-4` | | `BackgroundDiffusivity` | Background diffusivity below the OBL (m^2/s) | `1.0e-5` | -| `DebugDiagnostics` | Enable additional KPP diagnostics/logging in debug workflows | `false` | +| `DebugDiagnostics` | Enable additional KPP diagnostics/logging in debug workflows, and extend the Ri diagnostic profiles below the boundary layer base | `false` | Note that KPP reads its own `BackgroundViscosity` and `BackgroundDiffusivity` from the `VertMix: KPP` group; these are separate from the `VertMix: Background` @@ -107,6 +107,13 @@ To diagnose KPP, include KPP fields in output stream contents. Common fields: - `SurfaceFrictionVelocity` - `SurfaceBuoyancyFlux` +The boundary layer search stops at the first level where the bulk Richardson +number reaches its critical value, so `BulkRichardsonNumber`, +`BulkRichardsonShear`, `UnresolvedShear` and `BuoyancyJump` are zero below the +boundary layer base. Set `DebugDiagnostics: true` to compute and output the +full water column profile of these four fields; it does not change +`BoundaryLayerDepth` or any mixing coefficient. + ## Typical Workflow 1. Enable KPP and set baseline options in `omega.yml`. diff --git a/components/omega/src/ocn/KPPMix.cpp b/components/omega/src/ocn/KPPMix.cpp index 8e98d2d55813..3b7e0e40bb5b 100644 --- a/components/omega/src/ocn/KPPMix.cpp +++ b/components/omega/src/ocn/KPPMix.cpp @@ -476,6 +476,7 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, OMEGA_SCOPE(LocUnresolvedShear, UnresolvedShear); OMEGA_SCOPE(LocBuoyancyJump, BuoyancyJump); const bool LocUseBLDSmoothing = UseBLDSmoothing; + const bool LocFullRiProfile = DebugDiagnostics; const Real LocIceFracThresholdForMinOBL = IceFractionThresholdForMinimumOBL; const Real LocMinimumOBLUnderSeaIce = MinimumOBLUnderSeaIce; @@ -588,7 +589,9 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, // the trial depth, the averaging window only ever extends downward, // so the running sums are carried across trial depths rather than // rebuilt from the surface at each one. - // The OBL base is the first d at which Ri_b reaches RiCritical. + // The OBL base is the first d at which Ri_b reaches RiCritical, and + // the search stops there unless the full profile is wanted for the + // Ri diagnostics. // ------------------------------------------------------------------- // Cell surface-layer density average @@ -740,6 +743,9 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, if (KCross < 0 && RiBulk > RiCritical) { KCross = K; + // Levels below the crossing only feed the Ri diagnostics + if (!LocFullRiProfile) + break; } } From 95d90222fa9f0760e69476ab70ed171fc050af6b Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Wed, 26 Aug 2026 07:12:56 -0700 Subject: [PATCH 33/36] updates docs and makes KPP compute once each step only --- components/omega/doc/design/KPPMix.md | 65 +++++++---- components/omega/doc/devGuide/KPPMix.md | 105 +++++++++--------- components/omega/doc/userGuide/KPPMix.md | 34 +++--- components/omega/src/ocn/Tendencies.cpp | 39 +++---- components/omega/src/ocn/Tendencies.h | 12 +- .../timeStepping/ForwardBackwardStepper.cpp | 4 +- .../src/timeStepping/RungeKutta2Stepper.cpp | 4 +- .../src/timeStepping/RungeKutta4Stepper.cpp | 11 +- .../omega/src/timeStepping/TimeStepper.cpp | 16 ++- .../omega/src/timeStepping/TimeStepper.h | 17 ++- 10 files changed, 164 insertions(+), 143 deletions(-) diff --git a/components/omega/doc/design/KPPMix.md b/components/omega/doc/design/KPPMix.md index 65d79f9df3e5..c2948648b25b 100644 --- a/components/omega/doc/design/KPPMix.md +++ b/components/omega/doc/design/KPPMix.md @@ -10,12 +10,14 @@ ## 1 Overview -This document describes the OMEGA implementation of the K Profile Parameterization +This document describes the Omega implementation of the K Profile Parameterization (KPP) ocean boundary layer mixing. KPP computes a boundary-layer depth, vertical viscosity, vertical diffusivity, and a non-local tracer flux shape implemented outside -the implicit vertical mixing routine. +the implicit vertical mixing routine. The implementation follows that in MPAS-Ocean and +uses direct ports of the functions defined in the [CVMix](https://github.com/CVMix/CVMix-src) +version of KPP. -The implementation is in `KPPMix` and is integrated with the OMEGA tendency and +The implementation is in `KPPMix` and is integrated with the Omega tendency and RK2, RK4, and Forward-backward stepping routines. Relative to broad vertical mixing documentation, this page focuses specifically on KPP theory, algorithmic choices, and testing. @@ -23,18 +25,19 @@ page focuses specifically on KPP theory, algorithmic choices, and testing. ### 2.1 Requirement: Boundary-layer depth from bulk Richardson criterion -Following [Large et al (1994)](https://agupubs.onlinelibrary.wiley.com/doi/10.1029/94RG01872), the OBL depth must be diagnosed from a bulk Richardson criterion so that +Following [Large et al (1994)](https://agupubs.onlinelibrary.wiley.com/doi/10.1029/94RG01872), +the OBL depth must be diagnosed from a bulk Richardson criterion so that mixing depth responds to evolving stratification, shear, and surface forcing. It also must include a unresolved turbulent shear contribution. ### 2.2 Requirement: Coefficients must be computable in parallel over columns -The KPP implementation must operate over many columns in parallel using OMEGA +The KPP implementation must operate over many columns in parallel using Omega array/kernels, rather than serial single-column calls. ### 2.3 Requirement: Compatible with additive vertical-mixing framework -KPP viscosity/diffusivity fields must be compatible with existing OMEGA vertical +KPP viscosity/diffusivity fields must be compatible with existing Omega vertical mixing infrastructure so that other chosen vertical mixing sources can be merged with KPP. @@ -71,11 +74,16 @@ is utilized to find the depth. In addition the boundary layer depth is constrai to fall between a configurable minimum OBL under sea ice and a maximum set by the water column depth. +The boundary layer depth search is a growing inner loop. The outer loop iterates over all model layers. +The current model layer is set as a boundary layer depth candidate and $Ri_b$ is calculated for all +model layers shallower than the current depth. If any layer in the inner loop has an $Ri_b*StopOBL$ that +exceeds $Ri_{crit}$ the loop terminates. + ### 3.2 Stage 2: KPP coefficients and non-local flux -Given a diagnosed $h$, KPP computes interface viscosity and diffusivity coefficients -using shape functions in normalized depth $\sigma = -d/h$, where $d$ is the depth relative -to the sea surface height, not the physical depth: +Given a diagnosed $h$, KPP computes viscosity and diffusivity coefficients at the top of every +Omega cell except the surface and the bottom using shape functions +in normalized depth $\sigma = -d/h$, where $d$ is the depth relative to the sea surface height, not the physical depth: $$ K_m(\sigma) = h\, w_m(\sigma)\, M_1(\sigma), @@ -86,7 +94,7 @@ K_s(\sigma) = h\, w_s(\sigma)\, S_1(\sigma), $$ where $w_m$ and $w_s$ are turbulent velocity scales from Monin-Obukhov style -stability functions, see Appendix B of [Large et al, 1994](https://agupubs.onlinelibrary.wiley.com/doi/10.1029/94RG01872). Where $M_1$ and $S_1$ are shape functions. +stability functions, see Appendix B of [Large et al, 1994](https://agupubs.onlinelibrary.wiley.com/doi/10.1029/94RG01872). $M_1$ and $S_1$ are shape functions. The generic form of the shape function is given by $$ @@ -100,8 +108,7 @@ version of KPP matches predicted viscosities and diffusivities to those predicte (e.g., shear instability driven mixing) and a second option where viscosities and diffusivities are instead additive. In the latter case, the shape function greatly simplifies to $X(\sigma) = \sigma(1-\sigma)^2. -For either shape function, enhanced diffusivity can be included near the boundary layer base. This can -smooth boundary layer deepening. +For either shape function, enhanced diffusivity can be included near the boundary layer base. This can smooth boundary layer deepening in time. The non-local tracer flux uses the same scalar shape function, scaled by the constant $C_s$ from Eq. (20) of Large et al. (1994) rather than by $h\, w_s$: @@ -110,8 +117,7 @@ $$ \gamma_s(\sigma) = C_s\, S_1(\sigma). $$ -Because a single $S_1$ drives both, $K_s$ and $\gamma_s$ cannot become inconsistent -with each other when the matching option changes. +Because a single shape function ($S_1$) drives both the diffusivity and nonlocal flux, $K_s$ and $\gamma_s$ cannot become inconsistent with each other when the matching option changes. @@ -126,7 +132,7 @@ KPP is configured from the `VertMix: KPP` YAML group. Key parameters include: - `Enable` - `CriticalBulkRichardsonNumber` - `MatchTechnique` (`SimpleShapes` or `MatchBoth`) -- `InterpType2` +- `InterpType2` (`LMD94`, `Linear`, `Quadratic`, `Cubic`) - `UseEnhancedDiffusion` - `IceFractionThresholdForLangmuir` - `IceFractionThresholdForMinimumOBL` @@ -169,14 +175,27 @@ Internal stages: ### 4.3 Time stepper coupling behavior -KPP is coupled to all three OMEGA time steppers -- Forward-Backward (default, -split-explicit-style), RungeKutta2, and RungeKutta4. For each stepper, KPP -recomputes boundary-layer depth and coefficients at every internal stage of -that stepper, and once more on the fully updated state after time levels are -advanced, immediately before implicit vertical mixing is applied. The final, -post-step recompute is what determines the KPP diagnostics for that step -across all three steppers. Full call-flow detail per stepper is described for -developers and users in: +KPP is coupled to all three Omega time steppers -- Forward-Backward, +RungeKutta2, and RungeKutta4. For every stepper, KPP is evaluated exactly +once per time step, at the start of the step on the state at time $n$, before +any tendency is evaluated. The resulting boundary-layer depth, viscosity, +diffusivity, and non-local flux profile are then held fixed for the remainder +of the step. + +This design differs from MPAS-Ocean, where the boundary layer depth, diffusivity, and viscosity are computed at the end of the time step and the non local flux is applied on the following timestep. The primary advantage of this new approach is: + +1. **Consistency.** The non-local flux $\gamma_s$ applied in the tracer + tendency at each stage and the diffusivity $K_s$ used by the end-of-step + implicit vertical mixing solve are derived from the same OBL depth and the + same shape function $S_1(\sigma)$. Recomputing KPP at each stage would + pair a stage-dependent $\gamma_s$ with a different $K_s$, breaking the + correspondence described in section 3.2. + +Because KPP is evaluated before the step advances, the KPP diagnostics +written for a step describe the state at the beginning of that step. The +coefficients are lagged relative to the state during the implicit solve at the end of the +step. Full call-flow detail per stepper is described for developers and +users in: - [Developer KPP workflow](../devGuide/KPPMix.md) - [User runtime notes](../userGuide/KPPMix.md) diff --git a/components/omega/doc/devGuide/KPPMix.md b/components/omega/doc/devGuide/KPPMix.md index bf62281639de..947457541276 100644 --- a/components/omega/doc/devGuide/KPPMix.md +++ b/components/omega/doc/devGuide/KPPMix.md @@ -8,14 +8,14 @@ call flow, and developer test strategy. ## Implementation Overview -The Omega implementation of KPP follows directly from the MPAS-Ocean implementation. +The Omega implementation of KPP mostly follows directly from the MPAS-Ocean implementation. Notably, by default it does not match diffusivity and viscosity from interior mixing (below the ocean surface boundary layer) sources at the base of the boundary layer. Instead these separate sources are added directly to the KPP diagnosed diffusivity and viscosity. Matching can be enabled via the `MatchTechnique` parameter to `MatchBoth` in the Omega yaml file. Boundary layer depth is computed as the depth where the bulk Richardson number exceeds a critical value. It is then smoothed horizontally. -KPP diffusivity, viscosity, and non local fluxes are computed based on this boundary layer. +KPP diffusivity, viscosity, and non local fluxes are computed based on this boundary layer. Unlike MPAS-Ocean, the boundary layer depth, vertical vicosity and vertical diffusivity are calculated at the beginning of a time step to ensure consistency of the nonlocal and local parts of the KPP scheme. Omega KPP is implemented in `KPPMix` as a singleton with two major compute phases: @@ -35,12 +35,11 @@ All KPP depths are measured downward from the free surface, not from the geoid. `SshCell(ICell) - GeomZ...(ICell, K)`. Layer thicknesses are differences of geometric heights and are unaffected by the sea surface height. -## Notation for Readers New to KPP +## KPP Notation -KPP splits the water column at the ocean boundary layer (OBL) depth `h`, also -called the boundary layer depth (BLD). Inside the OBL, diffusivity and -viscosity are prescribed as a depth profile scaled by `h` and a turbulent -velocity scale; below it, only interior mixing applies. +KPP splits the water column at the ocean boundary layer (OBL) depth `h`. Inside the OBL, diffusivity and viscosity are prescribed via a cubic shape function +scaled by `h` and a turbulent velocity scale; below it, only interior mixing + applies (e.g., shear instability driven mixing). | Symbol | Code name | Units | Meaning | | --- | --- | --- | --- | @@ -109,10 +108,9 @@ KPP does not define its own maximum. KPP coupling into tendencies occurs through: -- `Tendencies::computeAllTendencies(...)` -- `Tendencies::computeStageVerticalMixing(...)` +- `Tendencies::computeKPPFields(...)` -`computeStageVerticalMixing(...)` assembles required inputs: +`computeKPPFields(...)` assembles required inputs: - potential density from EOS specific volume - Brunt-Vaisala frequency squared @@ -128,45 +126,45 @@ KPPInstance->computeKPPMix(...) ### Time stepper interaction -KPP is hooked into all three OMEGA time steppers +KPP is evaluated exactly once per time step by every Omega time stepper (`src/timeStepping/RungeKutta4Stepper.cpp`, `src/timeStepping/RungeKutta2Stepper.cpp`, -`src/timeStepping/ForwardBackwardStepper.cpp`) through two mechanisms: - -1. **Stage recompute**: `Tendencies::StageVerticalMixingEnabled` (default - `true`) gates a call to `computeStageVerticalMixing(...)` inside - `computeAllTendencies`, `computeVelocityTendencies`, and - `computeTracerTendencies`. Whichever of these tendency functions a stepper - calls during its stages will trigger a KPP recompute on that stage's - state. -2. **Post-step recompute**: `TimeStepper::applyPostStepVerticalMixing(...)` - (in `src/timeStepping/TimeStepper.cpp`) is called by every stepper's - `doStep()` immediately after `State->updateTimeLevels()`. It recomputes - auxiliary state and calls `computeStageVerticalMixing(...)` once more on - the fully updated state, then applies implicit vertical mixing via - `VertMix::VertMixImplicit(...)` if enabled. - -Per-stepper call flow: - -- **RungeKutta4Stepper**: calls `computeAllTendencies(...)` once per stage - (base stage plus 3 provisional stages), so KPP recomputes 4 times during - stepping, followed by `applyPostStepVerticalMixing(..., "RK4")`. -- **RungeKutta2Stepper**: calls `computeAllTendencies(...)` twice (initial - stage, midpoint stage), so KPP recomputes twice during stepping, followed - by `applyPostStepVerticalMixing(..., "RK2")`. -- **ForwardBackwardStepper**: calls `computeVelocityTendencies(...)` and - `computeTracerTendencies(...)` separately, each triggering a KPP recompute, - followed by `applyPostStepVerticalMixing(..., "ForwardBackward")`. - -In every stepper, the post-step recompute uses the fully updated state and is -what determines the KPP diagnostics written to output for that step. - -Note: `RungeKutta4Stepper::doStep` still saves/restores -`StageVerticalMixingEnabled` around its stage loop and ANDs it with -`KPPMix::Enabled`. This is currently a no-op with respect to gating stage -recompute, since `computeStageVerticalMixing` already early-returns when KPP -is disabled; do not assume stage recompute is suppressed during RK4 -sub-stages when reading that code. +`src/timeStepping/ForwardBackwardStepper.cpp`) through two hooks in +`src/timeStepping/TimeStepper.cpp`: + +1. **Start-of-step compute**: `TimeStepper::updateKPPFields(...)` fetches the + current tracer array and calls `Tendencies::computeKPPFields(...)`. Each + stepper calls it once at the top of `doStep()`, after `prescribeState` / + `prescribeVelocity` and before the first tendency evaluation: + + - **RungeKutta4Stepper**: in the `Stage == 0` branch, before the first + `computeAllTendencies(...)`. + - **RungeKutta2Stepper**: after the initial `prescribeState(...)`. + - **ForwardBackwardStepper**: after `prescribeVelocity(...)`. + +2. **End-of-step application**: + `TimeStepper::applyImplicitVerticalMixing(...)` is called by every + stepper's `doStep()` immediately after `State->updateTimeLevels()`. It + recomputes auxiliary state and calls `VertMix::VertMixImplicit(...)` if + enabled. It does **not** recompute KPP; the fields from the start of the + step are reused. + +The KPP fields are consumed in two places within the step: + +- `Tendencies::computeTracerTendenciesOnly(...)` adds the non-local tracer + tendency from `KPPMix::VertNonLocalFlux` at every stage. +- `VertMix::computeVertMix(...)`, called from `VertMixImplicit(...)`, merges + `KPPMix::VertDiff` / `VertVisc` into the final coefficients using + `KPPMix::IndexBoundaryLayerDepth`. + +Both therefore see the same boundary-layer depth and the same shape function, +so the non-local flux and the diffusivity it is paired with cannot become +inconsistent. KPP diagnostics written for a step describe the state at the +beginning of that step. + +Note that `VertMix::VertMixImplicit(...)` recomputes Brunt-Vaisala frequency +itself before calling `computeVertMix(...)`, so shear and convective mixing +still use end-of-step stratification; only the KPP contribution is lagged. ## Configuration Mapping @@ -217,6 +215,7 @@ behavior in experiments. name. - `MatchBoth` needs interior coefficients to be passed in; without them `ShapeAtBase` is zero and it degenerates exactly to `SimpleShapes`. +- `Interptype2` accepts `LMD94`, `Linear`, `Quadratic`, and `Cubic`, but only `LMD94` is recommended strongly recommended. For `MatchBoth` other options can result in negative diffusivities and viscosities. - When `DebugDiagnostics` is enabled in debug builds, targeted diagnostic logging is available; behavior is compile/build-mode aware. @@ -225,12 +224,12 @@ behavior in experiments. ### Code-level checks 1. Verify KPP initialization with explicit and default YAML keys. -2. Verify stage call path executes with KPP enabled and is skipped when - disabled. -3. Verify per-stepper sequencing: stage recompute at each stage of the active - stepper (4 for RK4, 2 for RK2, 2 for Forward-Backward), plus one final - recompute on the updated state before implicit vertical mixing, for all - three steppers. +2. Verify the `computeKPPFields` path executes with KPP enabled and + early-returns when disabled. +3. Verify per-stepper sequencing: exactly one KPP evaluation per step for all + three steppers, occurring before the first tendency evaluation and reused + by the end-of-step implicit vertical mixing. The `Tend:computeKPPFields` + Pacer region can be used to confirm the call count. ### Diagnostics-based checks diff --git a/components/omega/doc/userGuide/KPPMix.md b/components/omega/doc/userGuide/KPPMix.md index 648d05a1c5d8..df39a12d6345 100644 --- a/components/omega/doc/userGuide/KPPMix.md +++ b/components/omega/doc/userGuide/KPPMix.md @@ -26,23 +26,23 @@ is included by default. ## How KPP Is Used in Time Stepping -KPP is connected to all three OMEGA time steppers: Forward-Backward (the -default, split-explicit-style stepper), RungeKutta2, and RungeKutta4. For -whichever stepper is active, KPP recomputes boundary-layer depth and -coefficients at each internal stage of that stepper, and then once more on -the fully updated state after time levels are advanced, immediately before -implicit vertical mixing is applied: - -- **Forward-Backward** (default): KPP recomputes when velocity tendencies - are evaluated and again when tracer tendencies are evaluated, then once - more on the updated state. -- **RungeKutta2**: KPP recomputes at the initial stage and at the midpoint - stage, then once more on the updated state. -- **RungeKutta4**: KPP recomputes at each of the four RK4 stages, then once - more on the updated state. - -In all cases, the final recompute on the fully updated state is what -determines the KPP diagnostics written to output for that step. +KPP is connected to all three Omega time steppers: Forward-Backward, RungeKutta2, and RungeKutta4. For +whichever stepper is active, KPP is computed **once per time step**, at the +start of the step (in contrast to MPAS-Ocean), before any tendency is evaluated. +Boundary-layer depth, viscosity, diffusivity, and the non-local flux profile +are then held fixed for the rest of the step and are used by: + +- the non-local tracer tendency at every internal stage of the stepper, and +- the implicit vertical mixing solve applied after the time levels are + advanced. + +Because both use the same KPP fields, the non-local flux and the diffusivity +it is paired with are always consistent with one another. + +The KPP diagnostics written to output for a step therefore describe the ocean +state at the **beginning** of that step, not the updated state at the end of +it. This is the same convention used by the MPAS-Ocean split-explicit +stepper. ## Configuration diff --git a/components/omega/src/ocn/Tendencies.cpp b/components/omega/src/ocn/Tendencies.cpp index 47543035e34e..f0b624a689f9 100644 --- a/components/omega/src/ocn/Tendencies.cpp +++ b/components/omega/src/ocn/Tendencies.cpp @@ -1175,10 +1175,6 @@ void Tendencies::computeVelocityTendencies( ) { Pacer::start("Tend:computeVelocityTendencies", 1); - if (StageVerticalMixingEnabled) { - computeStageVerticalMixing(State, AuxState, TracerArray, ThickTimeLevel, - VelTimeLevel); - } AuxState->computeMomAux(State, TracerArray, ThickTimeLevel, VelTimeLevel, ProjDt); computeVelocityTendenciesOnly(State, AuxState, TracerArray, ThickTimeLevel, @@ -1205,10 +1201,6 @@ void Tendencies::computeTracerTendencies( Pacer::start("Tend:computeTracerTendencies", 1); - if (StageVerticalMixingEnabled) { - computeStageVerticalMixing(State, AuxState, TracerArray, ThickTimeLevel, - VelTimeLevel); - } const auto &MeanPseudoThickEdge = AuxState->PseudoThicknessAux.MeanPseudoThickEdge; Pacer::start("Tend:computeTracerAuxCell", 2); @@ -1249,10 +1241,6 @@ void Tendencies::computeAllTendencies( AuxState->computeAll(State, TracerArray, ThickTimeLevel, VelTimeLevel, ProjDt); - if (StageVerticalMixingEnabled) { - computeStageVerticalMixing(State, AuxState, TracerArray, ThickTimeLevel, - VelTimeLevel); - } computePseudoThicknessTendenciesOnly(State, AuxState, ThickTimeLevel, VelTimeLevel, Time); computeVelocityTendenciesOnly(State, AuxState, TracerArray, ThickTimeLevel, @@ -1272,25 +1260,25 @@ void Tendencies::setSurfaceTracerFlux(const Array2DReal &Flux) { } //------------------------------------------------------------------------------ -// Prepare KPP state for the current stage. Final VertDiff/VertVisc coefficient -// assembly is owned by VertMix::computeVertMix. -void Tendencies::computeStageVerticalMixing(const OceanState *State, - const AuxiliaryState *AuxState, - const Array3DReal &TracerArray, - int ThickTimeLevel, - int VelTimeLevel) { - (void)AuxState; +// Prepare KPP state for the current time step. Final VertDiff/VertVisc +// coefficient assembly is owned by VertMix::computeVertMix. +void Tendencies::computeKPPFields(const OceanState *State, + const Array3DReal &TracerArray, + int ThickTimeLevel, int VelTimeLevel) { KPPMix *KPPInstance = KPPMix::getInstance(); if (!EqState || !KPPInstance || !KPPInstance->Enabled) return; + Pacer::start("Tend:computeKPPFields", 1); + I4 TempIdx = -1; I4 SaltIdx = -1; if (Tracers::getIndex(TempIdx, "Temperature") != 0 || Tracers::getIndex(SaltIdx, "Salinity") != 0) { - LOG_WARN("Tendencies::computeStageVerticalMixing: Temperature/Salinity " - "tracers not found, skipping KPP stage update"); + LOG_WARN("Tendencies::computeKPPFields: Temperature/Salinity " + "tracers not found, skipping KPP update"); + Pacer::stop("Tend:computeKPPFields", 1); return; } @@ -1374,8 +1362,9 @@ void Tendencies::computeStageVerticalMixing(const OceanState *State, const auto *ForcingState = Forcing::getDefault(); if (!ForcingState) { - LOG_WARN("Tendencies::computeStageVerticalMixing: Forcing has not " - "been initialized, skipping KPP stage update"); + LOG_WARN("Tendencies::computeKPPFields: Forcing has not " + "been initialized, skipping KPP update"); + Pacer::stop("Tend:computeKPPFields", 1); return; } @@ -1478,6 +1467,8 @@ void Tendencies::computeStageVerticalMixing(const OceanState *State, PotentialDensity, NormalVelEdge, TangentialVelEdge, KPPInstance->SurfaceFrictionVelocity, KPPInstance->SurfaceBuoyancyFlux, EqState->BruntVaisalaFreqSq, IceFraction, WindSpeed10m); + + Pacer::stop("Tend:computeKPPFields", 1); } } // end namespace OMEGA diff --git a/components/omega/src/ocn/Tendencies.h b/components/omega/src/ocn/Tendencies.h index fc5a4a0360a4..b3628ff3e19e 100644 --- a/components/omega/src/ocn/Tendencies.h +++ b/components/omega/src/ocn/Tendencies.h @@ -96,9 +96,6 @@ class Tendencies { // Enable diagnostics that isolate temperature non-local terms. bool TracerNonLocalDiagnosticsEnable = true; - // Controls whether KPP is recomputed during tendency stages. - bool StageVerticalMixingEnabled = true; - std::string Name; // Methods to compute tendency groups @@ -140,10 +137,11 @@ class Tendencies { void setSurfaceTracerFlux(const Array2DReal &Flux); - void computeStageVerticalMixing(const OceanState *State, - const AuxiliaryState *AuxState, - const Array3DReal &TracerArray, - int ThickTimeLevel, int VelTimeLevel); + // Computes KPP boundary layer depth, coefficients and non-local flux. + // Called once per time step by the active time stepper. + void computeKPPFields(const OceanState *State, + const Array3DReal &TracerArray, int ThickTimeLevel, + int VelTimeLevel); // Create a non-default group of tendencies template diff --git a/components/omega/src/timeStepping/ForwardBackwardStepper.cpp b/components/omega/src/timeStepping/ForwardBackwardStepper.cpp index ec490ed311ba..82c1802d70e8 100644 --- a/components/omega/src/timeStepping/ForwardBackwardStepper.cpp +++ b/components/omega/src/timeStepping/ForwardBackwardStepper.cpp @@ -47,6 +47,8 @@ void ForwardBackwardStepper::doStep( prescribeVelocity(State, VelCurLevel, State, VelCurLevel, SimTime); + updateKPPFields(State, TracerCurLevel, ThickCurLevel, VelCurLevel); + // R_u^{n} = RHS_u(u^{n}, h^{n}, t^{n}) Tend->computeVelocityTendencies(State, AuxState, CurTracerArray, ThickCurLevel, VelCurLevel, TracerCurLevel, @@ -86,7 +88,7 @@ void ForwardBackwardStepper::doStep( Tracers::updateTimeLevels(); Pacer::stop("ForwardBackward:haloExch", 3); - applyPostStepVerticalMixing(State, TracerCurLevel, ThickCurLevel, + applyImplicitVerticalMixing(State, TracerCurLevel, ThickCurLevel, VelCurLevel, "ForwardBackward"); validateOceanState(State, AuxState, VertCoord::getDefault(), 0); diff --git a/components/omega/src/timeStepping/RungeKutta2Stepper.cpp b/components/omega/src/timeStepping/RungeKutta2Stepper.cpp index 729dacd2c343..26fb124f76f7 100644 --- a/components/omega/src/timeStepping/RungeKutta2Stepper.cpp +++ b/components/omega/src/timeStepping/RungeKutta2Stepper.cpp @@ -36,6 +36,8 @@ void RungeKutta2Stepper::doStep(OceanState *State, // model state prescribeState(State, CurLevel, State, CurLevel, SimTime); + updateKPPFields(State, CurLevel, CurLevel, CurLevel); + // q = (h,u,phi) // R_q^{n} = RHS_q(u^{n}, h^{n}, phi^{n}, t^{n}) Tend->computeAllTendencies(State, AuxState, CurTracerArray, CurLevel, @@ -70,7 +72,7 @@ void RungeKutta2Stepper::doStep(OceanState *State, // model state Tracers::updateTimeLevels(); Pacer::stop("RK2:haloExch", 3); - applyPostStepVerticalMixing(State, CurLevel, CurLevel, CurLevel, "RK2"); + applyImplicitVerticalMixing(State, CurLevel, CurLevel, CurLevel, "RK2"); validateOceanState(State, AuxState, VertCoord::getDefault(), CurLevel); diff --git a/components/omega/src/timeStepping/RungeKutta4Stepper.cpp b/components/omega/src/timeStepping/RungeKutta4Stepper.cpp index 0cbf060c8c68..c0ed337b7b62 100644 --- a/components/omega/src/timeStepping/RungeKutta4Stepper.cpp +++ b/components/omega/src/timeStepping/RungeKutta4Stepper.cpp @@ -5,7 +5,6 @@ //===----------------------------------------------------------------------===// #include "RungeKutta4Stepper.h" -#include "KPPMix.h" #include "Pacer.h" namespace OMEGA { @@ -84,11 +83,6 @@ void RungeKutta4Stepper::doStep(OceanState *State, // model state Array3DReal NextTracerArray = Tracers::getAll(NextLevel); TimeInstant ForcingStageTime = SimTime; - const bool StageKPPEnabledPrev = Tend->StageVerticalMixingEnabled; - KPPMix *KPPInstance = KPPMix::getInstance(); - Tend->StageVerticalMixingEnabled = - StageKPPEnabledPrev && KPPInstance && KPPInstance->Enabled; - for (int Stage = 0; Stage < NStages; ++Stage) { const TimeInstant StageTime = SimTime + RKC[Stage] * TimeStep; // first stage does: @@ -97,6 +91,7 @@ void RungeKutta4Stepper::doStep(OceanState *State, // model state if (Stage == 0) { weightTracers(NextTracerArray, CurTracerArray, State, CurLevel); prescribeState(State, CurLevel, State, CurLevel, ForcingStageTime); + updateKPPFields(State, CurLevel, CurLevel, CurLevel); Tend->computeAllTendencies(State, AuxState, CurTracerArray, CurLevel, CurLevel, CurLevel, StageTime, RKProj[Stage] * TimeStep); @@ -141,9 +136,7 @@ void RungeKutta4Stepper::doStep(OceanState *State, // model state Tracers::updateTimeLevels(); Pacer::stop("RK4:haloExch", 3); - // Recompute KPP once on the fully updated state before implicit mixing. - applyPostStepVerticalMixing(State, CurLevel, CurLevel, CurLevel, "RK4"); - Tend->StageVerticalMixingEnabled = StageKPPEnabledPrev; + applyImplicitVerticalMixing(State, CurLevel, CurLevel, CurLevel, "RK4"); validateOceanState(State, AuxState, VertCoord::getDefault(), CurLevel); diff --git a/components/omega/src/timeStepping/TimeStepper.cpp b/components/omega/src/timeStepping/TimeStepper.cpp index fc2306adbe40..e1d8abc50869 100644 --- a/components/omega/src/timeStepping/TimeStepper.cpp +++ b/components/omega/src/timeStepping/TimeStepper.cpp @@ -793,17 +793,23 @@ void TimeStepper::finalizeTracersUpdate(const Array3DReal &NextTracers, } //------------------------------------------------------------------------------ -// Recompute stage vertical mixing and apply implicit vertical mixing after -// state/tracer time levels are updated. -void TimeStepper::applyPostStepVerticalMixing( +// Compute KPP fields once per time step, before any tendency evaluation. +void TimeStepper::updateKPPFields(OceanState *State, int TracerTimeLevel, + int ThickTimeLevel, int VelTimeLevel) const { + + Array3DReal CurTracerArray = Tracers::getAll(TracerTimeLevel); + Tend->computeKPPFields(State, CurTracerArray, ThickTimeLevel, VelTimeLevel); +} + +//------------------------------------------------------------------------------ +// Apply implicit vertical mixing after state/tracer time levels are updated. +void TimeStepper::applyImplicitVerticalMixing( OceanState *State, int TracerTimeLevel, int ThickTimeLevel, int VelTimeLevel, const std::string &TimerPrefix) const { Array3DReal CurTracerArray = Tracers::getAll(TracerTimeLevel); AuxState->computeAll(State, CurTracerArray, ThickTimeLevel, VelTimeLevel, TimeStep); - Tend->computeStageVerticalMixing(State, AuxState, CurTracerArray, - ThickTimeLevel, VelTimeLevel); VertMix *VMix = VertMix::getInstance(); if (!VMix) diff --git a/components/omega/src/timeStepping/TimeStepper.h b/components/omega/src/timeStepping/TimeStepper.h index f53fce116393..69c473455c55 100644 --- a/components/omega/src/timeStepping/TimeStepper.h +++ b/components/omega/src/timeStepping/TimeStepper.h @@ -294,9 +294,20 @@ class TimeStepper { int TimeLevel ///< [in] time level index ) const; - /// Recompute stage vertical mixing and apply implicit vertical mixing after - /// state/tracer time levels are updated. - void applyPostStepVerticalMixing( + /// Compute KPP boundary layer depth, mixing coefficients and non-local + /// flux once for the current time step. Must be called before the first + /// tendency evaluation of the step so that every stage and the end-of-step + /// implicit mixing share the same KPP fields. + void + updateKPPFields(OceanState *State, ///< [in] model state + int TracerTimeLevel, ///< [in] tracer time level + int ThickTimeLevel, ///< [in] pseudo-thickness time level + int VelTimeLevel ///< [in] velocity time level + ) const; + + /// Apply implicit vertical mixing after state/tracer time levels are + /// updated, using the KPP fields computed at the start of the step. + void applyImplicitVerticalMixing( OceanState *State, ///< [inout] model state int TracerTimeLevel, ///< [in] tracer time level int ThickTimeLevel, ///< [in] pseudo-thickness time level From bc5946c09e96d99b24e02448a07e38c54bbd938b Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Wed, 26 Aug 2026 15:08:12 -0700 Subject: [PATCH 34/36] Deduplicate KPP device math and decouple non-local shape Factor repeated KPP device math into KPPConstants.h helpers, decouple the non-local flux shape from the coefficient matching option, and correct several array extents that made KPP abort on multi-rank runs. Shared device math (answer-preserving): - Add kppTurbScales, kppMatchShape, kppNonLocalCs, kppClampOBLDepth and kppOBLIndex to KPPConstants.h. - Replace the six duplicated blocks in KPPMix.cpp: two turbulent-scale computations, two match-shape computations, the inline C_s constant, and two OBL clamp plus index-search loops. Collapse the repeated UseInteriorMix && MatchBoth predicate into LocUseMatchedShapes. Non-local shape (changes answers under MatchBoth only): - Always build the non-local flux from the unmatched scalar shape rather than reusing the diffusivity shape. The matched shape is non-zero at the boundary layer base by construction, so gamma was finite there and then dropped discontinuously to zero immediately below. CVMix and MPAS-Ocean likewise treat matching as a diffusivity choice, separate from the non-local shape. - Update testMatchBothInteriorCoefficients, which previously asserted both the matched value inside the boundary layer and zero at its base. Those two expectations cannot both hold. - Update design documentation, which had described the shared shape as intentional. Array extents (answer-preserving): - Size the KPP scratch arrays in Tendencies::computeKPPFields and the boundary layer index copy in VertMix::computeVertMix with NCellsSize rather than NCellsAll, matching the KPPMix members they are copied to and from. Kokkos rejects mismatched deep_copy extents, so KPP aborted on any run where NCellsAll and NCellsSize differ. Single-rank tests could not detect this. - Size the KPPMixTest scratch and expected buffers the same way. Add unit tests for the new helpers. Full Omega suite passes 55/55 on pm-cpu with gnu. --- components/omega/doc/design/KPPMix.md | 10 +- components/omega/src/ocn/KPPConstants.h | 118 +++++++++++- components/omega/src/ocn/KPPMix.cpp | 158 ++++++---------- components/omega/src/ocn/Tendencies.cpp | 18 +- components/omega/src/ocn/VertMix.cpp | 2 +- components/omega/test/ocn/KPPMixTest.cpp | 222 +++++++++++++++++++++-- 6 files changed, 393 insertions(+), 135 deletions(-) diff --git a/components/omega/doc/design/KPPMix.md b/components/omega/doc/design/KPPMix.md index c2948648b25b..83fe2f1d6107 100644 --- a/components/omega/doc/design/KPPMix.md +++ b/components/omega/doc/design/KPPMix.md @@ -110,14 +110,20 @@ instead additive. In the latter case, the shape function greatly simplifies to For either shape function, enhanced diffusivity can be included near the boundary layer base. This can smooth boundary layer deepening in time. -The non-local tracer flux uses the same scalar shape function, scaled by the constant +The non-local tracer flux uses the scalar shape function, scaled by the constant $C_s$ from Eq. (20) of Large et al. (1994) rather than by $h\, w_s$: $$ \gamma_s(\sigma) = C_s\, S_1(\sigma). $$ -Because a single shape function ($S_1$) drives both the diffusivity and nonlocal flux, $K_s$ and $\gamma_s$ cannot become inconsistent with each other when the matching option changes. +Here $S_1$ is always the unmatched scalar shape $\sigma(1-\sigma)^2$, regardless +of the `MatchTechnique` setting. Matching is a property of the diffusivity +profile only: the matched shape is non-zero at $\sigma = -1$ by construction, so +reusing it for $\gamma_s$ would leave a finite non-local flux at the boundary +layer base that drops discontinuously to zero immediately below it. CVMix draws +the same distinction, exposing the non-local shape as a separate choice from the +matching option. diff --git a/components/omega/src/ocn/KPPConstants.h b/components/omega/src/ocn/KPPConstants.h index 4f51e02e20fc..5377c9db0769 100644 --- a/components/omega/src/ocn/KPPConstants.h +++ b/components/omega/src/ocn/KPPConstants.h @@ -144,7 +144,8 @@ Real kppPhiInvMomentum(Real Zeta) { /// @brief Scalar gradient shape function, multiplied by h and the turbulent /// velocity scale to give the KPP diffusivity: Kx = h * w_s * G(sigma). -/// The non-local flux reuses this same shape, scaled by C_s instead of h*w_s. +/// The non-local flux always uses this shape, scaled by C_s instead of h*w_s, +/// even under MatchBoth, so that gamma vanishes at the OBL base. /// REFERENCES: Large et al. (1994) Eq. (11), Eq. (12)-(13), Large et al. (1997) /// /// @param Sigma Normalized vertical position (-z/h) @@ -333,6 +334,121 @@ Real computeTurbVelocityScale(Real UStar, Real BuoyFlux, Real HOBL) { 1.0_Real / 3.0_Real); } +/// @brief Momentum and scalar turbulent velocity scales at a point in the OBL +/// +/// CVMix-style scales: w = kappa * u* * phi^{-1}(zeta) in general, with +/// explicit free-convection limits when u* vanishes. Both scales are returned +/// together because they share the stability coordinate zeta. +/// REFERENCES: Large et al. (1994) Eq. (13), Appendix B +/// +/// @param UStar Friction velocity (m/s) +/// @param BuoyFlux Surface buoyancy flux (m^2/s^3), negative when convective +/// @param HOBL Boundary layer depth (m) +/// @param SigmaLoc Normalized depth [0,1], capped at SurfaceLayerExtent +/// @param Kappa von Karman constant +/// @param WMTurb [out] Momentum turbulent velocity scale w_m (m/s) +/// @param WSTurb [out] Scalar turbulent velocity scale w_s (m/s) +KOKKOS_INLINE_FUNCTION +void kppTurbScales(Real UStar, Real BuoyFlux, Real HOBL, Real SigmaLoc, + Real Kappa, Real &WMTurb, Real &WSTurb) { + WMTurb = 0.0_Real; + WSTurb = 0.0_Real; + + if (UStar > 0.0_Real) { + const Real U3 = UStar * UStar * UStar; + const Real Zeta = + SigmaLoc * HOBL * BuoyFlux * Kappa / Kokkos::max(U3, 1.0e-20_Real); + + // These return phi^{-1}; do not invert again. + WMTurb = Kappa * UStar * Kokkos::max(kppPhiInvMomentum(Zeta), 0.0_Real); + WSTurb = Kappa * UStar * Kokkos::max(kppPhiInvScalar(Zeta), 0.0_Real); + } else if (BuoyFlux < 0.0_Real) { + // Free-convection edge case (u*=0, unstable forcing). + const Real WM3 = -CMoM * SigmaLoc * HOBL * Kappa * BuoyFlux; + const Real WS3 = -CMoS * SigmaLoc * HOBL * Kappa * BuoyFlux; + WMTurb = + Kappa * Kokkos::pow(Kokkos::max(0.0_Real, WM3), 1.0_Real / 3.0_Real); + WSTurb = + Kappa * Kokkos::pow(Kokkos::max(0.0_Real, WS3), 1.0_Real / 3.0_Real); + } +} + +/// @brief Shape value the KPP profile must reach at the OBL base so that it +/// joins the pre-existing interior coefficient there (MatchBoth only). +/// +/// Callers must skip this when no interior mixing is supplied, since +/// InteriorCoeff is then read from an unallocated array. +/// +/// @param InteriorCoeff Interior diffusivity or viscosity at the OBL base +/// @param HOBL Boundary layer depth (m) +/// @param W Turbulent velocity scale matching InteriorCoeff (m/s) +/// @return Shape value at the OBL base (dimensionless) +KOKKOS_INLINE_FUNCTION +Real kppMatchShape(Real InteriorCoeff, Real HOBL, Real W) { + if (HOBL <= 0.0_Real || W <= 0.0_Real) { + return 0.0_Real; + } + + return InteriorCoeff / Kokkos::max(HOBL * W, 1.0e-20_Real); +} + +/// @brief Non-local flux normalization constant +/// C_s = C* * kappa * (c_s * kappa * epsilon)^(1/3), with C* = 10. +/// Evaluates to roughly 6.33 with the default constants. +/// REFERENCES: Large et al. (1994) Eq. (20) +/// +/// @param Kappa von Karman constant +/// @param SurfLayerExtent Surface layer extent epsilon (dimensionless) +/// @return C_s (dimensionless) +KOKKOS_INLINE_FUNCTION +Real kppNonLocalCs(Real Kappa, Real SurfLayerExtent) { + return 10.0_Real * Kappa * + Kokkos::pow(CMoS * Kappa * SurfLayerExtent, 1.0_Real / 3.0_Real); +} + +/// @brief Clamp a trial OBL depth to the range supported by the column +/// +/// @param OBLDepth Trial OBL depth (m) +/// @param MinOBLDepth Lower bound, typically half the top layer thickness (m) +/// @param MaxOBLDepth Upper bound, typically the deepest cell center (m) +/// @param ApplyIceMinimum Whether the sea-ice minimum depth applies +/// @param MinOBLUnderIce Minimum OBL depth under sea ice (m) +/// @return Clamped OBL depth (m) +KOKKOS_INLINE_FUNCTION +Real kppClampOBLDepth(Real OBLDepth, Real MinOBLDepth, Real MaxOBLDepth, + bool ApplyIceMinimum, Real MinOBLUnderIce) { + OBLDepth = Kokkos::fmax(OBLDepth, MinOBLDepth); + + if (ApplyIceMinimum) { + OBLDepth = Kokkos::fmax(OBLDepth, MinOBLUnderIce); + } + + return Kokkos::fmin(OBLDepth, MaxOBLDepth); +} + +/// @brief Index of the cell layer containing a given OBL depth +/// +/// @param ZInterface Geometric height of layer interfaces (m) +/// @param ICell Cell index +/// @param KMin Index of the top active layer +/// @param KMax Index of the bottom active layer +/// @param Ssh Sea surface height (m), since depths are measured below it +/// @param OBLDepth OBL depth (m) +/// @return Layer index bracketing OBLDepth, or KMax if none does +KOKKOS_INLINE_FUNCTION +I4 kppOBLIndex(const Array2DReal &ZInterface, I4 ICell, I4 KMin, I4 KMax, + Real Ssh, Real OBLDepth) { + for (I4 K = KMin; K < KMax; ++K) { + const Real ZAbove = Ssh - ZInterface(ICell, K); + const Real ZBelow = Ssh - ZInterface(ICell, K + 1); + if (OBLDepth >= ZAbove && OBLDepth <= ZBelow) { + return K; + } + } + + return KMax; +} + } // namespace OMEGA::KPP #endif // OMEGA_KPP_CONSTANTS_H diff --git a/components/omega/src/ocn/KPPMix.cpp b/components/omega/src/ocn/KPPMix.cpp index 3b7e0e40bb5b..947907484f16 100644 --- a/components/omega/src/ocn/KPPMix.cpp +++ b/components/omega/src/ocn/KPPMix.cpp @@ -846,21 +846,12 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, Kokkos::abs(ZInterface(ICell, KIntTop) - ZInterface(ICell, KMin)); const Real MinOBLDepth = 0.5_Real * TopLayerThickness; const Real MaxOBLDepth = Ssh - ZMid(ICell, KMax); - OBLDepth = Kokkos::fmax(OBLDepth, MinOBLDepth); - if (IceFrac > LocIceFracThresholdForMinOBL) { - OBLDepth = Kokkos::fmax(OBLDepth, LocMinimumOBLUnderSeaIce); - } - OBLDepth = Kokkos::fmin(OBLDepth, MaxOBLDepth); - - I4 KFinal = KMax; - for (I4 K = KMin; K < KMax; ++K) { - const Real ZAbove = Ssh - ZInterface(ICell, K); - const Real ZBelow = Ssh - ZInterface(ICell, K + 1); - if (OBLDepth >= ZAbove && OBLDepth <= ZBelow) { - KFinal = K; - break; - } - } + OBLDepth = KPP::kppClampOBLDepth( + OBLDepth, MinOBLDepth, MaxOBLDepth, + IceFrac > LocIceFracThresholdForMinOBL, LocMinimumOBLUnderSeaIce); + + const I4 KFinal = + KPP::kppOBLIndex(ZInterface, ICell, KMin, KMax, Ssh, OBLDepth); LocBoundaryLayerDepth(ICell) = OBLDepth; LocIndexBoundaryLayerDepth(ICell) = KFinal; @@ -938,19 +929,14 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, const Real MinOBLDepth = 0.5_Real * TopLayerThickness; const Real MaxOBLDepth = Ssh - ZMid(ICell, KMax); - Real OBLDepth = LocBoundaryLayerDepthSmooth(ICell); - OBLDepth = Kokkos::fmax(OBLDepth, MinOBLDepth); - OBLDepth = Kokkos::fmin(OBLDepth, MaxOBLDepth); + // The sea-ice minimum is deliberately not reapplied here; it was + // already enforced before smoothing. + const Real OBLDepth = KPP::kppClampOBLDepth( + LocBoundaryLayerDepthSmooth(ICell), MinOBLDepth, MaxOBLDepth, + false, 0.0_Real); - I4 KFinal = KMax; - for (I4 K = KMin; K < KMax; ++K) { - const Real ZAbove = Ssh - ZInterface(ICell, K); - const Real ZBelow = Ssh - ZInterface(ICell, K + 1); - if (OBLDepth >= ZAbove && OBLDepth <= ZBelow) { - KFinal = K; - break; - } - } + const I4 KFinal = + KPP::kppOBLIndex(ZInterface, ICell, KMin, KMax, Ssh, OBLDepth); LocBoundaryLayerDepth(ICell) = OBLDepth; LocIndexBoundaryLayerDepth(ICell) = KFinal; @@ -995,18 +981,13 @@ void KPPMix::computeMixingCoefficients( // Capture member variables for use in lambda const Real LocSurfaceLayerExtent = SurfaceLayerExtent; const KPPMatchType LocMatch = MatchTechnique; - // Non-local flux normalization constant from Large et al. (1994) eq. 20: - // C_s = C* * kappa * (c_s * kappa * epsilon)^(1/3) - // where C* = 10, c_s = CMoS = 98.9545, kappa = VonKar, epsilon = - // SurfaceLayerExtent - const Real LocNonLocalCs = - 10.0_Real * VonKar * - Kokkos::pow(KPP::CMoS * VonKar * LocSurfaceLayerExtent, - 1.0_Real / 3.0_Real); + const Real LocNonLocalCs = KPP::kppNonLocalCs(VonKar, LocSurfaceLayerExtent); bool LocUseEnhancedDiffusion = UseEnhancedDiffusion; const Real LocKappa = VonKar; const bool LocUseInteriorMix = InteriorVertDiff.data() != nullptr && InteriorVertVisc.data() != nullptr; + const bool LocUseMatchedShapes = + LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth; // ======================================================================= // Initialize with zero KPP contribution, or precomputed interior mixing for @@ -1071,77 +1052,60 @@ void KPPMix::computeMixingCoefficients( const Real SigmaLoc = Kokkos::fmin( LocSurfaceLayerExtent, Kokkos::fmax(0.0_Real, SigmaCoord)); - Real Zeta = 0.0_Real; Real WMTurb = 0.0_Real; Real WSTurb = 0.0_Real; - - if (UStar > 0.0_Real) { - const Real U3 = UStar * UStar * UStar; - Zeta = SigmaLoc * HOBL * BuoyFlux * LocKappa / - Kokkos::max(U3, 1.0e-20_Real); - - // These return phi^{-1}; do not invert again. - const Real PhiInvM = KPP::kppPhiInvMomentum(Zeta); - const Real PhiInvS = KPP::kppPhiInvScalar(Zeta); - - WMTurb = LocKappa * UStar * Kokkos::max(PhiInvM, 0.0_Real); - WSTurb = LocKappa * UStar * Kokkos::max(PhiInvS, 0.0_Real); - } else if (BuoyFlux < 0.0_Real) { - // Free-convection edge case (u*=0, unstable forcing). - const Real CM = KPP::CMoM; - const Real CS = KPP::CMoS; - const Real WM3 = -CM * SigmaLoc * HOBL * LocKappa * BuoyFlux; - const Real WS3 = -CS * SigmaLoc * HOBL * LocKappa * BuoyFlux; - WMTurb = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, WM3), - 1.0_Real / 3.0_Real); - WSTurb = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, WS3), - 1.0_Real / 3.0_Real); - } + KPP::kppTurbScales(UStar, BuoyFlux, HOBL, SigmaLoc, LocKappa, + WMTurb, WSTurb); // For MatchBoth, the shape value the KPP profile must reach at // the OBL base so that it joins the interior coefficient there. const Real MatchViscShape = - (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth && - HOBL > 0.0_Real && WMTurb > 0.0_Real) - ? LocInteriorVertVisc(ICell, KMatch) / - Kokkos::max(HOBL * WMTurb, 1.0e-20_Real) + LocUseMatchedShapes + ? KPP::kppMatchShape(LocInteriorVertVisc(ICell, KMatch), + HOBL, WMTurb) : 0.0_Real; const Real MatchDiffShape = - (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth && - HOBL > 0.0_Real && WSTurb > 0.0_Real) - ? LocInteriorVertDiff(ICell, KMatch) / - Kokkos::max(HOBL * WSTurb, 1.0e-20_Real) + LocUseMatchedShapes + ? KPP::kppMatchShape(LocInteriorVertDiff(ICell, KMatch), + HOBL, WSTurb) : 0.0_Real; // ======================================================== // Momentum mixing contribution. // ======================================================== - Real ShapeM = - (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth) - ? KPP::kppShapeMatched(Sigma, MatchViscShape) - : KPP::kppShapeMomentum(Sigma); + Real ShapeM = LocUseMatchedShapes + ? KPP::kppShapeMatched(Sigma, MatchViscShape) + : KPP::kppShapeMomentum(Sigma); LocVertVisc(ICell, K) = HOBL * WMTurb * ShapeM; // ======================================================== // Tracer mixing contribution. // ======================================================== - Real ShapeS = - (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth) - ? KPP::kppShapeMatched(Sigma, MatchDiffShape) - : KPP::kppShapeScalar(Sigma); + Real ShapeS = LocUseMatchedShapes + ? KPP::kppShapeMatched(Sigma, MatchDiffShape) + : KPP::kppShapeScalar(Sigma); LocVertDiff(ICell, K) = HOBL * WSTurb * ShapeS; LocTurbulentVelocityScale(ICell, K) = WSTurb; // ======================================================== - // Non-local flux: C_s * G(sigma), reusing the scalar - // diffusivity shape so gamma and K share one profile. + // Non-local flux: C_s * G(sigma). // C_s = C* * kappa * (c_s * kappa * epsilon)^(1/3) // per Large et al. (1994) eq. 20 (~6.33 with default constants) // ======================================================== + // The non-local shape is always the unmatched scalar shape, + // independent of MatchTechnique, so that gamma vanishes at the + // OBL base. The matched shape is non-zero there by + // construction, which would leave a non-local flux at the base + // that jumps to zero just below it. CVMix likewise keeps + // matching (a diffusivity choice) separate from the non-local + // shape. + const Real NonLocalShape = KPP::kppShapeScalar(Sigma); + // Match CVMix behavior: apply non-local term only when // surface buoyancy forcing is unstable/neutral. if (BuoyFlux <= 0.0_Real) { - LocVertNonLocalFlux(ICell, K) = LocNonLocalCs * ShapeS; + LocVertNonLocalFlux(ICell, K) = + LocNonLocalCs * NonLocalShape; } else { LocVertNonLocalFlux(ICell, K) = 0.0; } @@ -1198,46 +1162,28 @@ void KPPMix::computeMixingCoefficients( Real WMKtup = 0.0_Real; Real WSKtup = 0.0_Real; - if (UStar > 0.0_Real) { - const Real U3 = UStar * UStar * UStar; - const Real Zeta = SigmaLoc * HOBL * BuoyFlux * LocKappa / - Kokkos::max(U3, 1.0e-20_Real); - WMKtup = LocKappa * UStar * - Kokkos::max(KPP::kppPhiInvMomentum(Zeta), 0.0_Real); - WSKtup = LocKappa * UStar * - Kokkos::max(KPP::kppPhiInvScalar(Zeta), 0.0_Real); - } else if (BuoyFlux < 0.0_Real) { - const Real WM3 = - -KPP::CMoM * SigmaLoc * HOBL * LocKappa * BuoyFlux; - const Real WS3 = - -KPP::CMoS * SigmaLoc * HOBL * LocKappa * BuoyFlux; - WMKtup = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, WM3), - 1.0_Real / 3.0_Real); - WSKtup = LocKappa * Kokkos::pow(Kokkos::max(0.0_Real, WS3), - 1.0_Real / 3.0_Real); - } + KPP::kppTurbScales(UStar, BuoyFlux, HOBL, SigmaLoc, LocKappa, + WMKtup, WSKtup); const Real MatchViscShape = - (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth && - HOBL > 0.0_Real && WMKtup > 0.0_Real) - ? LocInteriorVertVisc(ICell, KMatch) / - Kokkos::max(HOBL * WMKtup, 1.0e-20_Real) + LocUseMatchedShapes + ? KPP::kppMatchShape(LocInteriorVertVisc(ICell, KMatch), + HOBL, WMKtup) : 0.0_Real; const Real MatchDiffShape = - (LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth && - HOBL > 0.0_Real && WSKtup > 0.0_Real) - ? LocInteriorVertDiff(ICell, KMatch) / - Kokkos::max(HOBL * WSKtup, 1.0e-20_Real) + LocUseMatchedShapes + ? KPP::kppMatchShape(LocInteriorVertDiff(ICell, KMatch), + HOBL, WSKtup) : 0.0_Real; const Real ViscKtup = HOBL * WMKtup * - ((LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth) + (LocUseMatchedShapes ? KPP::kppShapeMatched(SigmaKtup, MatchViscShape) : KPP::kppShapeMomentum(SigmaKtup)); const Real DiffKtup = HOBL * WSKtup * - ((LocUseInteriorMix && LocMatch == KPPMatchType::MatchBoth) + (LocUseMatchedShapes ? KPP::kppShapeMatched(SigmaKtup, MatchDiffShape) : KPP::kppShapeScalar(SigmaKtup)); diff --git a/components/omega/src/ocn/Tendencies.cpp b/components/omega/src/ocn/Tendencies.cpp index f0b624a689f9..d953383722e4 100644 --- a/components/omega/src/ocn/Tendencies.cpp +++ b/components/omega/src/ocn/Tendencies.cpp @@ -1283,10 +1283,11 @@ void Tendencies::computeKPPFields(const OceanState *State, } const I4 NCellsAll = Mesh->NCellsAll; + const I4 NCellsSize = Mesh->NCellsSize; const I4 NVertLayers = VCoord->NVertLayers; - Array2DReal ConservTemp("KPP-ConservTemp", NCellsAll, NVertLayers); - Array2DReal AbsSalinity("KPP-AbsSalinity", NCellsAll, NVertLayers); + Array2DReal ConservTemp("KPP-ConservTemp", NCellsSize, NVertLayers); + Array2DReal AbsSalinity("KPP-AbsSalinity", NCellsSize, NVertLayers); parallelFor( "KPP-ExtractTS", {NCellsAll, NVertLayers}, KOKKOS_LAMBDA(I4 ICell, I4 K) { @@ -1297,7 +1298,7 @@ void Tendencies::computeKPPFields(const OceanState *State, Array2DReal LayerThickCell = State->getPseudoThickness(ThickTimeLevel); Array2DReal NormalVelEdge = State->getNormalVelocity(VelTimeLevel); - Array1DReal SurfacePressure("KPP-SurfacePressure", NCellsAll); + Array1DReal SurfacePressure("KPP-SurfacePressure", NCellsSize); deepCopy(SurfacePressure, 1.0e5_Real); const_cast(VCoord)->computePressure(LayerThickCell, SurfacePressure); @@ -1309,9 +1310,10 @@ void Tendencies::computeKPPFields(const OceanState *State, EqState->SpecVol); OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); - Array2DReal PotentialDensity("KPP-PotentialDensity", NCellsAll, NVertLayers); + Array2DReal PotentialDensity("KPP-PotentialDensity", NCellsSize, + NVertLayers); Array2DReal PotentialDensityPressure("KPP-PotentialDensityPressure", - NCellsAll, NVertLayers); + NCellsSize, NVertLayers); parallelFor( "KPP-PotentialDensityPressure", {NCellsAll, NVertLayers}, KOKKOS_LAMBDA(I4 ICell, I4 K) { @@ -1348,7 +1350,7 @@ void Tendencies::computeKPPFields(const OceanState *State, }); } - Array1DReal IceFraction("KPP-IceFraction", NCellsAll); + Array1DReal IceFraction("KPP-IceFraction", NCellsSize); OMEGA_SCOPE(LocSurfaceFrictionVelocity, KPPInstance->SurfaceFrictionVelocity); @@ -1357,8 +1359,6 @@ void Tendencies::computeKPPFields(const OceanState *State, const EosType LocEosChoice = EqState->EosChoice; const Real LocLinearDRhodT = EqState->getLinearDRhodT(); const Real LocLinearDRhodS = EqState->getLinearDRhodS(); - const Real HeatFluxToTracerFluxFactor = - 1._Real / (RhoSw * (LocEosChoice == EosType::Teos10Eos ? Cp0Sw : CpSw)); const auto *ForcingState = Forcing::getDefault(); if (!ForcingState) { @@ -1435,7 +1435,7 @@ void Tendencies::computeKPPFields(const OceanState *State, LocSnowFlux(ICell) + LocRainFlux(ICell) + LocSeaIceFreshWaterFlux(ICell) + LocIceRunoffFlux(ICell) + LocRiverRunoffFlux(ICell) + LocEvaporationFlux(ICell); - const Real temp_flux = heat_flux * HeatFluxToTracerFluxFactor; + const Real temp_flux = heat_flux * HFluxFac; const Real salt_flux = LocSeaIceSaltFlux(ICell) / RhoSw - freshwater_flux * surface_salinity / RhoSw; const Real spec_vol = diff --git a/components/omega/src/ocn/VertMix.cpp b/components/omega/src/ocn/VertMix.cpp index 914543851b46..46faf652fdc5 100644 --- a/components/omega/src/ocn/VertMix.cpp +++ b/components/omega/src/ocn/VertMix.cpp @@ -228,7 +228,7 @@ void VertMix::computeVertMix(const Array2DReal &NormalVelocity, const Real LocConvDiff = LocComputeVertMixConv.ConvDiff; const Real LocConvTriggerBVF = LocComputeVertMixConv.ConvTriggerBVF; Array1DI4 KPPBoundaryLayerIndex("VertMix-KPPBoundaryLayerIndex", - Mesh->NCellsAll); + Mesh->NCellsSize); deepCopy(KPPBoundaryLayerIndex, -1); KPPMix *KPPInstance = KPPMix::getInstance(); const bool LocKPPEnabled = (KPPInstance && KPPInstance->Enabled); diff --git a/components/omega/test/ocn/KPPMixTest.cpp b/components/omega/test/ocn/KPPMixTest.cpp index 0dd46875dd87..8734f1d0e763 100644 --- a/components/omega/test/ocn/KPPMixTest.cpp +++ b/components/omega/test/ocn/KPPMixTest.cpp @@ -368,6 +368,189 @@ void testTurbulentVelocityScale() { checkResult("turbulent velocity scale", NumErrors); } +void testTurbScales() { + int NumErrors = 0; + + parallelReduce( + "KPPMixTest-TurbScales", {5}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + // 0: wind only, 1: wind + unstable, 2: wind + stable, + // 3: free convection (u*=0, B0<0), 4: calm and stable (both zero) + const Real UStar = (ITest == 3 || ITest == 4) ? 0.0_Real : 0.02_Real; + const Real B0 = ITest == 0 ? 0.0_Real + : ITest == 1 ? -1.0e-7_Real + : ITest == 2 ? 1.0e-7_Real + : ITest == 3 ? -1.0e-7_Real + : 1.0e-7_Real; + const Real HOBL = 50.0_Real; + const Real SigmaLoc = KPP::SurfaceLayerExtent; + + Real WM = -1.0_Real; + Real WS = -1.0_Real; + KPP::kppTurbScales(UStar, B0, HOBL, SigmaLoc, VonKar, WM, WS); + + Real ExpectedWM = 0.0_Real; + Real ExpectedWS = 0.0_Real; + if (UStar > 0.0_Real) { + const Real U3 = UStar * UStar * UStar; + const Real Zeta = + SigmaLoc * HOBL * B0 * VonKar / Kokkos::max(U3, 1.0e-20_Real); + ExpectedWM = VonKar * UStar * + Kokkos::max(KPP::kppPhiInvMomentum(Zeta), 0.0_Real); + ExpectedWS = VonKar * UStar * + Kokkos::max(KPP::kppPhiInvScalar(Zeta), 0.0_Real); + } else if (B0 < 0.0_Real) { + const Real WM3 = -KPP::CMoM * SigmaLoc * HOBL * VonKar * B0; + const Real WS3 = -KPP::CMoS * SigmaLoc * HOBL * VonKar * B0; + ExpectedWM = VonKar * Kokkos::pow(WM3, 1.0_Real / 3.0_Real); + ExpectedWS = VonKar * Kokkos::pow(WS3, 1.0_Real / 3.0_Real); + } + + if (!isApprox(WM, ExpectedWM, RTol, ATol) || + !isApprox(WS, ExpectedWS, RTol, ATol)) + ++ErrorCount; + + // Scales must never go negative, and scalars mix at least as + // efficiently as momentum under unstable forcing. + if (WM < 0.0_Real || WS < 0.0_Real) + ++ErrorCount; + if (B0 < 0.0_Real && WS < WM) + ++ErrorCount; + + // Calm and stable is fully quiescent. + if (ITest == 4 && (WM != 0.0_Real || WS != 0.0_Real)) + ++ErrorCount; + }, + NumErrors); + + checkResult("turbulent scales", NumErrors); +} + +void testMatchShape() { + int NumErrors = 0; + + parallelReduce( + "KPPMixTest-MatchShape", {4}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + const Real Interior = 1.0e-4_Real; + const Real HOBL = ITest == 1 ? 0.0_Real : 50.0_Real; + const Real W = ITest == 2 ? 0.0_Real : 0.01_Real; + + const Real Shape = KPP::kppMatchShape(Interior, HOBL, W); + + // Degenerate HOBL or velocity scale switches matching off. + if (ITest == 1 || ITest == 2) { + if (Shape != 0.0_Real) + ++ErrorCount; + return; + } + + if (!isApprox(Shape, Interior / (HOBL * W), RTol, ATol)) + ++ErrorCount; + + // The matched shape must reproduce the interior coefficient when + // multiplied back by h*w, which is the whole point of matching. + if (!isApprox(HOBL * W * Shape, Interior, RTol, ATol)) + ++ErrorCount; + }, + NumErrors); + + checkResult("match shape", NumErrors); +} + +void testNonLocalCs() { + int NumErrors = 0; + + parallelReduce( + "KPPMixTest-NonLocalCs", {1}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + const Real Cs = KPP::kppNonLocalCs(VonKar, KPP::SurfaceLayerExtent); + const Real Expected = + 10.0_Real * VonKar * + Kokkos::pow(KPP::CMoS * VonKar * KPP::SurfaceLayerExtent, + 1.0_Real / 3.0_Real); + if (!isApprox(Cs, Expected, RTol, ATol)) + ++ErrorCount; + + // Large et al. (1994) quote C_s ~ 6.33 for the default constants. + if (Kokkos::abs(Cs - 6.33_Real) > 0.05_Real) + ++ErrorCount; + }, + NumErrors); + + checkResult("non-local flux constant", NumErrors); +} + +void testClampOBLDepth() { + int NumErrors = 0; + + parallelReduce( + "KPPMixTest-ClampOBLDepth", {5}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + const Real MinDepth = 2.0_Real; + const Real MaxDepth = 95.0_Real; + const Real Input = ITest == 0 ? 40.0_Real + : ITest == 1 ? 1.0_Real + : ITest == 2 ? 200.0_Real + : ITest == 3 ? 1.0_Real + : 1.0_Real; + const bool ApplyIce = (ITest == 3); + + const Real Actual = KPP::kppClampOBLDepth( + Input, MinDepth, MaxDepth, ApplyIce, KPP::MinOBLUnderIce); + + Real Expected = Kokkos::fmax(Input, MinDepth); + if (ApplyIce) + Expected = Kokkos::fmax(Expected, KPP::MinOBLUnderIce); + Expected = Kokkos::fmin(Expected, MaxDepth); + + if (!isApprox(Actual, Expected, RTol, ATol)) + ++ErrorCount; + + // Result must always land inside the supported range. + if (Actual < MinDepth || Actual > MaxDepth) + ++ErrorCount; + }, + NumErrors); + + checkResult("OBL depth clamping", NumErrors); +} + +void testOBLIndex() { + int NumErrors = 0; + + // Single column of five 10 m layers, interfaces at 0,-10,...,-50 m. + constexpr I4 NLayers = 5; + Array2DReal ZInterface("KPPMixTest-OBLIndexZ", 1, NLayers + 1); + parallelFor( + "KPPMixTest-OBLIndexInit", {NLayers + 1}, + KOKKOS_LAMBDA(I4 K) { ZInterface(0, K) = -10.0_Real * K; }); + + parallelReduce( + "KPPMixTest-OBLIndex", {5}, + KOKKOS_LAMBDA(int ITest, int &ErrorCount) { + // Cases 0-3 bracket a layer; case 4 is deeper than the column. + const Real Depth = ITest == 0 ? 5.0_Real + : ITest == 1 ? 25.0_Real + : ITest == 2 ? 20.0_Real + : ITest == 3 ? 0.0_Real + : 100.0_Real; + const I4 Expected = ITest == 0 ? 0 + : ITest == 1 ? 2 + : ITest == 2 ? 1 + : ITest == 3 ? 0 + : NLayers - 1; + + const I4 Actual = + KPP::kppOBLIndex(ZInterface, 0, 0, NLayers - 1, 0.0_Real, Depth); + if (Actual != Expected) + ++ErrorCount; + }, + NumErrors); + + checkResult("OBL index lookup", NumErrors); +} + // Builds a uniform column whose free surface sits at Ssh. All KPP results must // be invariant to Ssh since depths are measured below the free surface. void setCoefficientTestGeometry(Real Ssh = 0.0_Real) { @@ -587,8 +770,10 @@ void testMatchBothInteriorCoefficients() { const Real ExpectedViscMid = TestOBLDepth * TurbVel * SimpleShape + SmoothAtSigma * ExpectedInteriorVisc; const Real MatchDiffShape = ExpectedInteriorDiff / (TestOBLDepth * TurbVel); + // The non-local shape is independent of MatchTechnique, so gamma still + // follows the unmatched scalar shape and vanishes at the OBL base. const Real ExpectedNonLocal = - nonLocalNormalization() * KPP::kppShapeMatched(Sigma, MatchDiffShape); + nonLocalNormalization() * KPP::kppShapeScalar(Sigma); int NumErrors = 0; for (I4 ICell = 0; ICell < Mesh->NCellsAll; ++ICell) { @@ -1638,10 +1823,10 @@ void testBoundaryLayerLangmuir() { KPPInstance->UseLangmuirCirculation = false; KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, UStar, B0, BVF, IceFraction, Wind); - Array1DReal DisabledBLD("KPPMixTest-LangmuirDisabledBLD", Mesh->NCellsAll); - Array2DReal DisabledRi("KPPMixTest-LangmuirDisabledRi", Mesh->NCellsAll, + Array1DReal DisabledBLD("KPPMixTest-LangmuirDisabledBLD", Mesh->NCellsSize); + Array2DReal DisabledRi("KPPMixTest-LangmuirDisabledRi", Mesh->NCellsSize, NVertLayers + 1); - Array2DReal DisabledVt2("KPPMixTest-LangmuirDisabledVt2", Mesh->NCellsAll, + Array2DReal DisabledVt2("KPPMixTest-LangmuirDisabledVt2", Mesh->NCellsSize, NVertLayers + 1); deepCopy(DisabledBLD, KPPInstance->BoundaryLayerDepth); deepCopy(DisabledRi, KPPInstance->BulkRichardsonNumber); @@ -1654,10 +1839,10 @@ void testBoundaryLayerLangmuir() { KPPInstance->IceFractionThresholdForLangmuir = 0.05_Real; KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, UStar, B0, BVF, IceFraction, Wind); - Array1DReal EnabledBLD("KPPMixTest-LangmuirEnabledBLD", Mesh->NCellsAll); - Array2DReal EnabledRi("KPPMixTest-LangmuirEnabledRi", Mesh->NCellsAll, + Array1DReal EnabledBLD("KPPMixTest-LangmuirEnabledBLD", Mesh->NCellsSize); + Array2DReal EnabledRi("KPPMixTest-LangmuirEnabledRi", Mesh->NCellsSize, NVertLayers + 1); - Array2DReal EnabledVt2("KPPMixTest-LangmuirEnabledVt2", Mesh->NCellsAll, + Array2DReal EnabledVt2("KPPMixTest-LangmuirEnabledVt2", Mesh->NCellsSize, NVertLayers + 1); deepCopy(EnabledBLD, KPPInstance->BoundaryLayerDepth); deepCopy(EnabledRi, KPPInstance->BulkRichardsonNumber); @@ -1791,7 +1976,7 @@ void testBoundaryLayerSmoothing() { KPPInstance->UseBLDSmoothing = false; KPPInstance->computeOBLDepth(Density, NormalVelocity, TangentialVelocity, UStar, B0, BVF, IceFraction, Wind); - Array1DReal UnsmoothedBLD("KPPMixTest-UnsmoothedBLD", Mesh->NCellsAll); + Array1DReal UnsmoothedBLD("KPPMixTest-UnsmoothedBLD", Mesh->NCellsSize); deepCopy(UnsmoothedBLD, KPPInstance->BoundaryLayerDepth); const auto UnsmoothedBLDH = createHostMirrorCopy(UnsmoothedBLD); @@ -1865,7 +2050,7 @@ void testEnabledFullCall() { setCoefficientTestGeometry(); VCoord->minMaxLayerEdge(Halo::getDefault()); - Array2DReal Density("KPPMixTest-FullCallDensity", Mesh->NCellsAll, + Array2DReal Density("KPPMixTest-FullCallDensity", Mesh->NCellsSize, NVertLayers); Array2DReal NormalVelocity("KPPMixTest-FullCallNormalVelocity", Mesh->NEdgesSize, NVertLayers); @@ -1898,17 +2083,17 @@ void testEnabledFullCall() { UStar, B0, BVF, IceFraction, Wind); KPPInstance->computeMixingCoefficients(Density, UStar, B0); - Array1DReal ExpectedBLD("KPPMixTest-ExpectedBLD", Mesh->NCellsAll); - Array1DI4 ExpectedBLDIndex("KPPMixTest-ExpectedBLDIndex", Mesh->NCellsAll); - Array2DReal ExpectedBulkRi("KPPMixTest-ExpectedBulkRi", Mesh->NCellsAll, + Array1DReal ExpectedBLD("KPPMixTest-ExpectedBLD", Mesh->NCellsSize); + Array1DI4 ExpectedBLDIndex("KPPMixTest-ExpectedBLDIndex", Mesh->NCellsSize); + Array2DReal ExpectedBulkRi("KPPMixTest-ExpectedBulkRi", Mesh->NCellsSize, NVertLayers + 1); - Array2DReal ExpectedVertDiff("KPPMixTest-ExpectedVertDiff", Mesh->NCellsAll, + Array2DReal ExpectedVertDiff("KPPMixTest-ExpectedVertDiff", Mesh->NCellsSize, NVertLayers + 1); - Array2DReal ExpectedVertVisc("KPPMixTest-ExpectedVertVisc", Mesh->NCellsAll, + Array2DReal ExpectedVertVisc("KPPMixTest-ExpectedVertVisc", Mesh->NCellsSize, NVertLayers + 1); - Array2DReal ExpectedNonLocal("KPPMixTest-ExpectedNonLocal", Mesh->NCellsAll, + Array2DReal ExpectedNonLocal("KPPMixTest-ExpectedNonLocal", Mesh->NCellsSize, NVertLayers + 1); - Array2DReal ExpectedTurbVel("KPPMixTest-ExpectedTurbVel", Mesh->NCellsAll, + Array2DReal ExpectedTurbVel("KPPMixTest-ExpectedTurbVel", Mesh->NCellsSize, NVertLayers + 1); deepCopy(ExpectedBLD, KPPInstance->BoundaryLayerDepth); deepCopy(ExpectedBLDIndex, KPPInstance->IndexBoundaryLayerDepth); @@ -2041,6 +2226,11 @@ int main(int argc, char *argv[]) { testLangmuirFunctions(); testOBLUtilities(); testTurbulentVelocityScale(); + testTurbScales(); + testMatchShape(); + testNonLocalCs(); + testClampOBLDepth(); + testOBLIndex(); } if (TestGroup == "bld" || TestGroup == "all") { testBoundaryLayerDepth(); From 4213aa16906f5fec35bebc053ae4af6aa9ec7087 Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Thu, 27 Aug 2026 07:44:09 -0700 Subject: [PATCH 35/36] moves KPP functors to header in tendencies --- components/omega/src/ocn/Tendencies.cpp | 183 +++++++------------ components/omega/src/ocn/Tendencies.h | 11 ++ components/omega/src/ocn/TendencyTerms.cpp | 15 ++ components/omega/src/ocn/TendencyTerms.h | 197 +++++++++++++++++++-- 4 files changed, 275 insertions(+), 131 deletions(-) diff --git a/components/omega/src/ocn/Tendencies.cpp b/components/omega/src/ocn/Tendencies.cpp index d953383722e4..f96297aa6750 100644 --- a/components/omega/src/ocn/Tendencies.cpp +++ b/components/omega/src/ocn/Tendencies.cpp @@ -533,6 +533,9 @@ Tendencies::Tendencies(const std::string &Name_, ///< [in] Name for tendencies EqState), TracerDiffusion(Mesh, VCoord), TracerHyperDiff(Mesh, VCoord), TracerHorzAdv(Mesh, VCoord), SurfaceTracerRestoring(Mesh), + PotentialDensityCalc(Mesh, VCoord), + KPPSurfaceForcing(Mesh, VCoord, Tracers::IndxTemp, Tracers::IndxSalt, + EqState), CustomThicknessTend(InCustomThicknessTend), CustomVelocityTend(InCustomVelocityTend), EqState(EqState), PGrad(PGrad), VMix(VMix) { @@ -554,6 +557,21 @@ Tendencies::Tendencies(const std::string &Name_, ///< [in] Name for tendencies deepCopy(TempNonLocalTendDiag, 0.0_Real); deepCopy(TempNonLocalColumnSumDiag, 0.0_Real); + // KPP scratch. Extents must match the KPPMix members these are copied to + // and from, so all cell-indexed arrays use NCellsSize. + KPPConservTemp = + Array2DReal("KPP-ConservTemp", Mesh->NCellsSize, VCoord->NVertLayers); + KPPAbsSalinity = + Array2DReal("KPP-AbsSalinity", Mesh->NCellsSize, VCoord->NVertLayers); + KPPSurfacePressure = Array1DReal("KPP-SurfacePressure", Mesh->NCellsSize); + KPPPotentialDensity = Array2DReal("KPP-PotentialDensity", Mesh->NCellsSize, + VCoord->NVertLayers); + KPPRefPressure = Array2DReal("KPP-PotentialDensityPressure", + Mesh->NCellsSize, VCoord->NVertLayers); + KPPTangentialVelEdge = Array2DReal("KPP-TangentialVelEdge", Mesh->NEdgesSize, + VCoord->NVertLayers); + KPPIceFraction = Array1DReal("KPP-IceFraction", Mesh->NCellsSize); + Name = Name_; NTracers = NTracersIn; @@ -1283,11 +1301,10 @@ void Tendencies::computeKPPFields(const OceanState *State, } const I4 NCellsAll = Mesh->NCellsAll; - const I4 NCellsSize = Mesh->NCellsSize; const I4 NVertLayers = VCoord->NVertLayers; - Array2DReal ConservTemp("KPP-ConservTemp", NCellsSize, NVertLayers); - Array2DReal AbsSalinity("KPP-AbsSalinity", NCellsSize, NVertLayers); + OMEGA_SCOPE(ConservTemp, KPPConservTemp); + OMEGA_SCOPE(AbsSalinity, KPPAbsSalinity); parallelFor( "KPP-ExtractTS", {NCellsAll, NVertLayers}, KOKKOS_LAMBDA(I4 ICell, I4 K) { @@ -1298,10 +1315,9 @@ void Tendencies::computeKPPFields(const OceanState *State, Array2DReal LayerThickCell = State->getPseudoThickness(ThickTimeLevel); Array2DReal NormalVelEdge = State->getNormalVelocity(VelTimeLevel); - Array1DReal SurfacePressure("KPP-SurfacePressure", NCellsSize); - deepCopy(SurfacePressure, 1.0e5_Real); + deepCopy(KPPSurfacePressure, 1.0e5_Real); const_cast(VCoord)->computePressure(LayerThickCell, - SurfacePressure); + KPPSurfacePressure); OMEGA_SCOPE(PressureMid, VCoord->PressureMid); @@ -1309,32 +1325,28 @@ void Tendencies::computeKPPFields(const OceanState *State, EqState->computeBruntVaisalaFreqSq(ConservTemp, AbsSalinity, PressureMid, EqState->SpecVol); - OMEGA_SCOPE(MinLayerCell, VCoord->MinLayerCell); - Array2DReal PotentialDensity("KPP-PotentialDensity", NCellsSize, - NVertLayers); - Array2DReal PotentialDensityPressure("KPP-PotentialDensityPressure", - NCellsSize, NVertLayers); + // Potential density referenced to each column's surface pressure + OMEGA_SCOPE(LocPotentialDensityCalc, PotentialDensityCalc); + OMEGA_SCOPE(RefPressure, KPPRefPressure); parallelFor( "KPP-PotentialDensityPressure", {NCellsAll, NVertLayers}, KOKKOS_LAMBDA(I4 ICell, I4 K) { - const I4 KSurf = MinLayerCell(ICell); - PotentialDensityPressure(ICell, K) = PressureMid(ICell, KSurf); + LocPotentialDensityCalc.computeRefPressure(RefPressure, ICell, K, + PressureMid); }); - EqState->computeSpecVolDisp(ConservTemp, AbsSalinity, - PotentialDensityPressure, 0); + EqState->computeSpecVolDisp(ConservTemp, AbsSalinity, RefPressure, 0); + OMEGA_SCOPE(SpecVolPotential, EqState->SpecVolDisplaced); + OMEGA_SCOPE(PotentialDensity, KPPPotentialDensity); parallelFor( "KPP-PotentialDensity", {NCellsAll, NVertLayers}, KOKKOS_LAMBDA(I4 ICell, I4 K) { - PotentialDensity(ICell, K) = - 1.0_Real / Kokkos::max(1.0e-12_Real, SpecVolPotential(ICell, K)); + LocPotentialDensityCalc(PotentialDensity, ICell, K, SpecVolPotential); }); - Array2DReal TangentialVelEdge("KPP-TangentialVelEdge", Mesh->NEdgesSize, - NVertLayers); { TangentialReconOnEdge TanReconEdge(Mesh); - OMEGA_SCOPE(LocTangentialVelEdge, TangentialVelEdge); + OMEGA_SCOPE(LocTangentialVelEdge, KPPTangentialVelEdge); OMEGA_SCOPE(MinLayerEdgeTop, VCoord->MinLayerEdgeTop); OMEGA_SCOPE(MaxLayerEdgeBot, VCoord->MaxLayerEdgeBot); parallelForOuter( @@ -1350,16 +1362,6 @@ void Tendencies::computeKPPFields(const OceanState *State, }); } - Array1DReal IceFraction("KPP-IceFraction", NCellsSize); - - OMEGA_SCOPE(LocSurfaceFrictionVelocity, - KPPInstance->SurfaceFrictionVelocity); - OMEGA_SCOPE(LocSurfaceBuoyancyFlux, KPPInstance->SurfaceBuoyancyFlux); - - const EosType LocEosChoice = EqState->EosChoice; - const Real LocLinearDRhodT = EqState->getLinearDRhodT(); - const Real LocLinearDRhodS = EqState->getLinearDRhodS(); - const auto *ForcingState = Forcing::getDefault(); if (!ForcingState) { LOG_WARN("Tendencies::computeKPPFields: Forcing has not " @@ -1368,103 +1370,54 @@ void Tendencies::computeKPPFields(const OceanState *State, return; } - const auto &SfcStressForcing = ForcingState->SfcStressForcing; - const auto &TracerForcing = ForcingState->TracerForcing; - OMEGA_SCOPE(ZonalStressCell, SfcStressForcing.ZonalStressCell); - OMEGA_SCOPE(MeridStressCell, SfcStressForcing.MeridStressCell); - OMEGA_SCOPE(LocLatentHeatFlux, TracerForcing.LatentHeatFluxCell); - OMEGA_SCOPE(LocSensibleHeatFlux, TracerForcing.SensibleHeatFluxCell); - OMEGA_SCOPE(LocLongWaveHeatFluxUp, TracerForcing.LongWaveHeatFluxUpCell); - OMEGA_SCOPE(LocLongWaveHeatFluxDown, TracerForcing.LongWaveHeatFluxDownCell); - OMEGA_SCOPE(LocSeaIceHeatFlux, TracerForcing.SeaIceHeatFluxCell); - OMEGA_SCOPE(LocShortWaveHeatFlux, TracerForcing.ShortWaveHeatFluxCell); - OMEGA_SCOPE(LocSnowFlux, TracerForcing.SnowFluxCell); - OMEGA_SCOPE(LocRainFlux, TracerForcing.RainFluxCell); - OMEGA_SCOPE(LocEvaporationFlux, TracerForcing.EvaporationFluxCell); - OMEGA_SCOPE(LocSeaIceFreshWaterFlux, TracerForcing.SeaIceFreshWaterFluxCell); - OMEGA_SCOPE(LocIceRunoffFlux, TracerForcing.IceRunoffFluxCell); - OMEGA_SCOPE(LocRiverRunoffFlux, TracerForcing.RiverRunoffFluxCell); - OMEGA_SCOPE(LocSeaIceSaltFlux, TracerForcing.SeaIceSaltFluxCell); - OMEGA_SCOPE(LocSurfaceTracerFlux, SurfaceTracerFlux); - OMEGA_SCOPE(LocSpecVol, EqState->SpecVol); - Teos10BruntVaisalaFreqSq Teos10Coeff(VCoord); + const auto &SfcStress = ForcingState->SfcStressForcing; + const auto &TracerForcing = ForcingState->TracerForcing; - const bool LocUpdateSurfaceTracerFlux = TracerNonLocalFluxEnabled; - const bool LocUseTracerForcing = SfcTracerForcing.Enabled; - const I4 TempTracerIndex = TempIdx; - const I4 SaltTracerIndex = SaltIdx; + KPPSurfaceForcing.UpdateSurfaceTracerFlux = TracerNonLocalFluxEnabled; + KPPSurfaceForcing.UseTracerForcing = SfcTracerForcing.Enabled; - if (LocUpdateSurfaceTracerFlux) { + if (TracerNonLocalFluxEnabled) { deepCopy(SurfaceTracerFlux, 0.0_Real); } deepCopy(KPPInstance->SurfaceBuoyancyFlux, 0.0_Real); + OMEGA_SCOPE(LocKPPSurfaceForcing, KPPSurfaceForcing); + OMEGA_SCOPE(LocFrictionVelocity, KPPInstance->SurfaceFrictionVelocity); + OMEGA_SCOPE(LocBuoyancyFlux, KPPInstance->SurfaceBuoyancyFlux); + OMEGA_SCOPE(LocSurfaceTracerFlux, SurfaceTracerFlux); + OMEGA_SCOPE(IceFraction, KPPIceFraction); + OMEGA_SCOPE(LocSpecVol, EqState->SpecVol); + OMEGA_SCOPE(ZonalStress, SfcStress.ZonalStressCell); + OMEGA_SCOPE(MeridStress, SfcStress.MeridStressCell); + OMEGA_SCOPE(LatentHeatFlux, TracerForcing.LatentHeatFluxCell); + OMEGA_SCOPE(SensibleHeatFlux, TracerForcing.SensibleHeatFluxCell); + OMEGA_SCOPE(LongWaveHeatFluxUp, TracerForcing.LongWaveHeatFluxUpCell); + OMEGA_SCOPE(LongWaveHeatFluxDown, TracerForcing.LongWaveHeatFluxDownCell); + OMEGA_SCOPE(SeaIceHeatFlux, TracerForcing.SeaIceHeatFluxCell); + OMEGA_SCOPE(ShortWaveHeatFlux, TracerForcing.ShortWaveHeatFluxCell); + OMEGA_SCOPE(SnowFlux, TracerForcing.SnowFluxCell); + OMEGA_SCOPE(RainFlux, TracerForcing.RainFluxCell); + OMEGA_SCOPE(EvaporationFlux, TracerForcing.EvaporationFluxCell); + OMEGA_SCOPE(SeaIceFreshWaterFlux, TracerForcing.SeaIceFreshWaterFluxCell); + OMEGA_SCOPE(IceRunoffFlux, TracerForcing.IceRunoffFluxCell); + OMEGA_SCOPE(RiverRunoffFlux, TracerForcing.RiverRunoffFluxCell); + OMEGA_SCOPE(SeaIceSaltFlux, TracerForcing.SeaIceSaltFluxCell); + parallelFor( "KPP-SurfaceForcing", {NCellsAll}, KOKKOS_LAMBDA(I4 ICell) { - const Real tau_x = ZonalStressCell(ICell); - const Real tau_y = MeridStressCell(ICell); - const Real tau_mag = Kokkos::sqrt(tau_x * tau_x + tau_y * tau_y); - LocSurfaceFrictionVelocity(ICell) = - Kokkos::sqrt(Kokkos::max(0.0_Real, tau_mag / RhoSw)); - LocSurfaceBuoyancyFlux(ICell) = 0.0_Real; - if (LocUpdateSurfaceTracerFlux) { - LocSurfaceTracerFlux(TempTracerIndex, ICell) = 0.0_Real; - LocSurfaceTracerFlux(SaltTracerIndex, ICell) = 0.0_Real; - } - IceFraction(ICell) = 0.0_Real; - - if (!LocUseTracerForcing) { - return; - } - - const I4 KSurf = MinLayerCell(ICell); - const Real surface_salinity = AbsSalinity(ICell, KSurf); - const Real surface_temp = ConservTemp(ICell, KSurf); - const Real ct_freezing = - Eos::calcCtFreezing(LocEosChoice, surface_salinity, - PressureMid(ICell, KSurf) * Pa2Db, 0.0_Real); - const Real heat_flux = - LocLatentHeatFlux(ICell) + LocSensibleHeatFlux(ICell) + - LocLongWaveHeatFluxUp(ICell) + LocLongWaveHeatFluxDown(ICell) + - LocSeaIceHeatFlux(ICell) + LocShortWaveHeatFlux(ICell) + - (LocRainFlux(ICell) + LocRiverRunoffFlux(ICell)) * Cp0Sw * - surface_temp + - (LocSnowFlux(ICell) + LocIceRunoffFlux(ICell)) * - (Cp0Sw * ct_freezing - LatIce); - const Real freshwater_flux = - LocSnowFlux(ICell) + LocRainFlux(ICell) + - LocSeaIceFreshWaterFlux(ICell) + LocIceRunoffFlux(ICell) + - LocRiverRunoffFlux(ICell) + LocEvaporationFlux(ICell); - const Real temp_flux = heat_flux * HFluxFac; - const Real salt_flux = LocSeaIceSaltFlux(ICell) / RhoSw - - freshwater_flux * surface_salinity / RhoSw; - const Real spec_vol = - Kokkos::max(1.0e-12_Real, LocSpecVol(ICell, KSurf)); - const Real rho_surface = 1.0_Real / spec_vol; - Real alpha = 0.0_Real; - Real beta = 0.0_Real; - if (LocEosChoice == EosType::Teos10Eos) { - alpha = Teos10Coeff.calcAlpha( - AbsSalinity(ICell, KSurf), ConservTemp(ICell, KSurf), - PressureMid(ICell, KSurf) * Pa2Db, spec_vol); - beta = Teos10Coeff.calcBeta( - AbsSalinity(ICell, KSurf), ConservTemp(ICell, KSurf), - PressureMid(ICell, KSurf) * Pa2Db, spec_vol); - } else if (LocEosChoice == EosType::LinearEos) { - alpha = -LocLinearDRhodT / rho_surface; - beta = LocLinearDRhodS / rho_surface; - } - LocSurfaceBuoyancyFlux(ICell) = - Gravity * (alpha * temp_flux - beta * salt_flux); - if (LocUpdateSurfaceTracerFlux) { - LocSurfaceTracerFlux(TempTracerIndex, ICell) = temp_flux; - LocSurfaceTracerFlux(SaltTracerIndex, ICell) = salt_flux; - } + LocKPPSurfaceForcing( + LocFrictionVelocity, LocBuoyancyFlux, LocSurfaceTracerFlux, + IceFraction, ICell, ConservTemp, AbsSalinity, PressureMid, + LocSpecVol, ZonalStress, MeridStress, LatentHeatFlux, + SensibleHeatFlux, LongWaveHeatFluxUp, LongWaveHeatFluxDown, + SeaIceHeatFlux, ShortWaveHeatFlux, SnowFlux, RainFlux, + EvaporationFlux, SeaIceFreshWaterFlux, IceRunoffFlux, + RiverRunoffFlux, SeaIceSaltFlux); }); Array1DReal WindSpeed10m; KPPInstance->computeKPPMix( - PotentialDensity, NormalVelEdge, TangentialVelEdge, + PotentialDensity, NormalVelEdge, KPPTangentialVelEdge, KPPInstance->SurfaceFrictionVelocity, KPPInstance->SurfaceBuoyancyFlux, EqState->BruntVaisalaFreqSq, IceFraction, WindSpeed10m); diff --git a/components/omega/src/ocn/Tendencies.h b/components/omega/src/ocn/Tendencies.h index b3628ff3e19e..1962ed2462be 100644 --- a/components/omega/src/ocn/Tendencies.h +++ b/components/omega/src/ocn/Tendencies.h @@ -80,6 +80,8 @@ class Tendencies { TracerDiffOnCell TracerDiffusion; TracerHyperDiffOnCell TracerHyperDiff; SurfaceTracerRestoringOnCell SurfaceTracerRestoring; + PotentialDensityOnCell PotentialDensityCalc; + KPPSurfaceForcingOnCell KPPSurfaceForcing; // Surface tracer flux used for KPP non-local tracer tendency [NTracers, // NCellsAll] @@ -90,6 +92,15 @@ class Tendencies { Array2DReal TempNonLocalTendDiag; Array1DReal TempNonLocalColumnSumDiag; + // Scratch used by computeKPPFields, allocated once rather than per step + Array2DReal KPPConservTemp; + Array2DReal KPPAbsSalinity; + Array1DReal KPPSurfacePressure; + Array2DReal KPPPotentialDensity; + Array2DReal KPPRefPressure; + Array2DReal KPPTangentialVelEdge; + Array1DReal KPPIceFraction; + // Enables explicit non-local tracer tendency from KPP bool TracerNonLocalFluxEnabled = false; diff --git a/components/omega/src/ocn/TendencyTerms.cpp b/components/omega/src/ocn/TendencyTerms.cpp index 2353f38049bd..747604b42441 100644 --- a/components/omega/src/ocn/TendencyTerms.cpp +++ b/components/omega/src/ocn/TendencyTerms.cpp @@ -85,6 +85,21 @@ SfcTracerForcingOnCell::SfcTracerForcingOnCell(const HorzMesh *Mesh, MinLayerCell(VCoord->MinLayerCell), MaxLayerCell(VCoord->MaxLayerCell), EosChoice(EosInst->EosChoice) {} +PotentialDensityOnCell::PotentialDensityOnCell(const HorzMesh *Mesh, + const VertCoord *VCoord) + : MinLayerCell(VCoord->MinLayerCell) {} + +KPPSurfaceForcingOnCell::KPPSurfaceForcingOnCell(const HorzMesh *Mesh, + const VertCoord *VCoord, + I4 TempTracerIndex, + I4 SaltTracerIndex, + const Eos *EosInst) + : TempIndex(TempTracerIndex), SaltIndex(SaltTracerIndex), + LinearDRhodT(EosInst->getLinearDRhodT()), + LinearDRhodS(EosInst->getLinearDRhodS()), + MinLayerCell(VCoord->MinLayerCell), EosChoice(EosInst->EosChoice), + Teos10Coeff(VCoord) {} + TracerHorzAdvOnCell::TracerHorzAdvOnCell(const HorzMesh *Mesh, const VertCoord *VCoord) : HorzontalMesh(Mesh), VerticalCoord(VCoord), diff --git a/components/omega/src/ocn/TendencyTerms.h b/components/omega/src/ocn/TendencyTerms.h index a2a8dc09492c..3131b601107e 100644 --- a/components/omega/src/ocn/TendencyTerms.h +++ b/components/omega/src/ocn/TendencyTerms.h @@ -22,6 +22,50 @@ namespace OMEGA { +//------------------------------------------------------------------------------ +// Shared surface flux helpers +// +// Used by the thickness, tracer, and KPP surface forcing functors so the same +// flux definitions are not restated in each. +//------------------------------------------------------------------------------ + +/// Net surface freshwater mass flux (kg/m^2/s) +KOKKOS_INLINE_FUNCTION Real sfcFreshWaterFlux( + I4 ICell, const Array1DReal &SnowFlux, const Array1DReal &RainFlux, + const Array1DReal &EvaporationFlux, const Array1DReal &SeaIceFreshWaterFlux, + const Array1DReal &IceRunoffFlux, const Array1DReal &RiverRunoffFlux) { + return SnowFlux(ICell) + RainFlux(ICell) + EvaporationFlux(ICell) + + SeaIceFreshWaterFlux(ICell) + IceRunoffFlux(ICell) + + RiverRunoffFlux(ICell); +} + +/// Direct surface heat flux (radiative and turbulent), excluding any enthalpy +/// carried by surface mass fluxes +KOKKOS_INLINE_FUNCTION Real sfcDirectHeatFlux( + I4 ICell, const Array1DReal &LatentHeatFlux, + const Array1DReal &SensibleHeatFlux, const Array1DReal &LongWaveHeatFluxUp, + const Array1DReal &LongWaveHeatFluxDown, const Array1DReal &SeaIceHeatFlux, + const Array1DReal &ShortWaveHeatFlux) { + return LatentHeatFlux(ICell) + SensibleHeatFlux(ICell) + + LongWaveHeatFluxUp(ICell) + LongWaveHeatFluxDown(ICell) + + SeaIceHeatFlux(ICell) + ShortWaveHeatFlux(ICell); +} + +/// Enthalpy carried into the ocean by surface mass fluxes. +/// Liquid mass fluxes (rain, rivers) enter at the local SST. Solid mass fluxes +/// (snow, frozen runoff) are melted locally by the ocean, so they enter at the +/// freezing point less a constant latent heat of fusion. +/// +/// @param CtTop Conservative temperature of the top layer +/// @param CtFrz Freezing conservative temperature at the top layer +KOKKOS_INLINE_FUNCTION Real sfcMassFluxEnthalpy( + I4 ICell, Real CtTop, Real CtFrz, const Array1DReal &SnowFlux, + const Array1DReal &RainFlux, const Array1DReal &IceRunoffFlux, + const Array1DReal &RiverRunoffFlux) { + return (RainFlux(ICell) + RiverRunoffFlux(ICell)) * Cp0Sw * CtTop + + (SnowFlux(ICell) + IceRunoffFlux(ICell)) * (Cp0Sw * CtFrz - LatIce); +} + /// Divergence of pseudo-thickness flux at cell centers, for updating /// pseudo-thickness arrays class PseudoThicknessFluxDivOnCell { @@ -394,10 +438,9 @@ class SfcThicknessForcingOnCell { return; } - const Real FreshWaterFlux = SnowFlux(ICell) + RainFlux(ICell) + - EvaporationFlux(ICell) + - SeaIceFreshWaterFlux(ICell) + - IceRunoffFlux(ICell) + RiverRunoffFlux(ICell); + const Real FreshWaterFlux = sfcFreshWaterFlux( + ICell, SnowFlux, RainFlux, EvaporationFlux, SeaIceFreshWaterFlux, + IceRunoffFlux, RiverRunoffFlux); Tend(ICell, KTop) += (FreshWaterFlux + SeaIceSaltFlux(ICell)) / RhoSw; } @@ -443,19 +486,12 @@ class SfcTracerForcingOnCell { const Real CtTop = TracerCell(TempIndex, ICell, KTop); // Heat tendencies are due to direct heat fluxes + enthalpy fluxes - // The enthalpy of liquid water is assumed to be: - // - local SST for liquid mass fluxes (rain, rivers) - // - local freezing point for solid --> liq mass fluxes (snow, frozen - // runoff) - // - solid mass fluxes are locally melted by the ocean (constant Lat - // heat of fusion) const Real HeatFlux = - LatentHeatFlux(ICell) + SensibleHeatFlux(ICell) + - LongWaveHeatFluxUp(ICell) + LongWaveHeatFluxDown(ICell) + - SeaIceHeatFlux(ICell) + ShortWaveHeatFlux(ICell) + - (RainFlux(ICell) + RiverRunoffFlux(ICell)) * Cp0Sw * CtTop + - (SnowFlux(ICell) + IceRunoffFlux(ICell)) * - (Cp0Sw * CtFrz - LatIce); + sfcDirectHeatFlux(ICell, LatentHeatFlux, SensibleHeatFlux, + LongWaveHeatFluxUp, LongWaveHeatFluxDown, + SeaIceHeatFlux, ShortWaveHeatFlux) + + sfcMassFluxEnthalpy(ICell, CtTop, CtFrz, SnowFlux, RainFlux, + IceRunoffFlux, RiverRunoffFlux); Tend(TempIndex, ICell, KTop) += HeatFlux * HFluxFac; } @@ -473,6 +509,135 @@ class SfcTracerForcingOnCell { EosType EosChoice; }; +/// Potential density referenced to the surface, used by the KPP boundary +/// layer depth search. +class PotentialDensityOnCell { + public: + bool Enabled = false; + + PotentialDensityOnCell(const HorzMesh *Mesh, const VertCoord *VCoord); + + /// Fills the reference pressure used for the displaced specific volume: + /// every layer in a column is referenced to that column's surface pressure + KOKKOS_FUNCTION void + computeRefPressure(const Array2DReal &RefPressure, I4 ICell, I4 K, + const Array2DReal &PressureMid) const { + RefPressure(ICell, K) = PressureMid(ICell, MinLayerCell(ICell)); + } + + /// Inverts the surface-referenced specific volume to give potential density + KOKKOS_FUNCTION void operator()(const Array2DReal &PotentialDensity, + I4 ICell, I4 K, + const Array2DReal &SpecVolDisplaced) const { + PotentialDensity(ICell, K) = + 1.0_Real / Kokkos::max(1.0e-12_Real, SpecVolDisplaced(ICell, K)); + } + + private: + Array1DI4 MinLayerCell; +}; + +/// Surface forcing inputs consumed by the KPP boundary layer scheme: friction +/// velocity, buoyancy flux, and the surface tracer fluxes that scale the +/// non-local term. +class KPPSurfaceForcingOnCell { + public: + bool Enabled = false; + + /// Store surface tracer fluxes for the KPP non-local tracer tendency + bool UpdateSurfaceTracerFlux = false; + + /// When false no coupled tracer forcing is active, so only the friction + /// velocity is set and the buoyancy and tracer fluxes stay zero + bool UseTracerForcing = false; + + KPPSurfaceForcingOnCell(const HorzMesh *Mesh, const VertCoord *VCoord, + I4 TempTracerIndex, I4 SaltTracerIndex, + const Eos *EosInst); + + KOKKOS_FUNCTION void operator()( + const Array1DReal &FrictionVelocity, const Array1DReal &BuoyancyFlux, + const Array2DReal &SurfaceTracerFlux, const Array1DReal &IceFraction, + I4 ICell, const Array2DReal &ConservTemp, const Array2DReal &AbsSalinity, + const Array2DReal &PressureMid, const Array2DReal &SpecVol, + const Array1DReal &ZonalStress, const Array1DReal &MeridStress, + const Array1DReal &LatentHeatFlux, const Array1DReal &SensibleHeatFlux, + const Array1DReal &LongWaveHeatFluxUp, + const Array1DReal &LongWaveHeatFluxDown, + const Array1DReal &SeaIceHeatFlux, const Array1DReal &ShortWaveHeatFlux, + const Array1DReal &SnowFlux, const Array1DReal &RainFlux, + const Array1DReal &EvaporationFlux, + const Array1DReal &SeaIceFreshWaterFlux, + const Array1DReal &IceRunoffFlux, const Array1DReal &RiverRunoffFlux, + const Array1DReal &SeaIceSaltFlux) const { + + const Real TauX = ZonalStress(ICell); + const Real TauY = MeridStress(ICell); + const Real TauMag = Kokkos::sqrt(TauX * TauX + TauY * TauY); + + FrictionVelocity(ICell) = + Kokkos::sqrt(Kokkos::max(0.0_Real, TauMag / RhoSw)); + BuoyancyFlux(ICell) = 0.0_Real; + if (UpdateSurfaceTracerFlux) { + SurfaceTracerFlux(TempIndex, ICell) = 0.0_Real; + SurfaceTracerFlux(SaltIndex, ICell) = 0.0_Real; + } + // Sea ice coupling is not wired in yet, so KPP sees an ice-free ocean + IceFraction(ICell) = 0.0_Real; + + if (!UseTracerForcing) { + return; + } + + const I4 KTop = MinLayerCell(ICell); + const Real SaTop = AbsSalinity(ICell, KTop); + const Real CtTop = ConservTemp(ICell, KTop); + const Real PTopDb = PressureMid(ICell, KTop) * Pa2Db; + const Real CtFrz = + Eos::calcCtFreezing(EosChoice, SaTop, PTopDb, 0.0_Real); + + // Mirrors the enthalpy treatment in SfcTracerForcingOnCell + const Real HeatFlux = sfcDirectHeatFlux( + ICell, LatentHeatFlux, SensibleHeatFlux, LongWaveHeatFluxUp, + LongWaveHeatFluxDown, SeaIceHeatFlux, ShortWaveHeatFlux); + const Real FreshWaterFlux = sfcFreshWaterFlux( + ICell, SnowFlux, RainFlux, EvaporationFlux, SeaIceFreshWaterFlux, + IceRunoffFlux, RiverRunoffFlux); + + const Real TempFlux = HeatFlux * HFluxFac; + const Real SaltFlux = + SeaIceSaltFlux(ICell) / RhoSw - FreshWaterFlux * SaTop / RhoSw; + + const Real SpVol = Kokkos::max(1.0e-12_Real, SpecVol(ICell, KTop)); + const Real RhoTop = 1.0_Real / SpVol; + + Real Alpha = 0.0_Real; + Real Beta = 0.0_Real; + if (EosChoice == EosType::Teos10Eos) { + Alpha = Teos10Coeff.calcAlpha(SaTop, CtTop, PTopDb, SpVol); + Beta = Teos10Coeff.calcBeta(SaTop, CtTop, PTopDb, SpVol); + } else if (EosChoice == EosType::LinearEos) { + Alpha = -LinearDRhodT / RhoTop; + Beta = LinearDRhodS / RhoTop; + } + + BuoyancyFlux(ICell) = Gravity * (Alpha * TempFlux - Beta * SaltFlux); + if (UpdateSurfaceTracerFlux) { + SurfaceTracerFlux(TempIndex, ICell) = TempFlux; + SurfaceTracerFlux(SaltIndex, ICell) = SaltFlux; + } + } + + private: + I4 TempIndex; + I4 SaltIndex; + Real LinearDRhodT; + Real LinearDRhodS; + Array1DI4 MinLayerCell; + EosType EosChoice; + Teos10BruntVaisalaFreqSq Teos10Coeff; +}; + // Tracer horizontal advection term class TracerHorzAdvOnCell { public: From cb185f204c1429288bf50193c3a61e2310466b7c Mon Sep 17 00:00:00 2001 From: Luke Van Roekel Date: Thu, 27 Aug 2026 07:48:27 -0700 Subject: [PATCH 36/36] removes unneeded debug information --- components/omega/src/ocn/KPPMix.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/components/omega/src/ocn/KPPMix.cpp b/components/omega/src/ocn/KPPMix.cpp index 947907484f16..05051fb2a233 100644 --- a/components/omega/src/ocn/KPPMix.cpp +++ b/components/omega/src/ocn/KPPMix.cpp @@ -942,8 +942,6 @@ void KPPMix::computeOBLDepth(const Array2DReal &PotentialDensity, LocIndexBoundaryLayerDepth(ICell) = KFinal; }); } - - LOG_INFO("KPPMix::computeOBLDepth: OBL depth computed"); } /// Stage 2: Compute KPP mixing contribution or matched coefficients @@ -1216,9 +1214,6 @@ void KPPMix::computeMixingCoefficients( } } }); - - LOG_INFO("KPPMix::computeMixingCoefficients: Phase 2 mixing coefficients " - "computed"); } /// Register fields with I/O system