diff --git a/compass/ocean/__init__.py b/compass/ocean/__init__.py
index 3c50d5125e..5d86c11962 100644
--- a/compass/ocean/__init__.py
+++ b/compass/ocean/__init__.py
@@ -1,4 +1,5 @@
from compass.mpas_core import MpasCore
+from compass.ocean.tests.turbulence_closure import TurbulenceClosure
from compass.ocean.tests.baroclinic_channel import BaroclinicChannel
from compass.ocean.tests.dam_break import DamBreak
from compass.ocean.tests.drying_slope import DryingSlope
@@ -32,6 +33,7 @@ def __init__(self):
"""
super().__init__(name='ocean')
+ self.add_test_group(TurbulenceClosure(mpas_core=self))
self.add_test_group(BaroclinicChannel(mpas_core=self))
self.add_test_group(DamBreak(mpas_core=self))
self.add_test_group(DryingSlope(mpas_core=self))
diff --git a/compass/ocean/tests/turbulence_closure/__init__.py b/compass/ocean/tests/turbulence_closure/__init__.py
new file mode 100644
index 0000000000..21cd27881d
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/__init__.py
@@ -0,0 +1,85 @@
+from compass.ocean.tests.turbulence_closure.decomp_test import DecompTest
+from compass.ocean.tests.turbulence_closure.default import Default
+from compass.ocean.tests.turbulence_closure.restart_test import RestartTest
+from compass.testgroup import TestGroup
+
+
+class TurbulenceClosure(TestGroup):
+ """
+ A test group for turbulence closure test cases
+ """
+ def __init__(self, mpas_core):
+ """
+ mpas_core : compass.MpasCore
+ the MPAS core that this test group belongs to
+ """
+ super().__init__(mpas_core=mpas_core, name='turbulence_closure')
+
+ for resolution in [1e4]:
+ self.add_test_case(
+ DecompTest(test_group=self, resolution=resolution))
+ self.add_test_case(
+ RestartTest(test_group=self, resolution=resolution))
+ for resolution in [1, 2, 1e4]:
+ for forcing in ['cooling', 'evaporation']:
+ self.add_test_case(
+ Default(test_group=self, resolution=resolution,
+ forcing=forcing))
+
+
+def configure(resolution, forcing, config):
+ """
+ Modify the configuration options for one of the turbulence closure test
+ cases
+
+ Parameters
+ ----------
+ resolution : float
+ The resolution of the test case in meters
+
+ config : configparser.ConfigParser
+ Configuration options for this test case
+ """
+ # The resolution parameters are different for different resolutions
+ # to match existing simulations
+ if resolution > 1e3:
+ nx = 16
+ ny = 50
+ vert_levels = 20
+ bottom_depth = 1e3
+ elif resolution <= 1e3 and resolution > 5:
+ nx = 50
+ ny = 50
+ vert_levels = 50
+ bottom_depth = 100.0
+ elif resolution <= 5 and resolution > 1:
+ nx = 150
+ ny = 150
+ vert_levels = 50
+ bottom_depth = 100.0
+ elif resolution <= 1:
+ nx = 128
+ ny = 128
+ vert_levels = 128
+ bottom_depth = 128.0
+
+ config.set('turbulence_closure', 'nx', f'{nx}')
+ config.set('turbulence_closure', 'ny', f'{ny}')
+ config.set('turbulence_closure', 'dc', f'{resolution}')
+ config.set('vertical_grid', 'vert_levels', f'{vert_levels}')
+ config.set('vertical_grid', 'bottom_depth', f'{bottom_depth}')
+
+ if forcing == 'cooling':
+ config.set('turbulence_closure', 'surface_heat_flux', '-100')
+ config.set('turbulence_closure', 'surface_freshwater_flux', '0')
+ config.set('turbulence_closure', 'interior_temperature_gradient',
+ '0.1')
+ config.set('turbulence_closure', 'interior_salinity_gradient', '0')
+ config.set('turbulence_closure', 'wind_stress_zonal', '0')
+ if forcing == 'evaporation':
+ config.set('turbulence_closure', 'surface_heat_flux', '0')
+ config.set('turbulence_closure', 'surface_freshwater_flux', '0.429')
+ config.set('turbulence_closure', 'interior_temperature_gradient', '0')
+ config.set('turbulence_closure', 'interior_salinity_gradient',
+ '-0.025')
+ config.set('turbulence_closure', 'wind_stress_zonal', '0')
diff --git a/compass/ocean/tests/turbulence_closure/boundary_values.py b/compass/ocean/tests/turbulence_closure/boundary_values.py
new file mode 100644
index 0000000000..c44fb3c0f5
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/boundary_values.py
@@ -0,0 +1,210 @@
+import numpy
+
+
+def add_boundary_arrays(mesh, x_nonperiodic, y_nonperiodic): # noqa: C901
+ """
+ Computes boundary arrays necessary for LES test cases
+ Values are appended to a MPAS mesh dataset
+
+ if the domain is doubly periodic, returns array of all zeros
+ as arrays are meaningless in that case.
+
+ Parameters
+ ----------
+ mesh : xarray dataset containing an MPAS mesh
+
+ x_nonperiodic : logical if x direction is non periodic
+
+ y_nonperiodic : logical if y direction is non periodic
+ """
+
+ nCells = mesh.dims['nCells']
+ neonc = mesh.nEdgesOnCell.values
+ eonc = mesh.edgesOnCell.values - 1
+ conc = mesh.cellsOnCell.values - 1
+ nVertLevels = mesh.dims['nVertLevels']
+ mlc = mesh.maxLevelCell.values - 1
+ cone = mesh.cellsOnEdge.values - 1
+
+ if (not x_nonperiodic) and (not y_nonperiodic):
+ mesh['distanceToBoundary'] = (
+ ['nCells', 'nVertLevels'],
+ numpy.zeros((nCells, nVertLevels)))
+ mesh['boundaryNormalAngle'] = (
+ ['nCells', 'nVertLevels'],
+ numpy.zeros((nCells, nVertLevels)))
+ mesh['boundaryCellMask'] = (
+ ['nCells', 'nVertLevels'],
+ numpy.zeros((nCells, nVertLevels)).astype(int))
+ return mesh
+
+ # surface first and save until later
+ indVals = []
+ angle = mesh.angleEdge.values
+ xc = mesh.xEdge.values
+ yc = mesh.yEdge.values
+ xCell = mesh.xCell.values
+ yCell = mesh.yCell.values
+ aCell = numpy.zeros(nCells)
+ bCell = numpy.zeros(nCells)
+ xEdge = []
+ yEdge = []
+ for i in range(nCells):
+ if conc[i, :].min() < 0:
+ aEdge = 0
+ count = 0
+ for j in range(neonc[i]):
+ if conc[i, j] < 0:
+ indVals.append(i)
+ xEdge.append(xc[eonc[i, j]])
+ yEdge.append(yc[eonc[i, j]])
+ # compute dx, dy
+ # check angle edge
+ # add pi if needed
+ # check if over 2pi correct if needed
+ dx = xc[eonc[i, j]] - xCell[i]
+ dy = yc[eonc[i, j]] - yCell[i]
+ if dx <= 0 and dy <= 0: # first quadrant
+ if angle[eonc[i, j]] >= numpy.pi / 2.:
+ aEdge += angle[eonc[i, j]] - numpy.pi
+ count += 1
+ else:
+ aEdge += angle[eonc[i, j]]
+ count += 1
+
+ # fourth quadrant, but reference to 0 not 2pi
+ elif dx <= 0 and dy >= 0:
+ if angle[eonc[i, j]] < 3.0 * numpy.pi / 2.0:
+ aEdge += angle[eonc[i, j]] + numpy.pi
+ count += 1
+ else:
+ aEdge += angle[eonc[i, j]]
+ count += 1
+
+ elif dx >= 0 and dy >= 0: # third quadrant
+ if angle[eonc[i, j]] < numpy.pi / 2.0:
+ # not in correct place
+ aEdge += angle[eonc[i, j]] + numpy.pi
+ count += 1
+ else:
+ aEdge += angle[eonc[i, j]]
+ count += 1
+
+ else: # quadrant 2
+ if angle[eonc[i, j]] > 3.0 * numpy.pi / 2.0:
+ # wrong place
+ aEdge += angle[eonc[i, j]] - numpy.pi
+ count += 1
+ else:
+ aEdge += angle[eonc[i, j]]
+ count += 1
+ if count > 0:
+ aCellTemp = aEdge / count
+ if aCellTemp > numpy.pi:
+ aCellTemp = 2.0 * numpy.pi - aCellTemp
+ aCell[i] = aCellTemp
+ bCell[i] = 1
+
+ # with angle and index arrays, now can do distance
+ dist = numpy.zeros((nCells, nVertLevels))
+ angleCells = numpy.zeros((nCells, nVertLevels))
+ boundaryCells = numpy.zeros((nCells, nVertLevels))
+ for i in range(nCells):
+ d = numpy.sqrt((xCell[i] - xEdge)**2 + (yCell[i] - yEdge)**2)
+ ind = d.argmin()
+ angleCells[i, 0] = aCell[indVals[ind]]
+ boundaryCells[i, 0] = bCell[i]
+ dist[i, 0] = d.min()
+
+ # now to the rest
+ for k in range(1, nVertLevels):
+ inds = numpy.where(k == mlc)[0]
+ edgeList = []
+ cellList = []
+ if len(inds) > 0:
+ # First step is forming list of edges bordering maxLC
+ for i in range(len(inds)):
+ for j in range(neonc[inds[i]]):
+ if conc[inds[i], j] not in inds:
+ edgeList.append(eonc[inds[i], j])
+ for i in range(len(edgeList)):
+ c1 = cone[edgeList[i], 0]
+ c2 = cone[edgeList[i], 1]
+ if c1 in inds:
+ if c2 not in cellList:
+ cellList.append(c2)
+ if c2 in inds:
+ if c1 not in cellList:
+ cellList.append(c1)
+
+ for i in range(len(cellList)):
+ aEdge = 0
+ count = 0
+ for j in range(neonc[cellList[i]]):
+ if eonc[cellList[i], j] in edgeList:
+ indVals.append(cellList[i])
+ xEdge.append(xc[eonc[cellList[i], j]])
+ yEdge.append(yc[eonc[cellList[i], j]])
+ # compute dx, dy
+ # check angle edge
+ # add pi if needed
+ # check if over 2pi correct if needed
+ dx = xc[eonc[cellList[i], j]] - xCell[cellList[i]]
+ dy = yc[eonc[cellList[i], j]] - yCell[cellList[i]]
+ if dx <= 0 and dy <= 0: # first quadrant
+ if angle[eonc[cellList[i], j]] >= numpy.pi / 2.:
+ aEdge += angle[eonc[cellList[i], j]] - numpy.pi
+ count += 1
+ else:
+ aEdge += angle[eonc[cellList[i], j]]
+ count += 1
+ # fourth quadrant, but reference to 0 not 2pi
+ elif dx <= 0 and dy >= 0:
+ if {(angle[eonc[cellList[i], j]] <
+ 3.0 * numpy.pi / 2.0)}:
+ aEdge += angle[eonc[cellList[i], j]] + numpy.pi
+ count += 1
+ else:
+ aEdge += angle[eonc[cellList[i], j]]
+ count += 1
+ elif dx >= 0 and dy >= 0: # third quadrant
+ # not in correct place
+ if angle[eonc[cellList[i], j]] < numpy.pi / 2.0:
+ aEdge += angle[eonc[cellList[i], j]] + numpy.pi
+ count += 1
+ else:
+ aEdge += angle[eonc[cellList[i], j]]
+ count += 1
+ else: # quadrant 2
+ # wrong place
+ if {(angle[eonc[cellList[i], j]] >
+ 3.0 * numpy.pi / 2.0)}:
+ aEdge += angle[eonc[cellList[i], j]] - numpy.pi
+ count += 1
+ else:
+ aEdge += angle[eonc[cellList[i], j]]
+ count += 1
+ if count > 0:
+ aCellTemp = aEdge / count
+ if aCellTemp > numpy.pi:
+ aCellTemp = 2.0 * numpy.pi - aCellTemp
+ aCell[cellList[i]] = aCellTemp
+ bCell[cellList[i]] = 1
+
+ for ii in range(nCells):
+ d = numpy.sqrt((xCell[ii] - xEdge)**2 +
+ (yCell[ii] - yEdge)**2)
+ ind = d.argmin()
+ angleCells[ii, k] = aCell[indVals[ind]]
+ boundaryCells[ii, k] = bCell[ii]
+ dist[ii, k] = d.min()
+ else:
+ dist[:, k] = dist[:, 0]
+ angleCells[:, k] = angleCells[:, 0]
+ boundaryCells[:, k] = boundaryCells[:, 0]
+ mesh['distanceToBoundary'] = (['nCells', 'nVertLevels'], dist)
+ mesh['boundaryNormalAngle'] = (['nCells', 'nVertLevels'], angleCells)
+ mesh['boundaryCellMask'] = (['nCells', 'nVertLevels'],
+ boundaryCells.astype(int))
+
+ return mesh
diff --git a/compass/ocean/tests/turbulence_closure/decomp_test/__init__.py b/compass/ocean/tests/turbulence_closure/decomp_test/__init__.py
new file mode 100644
index 0000000000..e0f7ae809b
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/decomp_test/__init__.py
@@ -0,0 +1,67 @@
+from compass.testcase import TestCase
+from compass.ocean.tests.turbulence_closure.initial_state import InitialState
+from compass.ocean.tests.turbulence_closure.forward import Forward
+from compass.ocean.tests import turbulence_closure
+from compass.validate import compare_variables
+
+
+class DecompTest(TestCase):
+ """
+ A decomposition test case for the turbulence closure test group, which
+ makes sure the model produces identical results on 1 and 4 cores.
+
+ Attributes
+ ----------
+ resolution : float
+ The resolution of the test case in meters
+ """
+
+ def __init__(self, test_group, resolution, forcing='cooling'):
+ """
+ Create the test case
+
+ Parameters
+ ----------
+ test_group : compass.ocean.tests.turbulence_closure.TurbulenceClosure
+ The test group that this test case belongs to
+
+ resolution : float
+ The resolution of the test case in meters
+ """
+ name = 'decomp_test'
+ self.resolution = resolution
+ if resolution >= 1e3:
+ res_name = f'{int(resolution/1e3)}km'
+ else:
+ res_name = f'{int(resolution)}m'
+ subdir = f'{res_name}/{forcing}/{name}'
+ super().__init__(test_group=test_group, name=name,
+ subdir=subdir)
+
+ self.add_step(
+ InitialState(test_case=self, resolution=resolution))
+
+ for procs in [4, 8]:
+ name = '{}proc'.format(procs)
+ self.add_step(
+ Forward(test_case=self, name=name, subdir=name, ntasks=procs,
+ openmp_threads=1, resolution=resolution))
+
+ def configure(self):
+ """
+ Modify the configuration options for this test case.
+ """
+ turbulence_closure.configure(self.resolution, self.config)
+
+ # no run() method is needed
+
+ def validate(self):
+ """
+ Test cases can override this method to perform validation of variables
+ and timers
+ """
+ variables = ['temperature', 'salinity', 'layerThickness',
+ 'normalVelocity']
+ compare_variables(test_case=self, variables=variables,
+ filename1='4proc/output.nc',
+ filename2='8proc/output.nc')
diff --git a/compass/ocean/tests/turbulence_closure/default/__init__.py b/compass/ocean/tests/turbulence_closure/default/__init__.py
new file mode 100644
index 0000000000..70483f0418
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/default/__init__.py
@@ -0,0 +1,68 @@
+from compass.ocean.tests import turbulence_closure
+from compass.ocean.tests.turbulence_closure.forward import Forward
+from compass.ocean.tests.turbulence_closure.initial_state import InitialState
+from compass.ocean.tests.turbulence_closure.viz import Viz
+from compass.testcase import TestCase
+
+
+class Default(TestCase):
+ """
+ The default test case for the turbulence closure test group simply creates
+ the mesh and initial condition, then performs a short forward run on 4
+ cores.
+
+ Attributes
+ ----------
+ resolution : float
+ The resolution of the test case in meters
+ """
+
+ def __init__(self, test_group, resolution, forcing='cooling'):
+ """
+ Create the test case
+
+ Parameters
+ ----------
+ test_group : compass.ocean.tests.turbulence_closure.TurbulenceClosure
+ The test group that this test case belongs to
+
+ resolution : float
+ The resolution of the test case in meters
+
+ forcing: str
+ The forcing applied to the test case
+ """
+ name = 'default'
+ self.resolution = resolution
+ self.forcing = forcing
+ if resolution >= 1e3:
+ res_name = f'{int(resolution/1e3)}km'
+ else:
+ res_name = f'{int(resolution)}m'
+ subdir = f'{res_name}/{forcing}/{name}'
+ super().__init__(test_group=test_group, name=name,
+ subdir=subdir)
+
+ if resolution <= 5:
+ ntasks = 128
+ plot_comparison = True
+ else:
+ ntasks = 4
+ plot_comparison = False
+
+ self.add_step(
+ InitialState(test_case=self, resolution=resolution))
+ self.add_step(
+ Forward(test_case=self, ntasks=ntasks, openmp_threads=1,
+ resolution=resolution))
+ self.add_step(Viz(test_case=self, resolution=resolution,
+ forcing=forcing, do_comparison=plot_comparison))
+
+ def configure(self):
+ """
+ Modify the configuration options for this test case.
+ """
+ turbulence_closure.configure(self.resolution, self.forcing,
+ self.config)
+
+ # no run() is needed because we're doing the default: running all steps
diff --git a/compass/ocean/tests/turbulence_closure/forward.py b/compass/ocean/tests/turbulence_closure/forward.py
new file mode 100644
index 0000000000..aac73f1af6
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/forward.py
@@ -0,0 +1,97 @@
+from compass.model import run_model
+from compass.step import Step
+
+
+class Forward(Step):
+ """
+ A step for performing forward MPAS-Ocean runs as part of turbulence
+ closure test cases.
+
+ Attributes
+ ----------
+ resolution : str
+ The resolution of the test case
+ """
+ def __init__(self, test_case, resolution, name='forward', subdir=None,
+ ntasks=1, min_tasks=None, openmp_threads=1, nu=None):
+ """
+ Create a new test case
+
+ Parameters
+ ----------
+ test_case : compass.TestCase
+ The test case this step belongs to
+
+ resolution : str
+ The resolution of the test case
+
+ name : str
+ the name of the test case
+
+ subdir : str, optional
+ the subdirectory for the step. The default is ``name``
+
+ ntasks: int, optional
+ the number of tasks the step would ideally use. If fewer tasks
+ are available on the system, the step will run on all available
+ cores as long as this is not below ``min_tasks``
+
+ min_tasks : int, optional
+ the number of tasks the step requires. If the system has fewer
+ than this number of tasks, the step will fail
+
+ openmp_threads : int, optional
+ the number of OpenMP threads the step will use
+ """
+ self.resolution = resolution
+ if min_tasks is None:
+ min_tasks = ntasks
+ super().__init__(test_case=test_case, name=name, subdir=subdir,
+ ntasks=ntasks, min_tasks=min_tasks,
+ openmp_threads=openmp_threads)
+ self.add_namelist_file('compass.ocean.tests.turbulence_closure',
+ 'namelist.forward')
+ if resolution < 500:
+ self.add_namelist_file('compass.ocean.tests.turbulence_closure',
+ 'namelist.les.forward')
+ if resolution < 100:
+ self.add_namelist_file('compass.ocean.tests.turbulence_closure',
+ 'namelist.nonhydro.forward')
+
+ # make sure output is double precision
+ self.add_streams_file('compass.ocean.streams', 'streams.output')
+
+ self.add_streams_file('compass.ocean.tests.turbulence_closure',
+ 'streams.forward')
+
+ self.add_input_file(filename='init.nc',
+ target='../initial_state/ocean.nc')
+ self.add_input_file(
+ filename='forcing.nc',
+ target='../initial_state/init_mode_forcing_data.nc')
+ self.add_input_file(filename='graph.info',
+ target='../initial_state/culled_graph.info')
+
+ self.add_model_as_input()
+
+ self.add_output_file(filename='output.nc')
+
+ # no setup() is needed
+
+ def run(self):
+ """
+ Run this step of the test case
+ """
+ # update the time step
+ resolution = self.resolution
+ if resolution == '10km':
+ self.update_namelist_at_runtime({'config_dt':
+ "'0000_00:00:01'"})
+ elif resolution == '2m':
+ self.update_namelist_at_runtime({'config_dt':
+ "'0000_00:00:00.1'"})
+ elif resolution == '1m':
+ self.update_namelist_at_runtime({'config_dt':
+ "'0000_00:00:00.1'"})
+
+ run_model(self)
diff --git a/compass/ocean/tests/turbulence_closure/initial_state.py b/compass/ocean/tests/turbulence_closure/initial_state.py
new file mode 100644
index 0000000000..d02f6da0e5
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/initial_state.py
@@ -0,0 +1,205 @@
+import matplotlib.pyplot as plt
+import numpy as np
+import xarray as xr
+from mpas_tools.io import write_netcdf
+from mpas_tools.mesh.conversion import convert, cull
+from mpas_tools.planar_hex import make_planar_hex_mesh
+
+from compass.ocean.tests.turbulence_closure.boundary_values import (
+ add_boundary_arrays,
+)
+from compass.ocean.vertical import init_vertical_coord
+from compass.step import Step
+
+
+class InitialState(Step):
+ """
+ A step for creating a mesh and initial condition for turbulence closure
+ test cases
+
+ Attributes
+ ----------
+ resolution : str
+ The resolution of the test case
+ """
+ def __init__(self, test_case, resolution):
+ """
+ Create the step
+
+ Parameters
+ ----------
+ test_case : compass.TestCase
+ The test case this step belongs to
+
+ resolution : str
+ The resolution of the test case
+ """
+ super().__init__(test_case=test_case, name='initial_state')
+ self.resolution = resolution
+
+ for file in ['base_mesh.nc', 'culled_graph.info', 'culled_mesh.nc',
+ 'init_mode_forcing_data.nc', 'ocean.nc']:
+ self.add_output_file(file)
+
+ def run(self):
+ """
+ Run this step of the test case
+ """
+ config = self.config
+ logger = self.logger
+
+ section = config['turbulence_closure']
+ nx = section.getint('nx')
+ ny = section.getint('ny')
+ dc = section.getfloat('dc')
+
+ x_nonperiodic = section.getboolean('x_nonperiodic')
+ y_nonperiodic = section.getboolean('y_nonperiodic')
+ disturbance_amplitude = section.getfloat('disturbance_amplitude')
+ surface_temperature = section.getfloat('surface_temperature')
+ interior_temperature_shape = section.get('interior_temperature_shape')
+ interior_salinity_shape = section.get('interior_salinity_shape')
+ surface_salinity = section.getfloat('surface_salinity')
+ mixed_layer_salinity_gradient = \
+ section.getfloat('mixed_layer_salinity_gradient')
+ mixed_layer_temperature_gradient = \
+ section.getfloat('mixed_layer_temperature_gradient')
+ mixed_layer_depth_salinity = \
+ section.getfloat('mixed_layer_depth_salinity')
+ mixed_layer_depth_temperature = \
+ section.getfloat('mixed_layer_depth_temperature')
+ interior_salinity_gradient = \
+ section.getfloat('interior_salinity_gradient')
+ interior_temperature_gradient = \
+ section.getfloat('interior_temperature_gradient')
+ interior_temperature_c0 = section.getfloat('interior_temperature_c0')
+ interior_temperature_c1 = section.getfloat('interior_temperature_c1')
+ interior_temperature_c2 = section.getfloat('interior_temperature_c2')
+ interior_temperature_c3 = section.getfloat('interior_temperature_c3')
+ interior_temperature_c4 = section.getfloat('interior_temperature_c4')
+ interior_temperature_c5 = section.getfloat('interior_temperature_c4')
+ interior_salinity_c0 = section.getfloat('interior_salinity_c0')
+ interior_salinity_c1 = section.getfloat('interior_salinity_c1')
+ interior_salinity_c2 = section.getfloat('interior_salinity_c2')
+ interior_salinity_c3 = section.getfloat('interior_salinity_c3')
+ interior_salinity_c4 = section.getfloat('interior_salinity_c4')
+ interior_salinity_c5 = section.getfloat('interior_salinity_c4')
+ surface_heat_flux = section.getfloat('surface_heat_flux')
+ surface_freshwater_flux = section.getfloat('surface_freshwater_flux')
+ wind_stress_zonal = section.getfloat('wind_stress_zonal')
+ wind_stress_meridional = section.getfloat('wind_stress_meridional')
+ coriolis_parameter = section.getfloat('coriolis_parameter')
+ bottom_depth = config.getfloat('vertical_grid', 'bottom_depth')
+
+ dsMesh = make_planar_hex_mesh(nx=nx, ny=ny, dc=dc,
+ nonperiodic_x=x_nonperiodic,
+ nonperiodic_y=y_nonperiodic)
+
+ write_netcdf(dsMesh, 'base_mesh.nc')
+
+ dsMesh = cull(dsMesh, logger=logger)
+ dsMesh = convert(dsMesh, graphInfoFileName='culled_graph.info',
+ logger=logger)
+ write_netcdf(dsMesh, 'culled_mesh.nc')
+
+ ds = dsMesh.copy()
+ dsForcing = dsMesh.copy()
+ xCell = ds.xCell
+
+ ds['bottomDepth'] = bottom_depth * xr.ones_like(xCell)
+ ds['ssh'] = xr.zeros_like(xCell)
+
+ init_vertical_coord(config, ds)
+
+ dsForcing['windStressZonal'] = wind_stress_zonal * xr.ones_like(xCell)
+ dsForcing['windStressMeridional'] = \
+ wind_stress_meridional * xr.ones_like(xCell)
+ dsForcing['sensibleHeatFlux'] = surface_heat_flux * xr.ones_like(xCell)
+ dsForcing['rainFlux'] = surface_freshwater_flux * xr.ones_like(xCell)
+
+ write_netcdf(dsForcing, 'init_mode_forcing_data.nc')
+
+ normalVelocity = xr.zeros_like(ds.xEdge)
+ normalVelocity, _ = xr.broadcast(normalVelocity, ds.refBottomDepth)
+ normalVelocity = normalVelocity.transpose('nEdges', 'nVertLevels')
+
+ temperature = xr.zeros_like(ds.layerThickness)
+
+ salinity = xr.zeros_like(ds.layerThickness)
+
+ zMid = ds.refZMid.values
+
+ temperature_at_mld = (surface_temperature -
+ mixed_layer_temperature_gradient *
+ mixed_layer_depth_temperature)
+ if interior_temperature_shape == 'linear':
+ initial_temperature = np.where(
+ zMid >= -mixed_layer_depth_temperature,
+ surface_temperature + mixed_layer_temperature_gradient * zMid,
+ temperature_at_mld + interior_temperature_gradient * zMid)
+ elif interior_temperature_shape == 'polynomial':
+ initial_temperature = np.where(
+ zMid >= -mixed_layer_depth_temperature,
+ surface_temperature + mixed_layer_temperature_gradient * zMid,
+ interior_temperature_c0 +
+ interior_temperature_c1 * zMid +
+ interior_temperature_c2 * zMid**2. +
+ interior_temperature_c3 * zMid**3. +
+ interior_temperature_c4 * zMid**4. +
+ interior_temperature_c5 * zMid**5.)
+ else:
+ print('interior_temperature_shape is not supported')
+
+ salinity_at_mld = (surface_salinity -
+ mixed_layer_salinity_gradient *
+ mixed_layer_depth_salinity)
+ if interior_salinity_shape == 'linear':
+ initial_salinity = np.where(
+ zMid >= -mixed_layer_depth_salinity,
+ surface_salinity + mixed_layer_salinity_gradient * zMid,
+ salinity_at_mld + interior_salinity_gradient * zMid)
+ elif interior_salinity_shape == 'polynomial':
+ initial_salinity = np.where(
+ zMid >= -mixed_layer_depth_salinity,
+ surface_salinity + mixed_layer_salinity_gradient * zMid,
+ interior_salinity_c0 +
+ interior_salinity_c1 * zMid +
+ interior_salinity_c2 * zMid**2. +
+ interior_salinity_c3 * zMid**3. +
+ interior_salinity_c4 * zMid**4. +
+ interior_salinity_c5 * zMid**5.)
+ else:
+ print('interior_salinity_shape is not supported')
+
+ normalVelocity = normalVelocity.expand_dims(dim='Time', axis=0)
+ ds['normalVelocity'] = normalVelocity
+
+ temperature[0, :, :] = initial_temperature
+ temperature[0, :, 0] += disturbance_amplitude * 2. * \
+ (np.random.rand(len(ds.xCell.values)) - 0.5)
+ salinity[0, :, :] = initial_salinity
+ ds['temperature'] = temperature
+ ds['salinity'] = salinity
+ ds['fCell'] = coriolis_parameter * xr.ones_like(xCell)
+ ds['fEdge'] = coriolis_parameter * xr.ones_like(ds.xEdge)
+ ds['fVertex'] = coriolis_parameter * xr.ones_like(ds.xVertex)
+
+ ds = add_boundary_arrays(ds, x_nonperiodic, y_nonperiodic)
+
+ write_netcdf(ds, 'ocean.nc')
+
+ plt.figure(dpi=100)
+ plt.plot(initial_temperature, zMid, 'k-')
+ plt.xlabel('PT (C)')
+ plt.ylabel('z (m)')
+ plt.savefig('pt_depth_t0h.png',
+ bbox_inches='tight', dpi=200)
+ plt.close()
+
+ plt.figure(dpi=100)
+ plt.plot(initial_salinity, zMid, 'k-')
+ plt.xlabel('SA (g/kg)')
+ plt.ylabel('z (m)')
+ plt.savefig('sa_depth_t0h.png',
+ bbox_inches='tight', dpi=200)
+ plt.close()
diff --git a/compass/ocean/tests/turbulence_closure/namelist.forward b/compass/ocean/tests/turbulence_closure/namelist.forward
new file mode 100644
index 0000000000..d1b9c76235
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/namelist.forward
@@ -0,0 +1,18 @@
+config_write_output_on_startup = .false.
+config_time_integrator = 'split_implicit'
+config_btr_si_partition_match_mode = .false.
+config_run_duration = '0004_00:00:00'
+config_use_implicit_bottom_drag = .false.
+config_zonal_ssh_grad = 0.0
+config_meridional_ssh_grad = 0.0
+config_use_mom_div2 = .true.
+config_mom_div2 = .00002
+config_use_mom_div4 = .false.
+config_mom_div4 = 1.0e12
+config_use_activeTracers_surface_bulk_forcing = .true.
+config_vertMom_del2 = 2e-6
+config_tracer_del2 = 2e-6
+config_mom_del2 = .true.
+config_mom_del2 = 2e-6
+config_cvmix_background_diffusion = 1.0e-2
+config_cvmix_background_viscosity = 1.0e-2
diff --git a/compass/ocean/tests/turbulence_closure/namelist.les.forward b/compass/ocean/tests/turbulence_closure/namelist.les.forward
new file mode 100644
index 0000000000..f26b031187
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/namelist.les.forward
@@ -0,0 +1,13 @@
+config_LES_mode = .true.
+config_use_two_equation_turbulence_model = .true.
+config_two_equation_model_choice = 'omega'
+config_use_cvmix = .false.
+config_two_equation_length_min = 0.1
+config_two_equation_tke_min = 1e-10
+config_two_equation_psi_min = 1e-10
+config_tke_ini = 1.0e-30
+config_LES_noise_enable = .true.
+config_LES_noise_max_time = 150
+config_LES_noise_min_level = 5
+config_LES_noise_max_level = 45
+config_LES_noise_perturbation_magnitude = 1e-4
diff --git a/compass/ocean/tests/turbulence_closure/namelist.nonhydro.forward b/compass/ocean/tests/turbulence_closure/namelist.nonhydro.forward
new file mode 100644
index 0000000000..c5b9b0c098
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/namelist.nonhydro.forward
@@ -0,0 +1,5 @@
+config_enable_nonhydrostatic_mode = .true.
+config_nonhydrostatic_preconditioner = 'bjacobi'
+config_nonhydrostatic_solver_type = 'cg'
+config_nonhydrostatic_solve_surface_boundary_condition = 'pressureTopGradientBottom'
+config_thickness_flux_type = 'upwind'
diff --git a/compass/ocean/tests/turbulence_closure/restart_test/__init__.py b/compass/ocean/tests/turbulence_closure/restart_test/__init__.py
new file mode 100644
index 0000000000..25b1ec4f46
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/restart_test/__init__.py
@@ -0,0 +1,75 @@
+from compass.testcase import TestCase
+from compass.ocean.tests.baroclinic_channel.initial_state import InitialState
+from compass.ocean.tests.baroclinic_channel.forward import Forward
+from compass.ocean.tests import baroclinic_channel
+from compass.validate import compare_variables
+
+
+class RestartTest(TestCase):
+ """
+ A restart test case for the baroclinic channel test group, which makes sure
+ the model produces identical results with one longer run and two shorter
+ runs with a restart in between.
+
+ Attributes
+ ----------
+ resolution : float
+ The resolution of the test case in meters
+ """
+
+ def __init__(self, test_group, resolution, forcing='cooling'):
+ """
+ Create the test case
+
+ Parameters
+ ----------
+ test_group : compass.ocean.tests.baroclinic_channel.BaroclinicChannel
+ The test group that this test case belongs to
+
+ resolution : float
+ The resolution of the test case in meters
+ """
+ name = 'restart_test'
+ self.resolution = resolution
+ if resolution >= 1e3:
+ res_name = f'{int(resolution/1e3)}km'
+ else:
+ res_name = f'{int(resolution)}m'
+ subdir = f'{res_name}/{forcing}/{name}'
+ super().__init__(test_group=test_group, name=name,
+ subdir=subdir)
+
+ self.add_step(
+ InitialState(test_case=self, resolution=resolution))
+
+ for part in ['full', 'restart']:
+ name = '{}_run'.format(part)
+ step = Forward(test_case=self, name=name, subdir=name, ntasks=4,
+ openmp_threads=1, resolution=resolution)
+
+ step.add_namelist_file(
+ 'compass.ocean.tests.baroclinic_channel.restart_test',
+ 'namelist.{}'.format(part))
+ step.add_streams_file(
+ 'compass.ocean.tests.baroclinic_channel.restart_test',
+ 'streams.{}'.format(part))
+ self.add_step(step)
+
+ def configure(self):
+ """
+ Modify the configuration options for this test case.
+ """
+ baroclinic_channel.configure(self.resolution, self.config)
+
+ # no run() method is needed
+
+ def validate(self):
+ """
+ Test cases can override this method to perform validation of variables
+ and timers
+ """
+ variables = ['temperature', 'salinity', 'layerThickness',
+ 'normalVelocity']
+ compare_variables(test_case=self, variables=variables,
+ filename1='full_run/output.nc',
+ filename2='restart_run/output.nc')
diff --git a/compass/ocean/tests/turbulence_closure/restart_test/namelist.full b/compass/ocean/tests/turbulence_closure/restart_test/namelist.full
new file mode 100644
index 0000000000..d99b25960d
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/restart_test/namelist.full
@@ -0,0 +1,3 @@
+config_start_time = '0001-01-01_00:00:00'
+config_run_duration = '0000_00:10:00'
+config_write_output_on_startup = .false.
diff --git a/compass/ocean/tests/turbulence_closure/restart_test/namelist.restart b/compass/ocean/tests/turbulence_closure/restart_test/namelist.restart
new file mode 100644
index 0000000000..620cd94498
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/restart_test/namelist.restart
@@ -0,0 +1,4 @@
+config_start_time = '0001-01-01_00:05:00'
+config_run_duration = '0000_00:05:00'
+config_write_output_on_startup = .false.
+config_do_restart = .true.
diff --git a/compass/ocean/tests/turbulence_closure/restart_test/streams.full b/compass/ocean/tests/turbulence_closure/restart_test/streams.full
new file mode 100644
index 0000000000..5d80d1abb5
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/restart_test/streams.full
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/compass/ocean/tests/turbulence_closure/restart_test/streams.restart b/compass/ocean/tests/turbulence_closure/restart_test/streams.restart
new file mode 100644
index 0000000000..c99ec09db0
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/restart_test/streams.restart
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/compass/ocean/tests/turbulence_closure/streams.forward b/compass/ocean/tests/turbulence_closure/streams.forward
new file mode 100644
index 0000000000..dc6a77fe5f
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/streams.forward
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compass/ocean/tests/turbulence_closure/turbulence_closure.cfg b/compass/ocean/tests/turbulence_closure/turbulence_closure.cfg
new file mode 100644
index 0000000000..c755c8422d
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/turbulence_closure.cfg
@@ -0,0 +1,87 @@
+# Options related to the vertical grid
+[vertical_grid]
+
+# the type of vertical grid
+grid_type = uniform
+
+# The type of vertical coordinate (e.g. z-level, z-star)
+coord_type = z-star
+
+# Whether to use "partial" or "full", or "None" to not alter the topography
+partial_cell_type = None
+
+# The minimum fraction of a layer for partial cells
+min_pc_fraction = 0.1
+
+# config options for baroclinic channel testcases
+[turbulence_closure]
+
+# Logical determining if grid is not periodic in x-direction
+x_nonperiodic = False
+
+# Logical determining if grid is not periodic in y-direction
+y_nonperiodic = False
+
+# Temperature at surface (^oC)
+surface_temperature = 20
+
+# Salinity at surface (PSU)
+surface_salinity = 35
+
+# Depth of mixed layer in temperature (m)
+mixed_layer_depth_temperature = 0
+
+# Depth of mixed layer in salinity (m)
+mixed_layer_depth_salinity = 0
+
+# Temperature gradient in the mixed layer (^oC/m)
+mixed_layer_temperature_gradient = 0
+
+# Salinity gradient in the mixed layer (PSU/m)
+mixed_layer_salinity_gradient = 0
+
+# Interior temperature shape, linear or polynomial
+interior_temperature_shape = linear
+
+# Temperature gradient in the interior (^oC/m)
+interior_temperature_gradient = 0.1
+
+# Temperature polynomial coefficients
+interior_temperature_c0 = 271.65
+interior_temperature_c1 = -0.42
+interior_temperature_c2 = -8.0e-3
+interior_temperature_c3 = -6.75e-5
+interior_temperature_c4 = -2.66e-7
+interior_temperature_c5 = -4.02e-10
+
+# Interior salinity shape, linear or polynomial
+interior_salinity_shape = linear
+
+# Salinity gradient in the interior (PSU/m)
+interior_salinity_gradient = 0
+
+# Salinity polynomial coefficients
+interior_salinity_c0 = 22.36
+interior_salinity_c1 = -0.3
+interior_salinity_c2 = -3.83e-3
+interior_salinity_c3 = -2.54e-5
+interior_salinity_c4 = -8.53e-8
+interior_salinity_c5 = -1.18e-10
+
+# Disturbance amplitude (added to initiate convection faster; ^oC)
+disturbance_amplitude = 0.005
+
+# Surface heat flux (W/m^2)
+surface_heat_flux = -100
+
+# Surface freshwater flux (kg / (m^2 s))
+surface_freshwater_flux = 0
+
+# Surface zonal windstress (N/m^2)
+wind_stress_zonal = 0
+
+# Surface meiridional windstress (N/m^2)
+wind_stress_meridional = 0
+
+# Coriolis parameter for entire domain.
+coriolis_parameter = 1.0e-4
diff --git a/compass/ocean/tests/turbulence_closure/viz.py b/compass/ocean/tests/turbulence_closure/viz.py
new file mode 100644
index 0000000000..405182ad9b
--- /dev/null
+++ b/compass/ocean/tests/turbulence_closure/viz.py
@@ -0,0 +1,195 @@
+import matplotlib.pyplot as plt
+import numpy as np
+import xarray as xr
+
+from compass.step import Step
+
+
+class Viz(Step):
+ """
+ A step for visualizing a cross-section through the domain
+ """
+ def __init__(self, test_case, resolution, forcing, do_comparison=False):
+ """
+ Create the step
+
+ Parameters
+ ----------
+ test_case : compass.TestCase
+ The test case this step belongs to
+ """
+ super().__init__(test_case=test_case, name='viz')
+
+ self.add_input_file(filename='output.nc',
+ target='../forward/output.nc')
+ self.add_input_file(filename='mesh.nc',
+ target='../initial_state/culled_mesh.nc')
+
+ if do_comparison:
+ if forcing == 'cooling':
+ forcing_name = 'c02'
+ elif forcing == 'evaporation':
+ forcing_name = 'e04'
+ else:
+ do_comparison = False
+
+ if do_comparison:
+ # Compare all cases with 1m resolution PALM output
+ suffix = 'g128_l128'
+ filename = f'case_{forcing_name}_{suffix}.nc'
+ self.add_input_file(filename='palm.nc', target=filename,
+ database='turbulence_closure')
+
+ self.do_comparison = do_comparison
+
+ def run(self):
+ """
+ Run this step of the test case
+ """
+
+ figsize = [6.4, 4.8]
+ markersize = 5
+
+ dsInit = xr.open_dataset('../forward/init.nc')
+ ds = xr.open_dataset('output.nc')
+ if self.do_comparison:
+ ds_palm = xr.open_dataset('palm.nc')
+
+ if 'Time' not in ds.dims:
+ print('Dataset missing time dimension')
+ return
+
+ nt = ds.sizes['Time'] # number of timesteps
+ tidx = nt - 1
+ seconds_per_day = 24.0 * 3600.0
+ if 'daysSinceStartOfSim' in ds.keys():
+ time = ds.daysSinceStartOfSim
+ else:
+ # This routine is not generic but should not be used as
+ # daysSinceStartOfSim is included in the streams file
+ time0 = 2.0 + (7.0 / 24.0)
+ dt = 0.5 / 24.0
+ time = np.linspace(time0 + dt, time0 + nt * dt, num=nt)
+
+ if self.do_comparison:
+ time_palm = ds_palm.time
+ time_palm_day = (time_palm.astype('float64') /
+ (seconds_per_day * 1e9))
+ tidx_palm = np.argmin(np.abs(np.subtract(
+ time_palm_day.values, time[tidx])))
+
+ ds = ds.isel(Time=tidx)
+ ds_palm = ds_palm.isel(time=tidx_palm)
+
+ if 'yEdge' not in ds.keys():
+ ds['yEdge'] = dsInit.yEdge
+ ds = ds.sortby('yEdge')
+
+ # Get mesh variables
+ xCell = dsInit.xCell
+ yCell = dsInit.yCell
+ zMid = dsInit.refZMid
+
+ # Import cell quantities
+ temperature = ds.temperature
+ temperature_z = temperature.mean(dim='nCells')
+ salinity = ds.salinity
+ salinity_z = salinity.mean(dim='nCells')
+ w = ds.verticalVelocity
+ w_zTop = w[:, 0]
+
+ velocityZonal = ds.velocityZonal
+ velocityZonal_z = velocityZonal.mean(dim='nCells')
+ velocityMeridional = ds.velocityMeridional
+ velocityMeridional_z = velocityMeridional.mean(dim='nCells')
+ buoyancyProduction = ds.buoyancyProduction
+ buoyancyProduction_z = buoyancyProduction.mean(dim='nCells')
+ wpt = ds.temperatureVerticalAdvectionTendency
+ wpt_z = wpt.mean(dim='nCells')
+
+ if self.do_comparison:
+ alpha_T = ds_palm.alpha_T
+ if 'beta_S' in ds_palm.keys():
+ beta_S = ds_palm.beta_S
+ else:
+ beta_S = 7.8e-4
+ pt_palm = ds_palm.pt - 273.15
+ sa_palm = ds_palm.sa
+ u_palm = ds_palm.u
+ v_palm = ds_palm.v
+ wpt_palm = np.add(ds_palm['w*pt*'].values, ds_palm['w"pt"'].values)
+ wsa_palm = np.add(ds_palm['w*sa*'].values, ds_palm['w"sa"'].values)
+ temp1 = np.multiply(alpha_T, wpt_palm)
+ temp2 = np.multiply(beta_S, wsa_palm)
+ buoyancyProduction_palm = np.multiply(9.81,
+ np.subtract(temp1, temp2))
+ zu_palm = ds_palm.zu
+ z_palm = ds_palm.zprho
+
+ # Figures
+
+ plt.figure(figsize=figsize, dpi=100)
+ plt.plot(temperature_z.values, zMid, 'k-')
+ if self.do_comparison:
+ plt.plot(pt_palm.values, z_palm.values, 'b-')
+ plt.xlabel('PT (C)')
+ plt.ylabel('z (m)')
+ plt.savefig(f'pt_depth_t{int(time[tidx]*24.0)}h.png',
+ bbox_inches='tight', dpi=200)
+ plt.close()
+
+ plt.figure(figsize=figsize, dpi=100)
+ plt.plot(wpt_z, zMid, 'k-')
+ if self.do_comparison:
+ plt.plot(wpt_palm, z_palm.values, 'b-')
+ plt.xlabel('wpt (C m/s)')
+ plt.ylabel('z (m)')
+ plt.savefig(f'wpt_depth_t{int(time[tidx]*24.0)}h.png',
+ bbox_inches='tight', dpi=200)
+ plt.close()
+
+ plt.figure(figsize=figsize, dpi=100)
+ plt.plot(buoyancyProduction_z, zMid, 'k-')
+ if self.do_comparison:
+ plt.plot(buoyancyProduction_palm, z_palm.values, 'b-')
+ plt.xlabel('bouyancy production')
+ plt.ylabel('z (m)')
+ plt.savefig(f'buoy_depth_t{int(time[tidx]*24.0)}h.png',
+ bbox_inches='tight', dpi=200)
+ plt.close()
+
+ plt.figure(figsize=figsize, dpi=100)
+ plt.plot(salinity_z.values, zMid, 'k-')
+ if self.do_comparison:
+ plt.plot(sa_palm.values, z_palm.values, 'b-')
+ plt.xlabel('SA (g/kg)')
+ plt.ylabel('z (m)')
+ plt.savefig(f'sa_depth_t{int(time[tidx]*24.0)}h.png',
+ bbox_inches='tight', dpi=200)
+ plt.close()
+
+ plt.figure(figsize=figsize, dpi=100)
+ plt.plot(velocityZonal_z.values, zMid, 'k-')
+ plt.plot(velocityMeridional_z.values, zMid, 'k--')
+ if self.do_comparison:
+ plt.plot(u_palm.values, zu_palm.values, 'b-')
+ plt.plot(v_palm.values, zu_palm.values, 'b--')
+ plt.xlabel('u,v (m/s)')
+ plt.ylabel('z (m)')
+ plt.savefig(f'uv_depth_t{int(time[tidx]*24.0)}h.png',
+ bbox_inches='tight', dpi=200)
+ plt.close()
+
+ plt.figure(figsize=figsize, dpi=100)
+ cmax = np.max(np.abs(w_zTop.values))
+ plt.scatter(np.divide(xCell, 1e3),
+ np.divide(yCell, 1e3),
+ s=markersize, c=w_zTop.values,
+ cmap='cmo.balance', vmin=-1. * cmax, vmax=cmax)
+ plt.xlabel('x (km)')
+ plt.ylabel('y (km)')
+ cbar = plt.colorbar()
+ cbar.ax.set_title('w (m/s)')
+ plt.savefig(f'w_top_section_t{int(time[tidx]*24.0)}h.png',
+ bbox_inches='tight', dpi=200)
+ plt.close()
diff --git a/docs/users_guide/ocean/test_groups/index.rst b/docs/users_guide/ocean/test_groups/index.rst
index 318d0b92ef..b80488f9f7 100644
--- a/docs/users_guide/ocean/test_groups/index.rst
+++ b/docs/users_guide/ocean/test_groups/index.rst
@@ -29,4 +29,5 @@ coming months.
sphere_transport
spherical_harmonic_transform
tides
- ziso
\ No newline at end of file
+ turbulence_closure
+ ziso
diff --git a/docs/users_guide/ocean/test_groups/turbulence_closure.rst b/docs/users_guide/ocean/test_groups/turbulence_closure.rst
new file mode 100644
index 0000000000..a4979a5269
--- /dev/null
+++ b/docs/users_guide/ocean/test_groups/turbulence_closure.rst
@@ -0,0 +1,115 @@
+.. _ocean_turbulence_closure:
+
+turbulence_closure
+==================
+
+The ``turbulence_closure`` test group implements studies of the surface layer
+evolution under different kinds of surface forcing for different turbulence
+closures.
+
+The domain is periodic in the horizontal dimensions and no-slip at both
+vertical boundaries.
+
+Initial temperature is 20 degC and salinity is 35 PSU. A linear gradient can be
+given over the mixed layer or the interior. Furthermore, a polynomial function
+can be used to describe the interior temperature and salinity. The default
+parameters are based on a fit to a time average over the whole 1 year
+deployment of ITP #118, located in the Beaufort Gyre (western Arctic).
+
+Variants of the test case are available at 1-m, 2-m and 10-km horizontal
+resolution. The O(1)m resolution test cases are designed to be run in LES mode
+whereas the O(1)km test cases are designed to be used to test turbulence
+closures in "standard" MPAS-Ocean.
+
+The vertical resolution for the LES test cases is the same as the horizontal
+resolution (1m or 2m) whereas the standard resolution test cases have a
+vertical resolution of 50m.
+
+There are currently two surface forcing variants available, cooling and
+evaporation.
+
+All test cases have 2 steps,
+``initial_state``, which defines the mesh and initial conditions for the model,
+and ``forward``, which performs time integration of the model. The ``default``
+test also has a viz step which performs visualization.
+
+
+config options
+--------------
+
+namelist options
+----------------
+When the horizontal resolution is less than 500m, LES mode is active with the
+following config options:
+
+.. code-block:: cfg
+
+ config_LES_mode = .true.
+ config_use_two_equation_turbulence_model = .true.
+
+ # TODO Explain what 'omega' means
+ config_two_equation_model_choice = 'omega'
+ config_use_cvmix = .false.
+
+ config_two_equation_length_min = 0.1
+ config_two_equation_tke_min = 1e-10
+ config_two_equation_psi_min = 1e-10
+ config_tke_ini = 1.0e-30
+
+ config_LES_noise_enable = .true.
+ config_LES_noise_max_time = 150
+ config_LES_noise_min_level = 5
+ config_LES_noise_max_level = 45
+
+ # TODO Can you provide any guidance here about what appropriate choices
+ # might be?
+ config_LES_noise_perturbation_magnitude = 1e-4
+
+When the horizontal resolution is less than 100m, nonhydrostatic mode is
+active with the following config options:
+
+.. code-block:: cfg
+
+ # turns the nonhydro model on or off
+ config_enable_nonhydrostatic_mode = .true.
+
+ # preconditioner for the linear solver. Other options can be used, like
+ # jacobi, sor and asm, but they were found to be slower than block jacobi.
+ config_nonhydrostatic_preconditioner = 'bjacobi'
+
+ # linear solver. Other options can be used, like gmres
+ config_nonhydrostatic_solver_type = 'cg'
+
+ # do not change!
+ config_nonhydrostatic_solve_surface_boundary_condition = 'pressureTopGradientBottom'
+
+ # Nonhydro will work with either 'centered' or 'upwind'.
+ config_thickness_flux_type = 'upwind'
+
+default
+-------
+
+The default version of the turbulence closure test case is available for
+multiple resolutions: ``ocean/baroclinic_channel/1m/default``,
+``ocean/baroclinic_channel/2m/default``, and
+``ocean/baroclinic_channel/10km/default``. The default simulation time is 4 days.
+
+decomp_test
+-----------
+
+``ocean/turbulence_closure/10km/decomp_test`` runs a short (15 min) integration
+of the model forward in time on 4 (``4proc`` step) and then on 8 processors
+(``8proc`` step) to make sure the resulting prognostic variables are
+bit-for-bit identical between the two runs. Currently, only 10-km horizontal
+resolution is supported.
+
+restart_test
+------------
+
+``ocean/turbulence_closure/10km/restart_test`` runs a short (10 min)
+integration of the model forward in time (``full_run`` step), saving a restart
+file every 5 minutes. Then, a second run (``restart_run`` step) is performed
+from the restart file 5 minutes into the simulation and prognostic variables
+are compared between the "full" and "restart" runs at minute 10 to make sure
+they are bit-for-bit identical. Currently, only 10-km horizontal resolution is
+supported.