Developer Tutorial: Porting a legacy COMPASS test group¶
This tutorial presents a step-by-step guide to porting a legacy COMPASS test
group to the compass
python package (see the Glossary for
definitions of these terms). The tutorial uses the legacy COMPASS test group
ocean/gotm
as an example. We will build a new python package
compass.ocean.tests.gotm
for the test group in compass
. The example
test group creates a tiny (4 x 4 cell) doubly periodic mesh and uses it to
test MPAS-Ocean calls to the General Ocean Turbulence Model (GOTM).
Getting started¶
To begin with, you will need to check out two different branches of compass.
First, you will need the legacy
branch. Since we will simply be looking
at the code and copying it as needed, you can simply browse the branch directly
on GitHub: https://github.com/MPAS-Dev/compass/tree/legacy/ocean/gotm/2.5km/default.
If you prefer, you can use either of the approaches described in
Set up a compass repository: for beginners or Set up a compass repository with worktrees: for advanced users to clone the repo
and check out the legacy
branch.
Next, you will need to create a new branch from main
for developing the
new test group. For this purpose, we will stick with the simpler approach in
Set up a compass repository: for beginners here, but feel free to use the worktree
approach
instead if you are comfortable with it.
git clone git@github.com:MPAS-Dev/compass.git add_gotm
cd add_gotm
Now, you will need to create a conda environment for developing compass, as described in compass conda environment, compilers and system modules. We will assume a simple situation where you are working on a “supported” machine and using the default compilers and MPI libraries, but consult the documentation to make an environment to suit your needs.
# this one will take a while the first time
./conda/configure_compass_env.py --conda $HOME/miniforge
If all goes well, you will have a file named load_dev_compass_1.0.0*.sh
, where
the details of the *
depend on your specific machine and compilers. For
example, on Chrysalis, you will have load_dev_compass_1.0.0_chrysalis_intel_impi.sh
,
which will be the example used here:
source load_dev_compass_1.0.0_chrysalis_intel_impi.sh
Now, we’re ready to get the MPAS-Ocean source code from the E3SM repository and build the MPAS-Ocean executable:
# Get the E3SM code -- this one takes a while every time
git submodule update --init --recursive
cd E3SM-Project/components/mpas-ocean/
make intel-mpi
cd ../../..
The make target will be different depending on the machine and compilers, see Supported Machines or Other Machines for the right one for your machine.
Now, we’re ready to start developing!
The legacy COMPASS test group¶
…But before we get started, a little background on legacy COMPASS for those who haven’t used it extensively. In legacy COMPASS, the test group is just a directory with multiple test cases and, optionally, templates and other files in it. Test cases are made up of XML config and template files, and sometimes additional files like python scripts, python config files, namelist files, and geojson files.
All test cases have a driver, config_driver.xml
, that lists the steps in
the test case. The gotm
test group that we are using as an example has a
single test case, ocean/gotm/2.5km/default
. Its config_driver.xml
looks like this:
<driver_script name="run_test.py">
<case name="init">
<step executable="./run.py" quiet="true" pre_message=" * Running init" post_message=" Complete"/>
</case>
<case name="forward">
<step executable="./run.py" quiet="true" pre_message=" * Running forward" post_message=" Complete"/>
</case>
<case name="analysis">
<step executable="./run.py" quiet="true" pre_message=" * Running analysis" post_message=" Complete"/>
</case>
</driver_script>
The test case is made up of 3 steps, init
, forward
and analysis
.
Each has its own XML file. For example, config_init.xml
looks like this:
<?xml version="1.0"?>
<config case="init">
<add_executable source="model" dest="ocean_model"/>
<namelist name="namelist.ocean" mode="init">
<option name="config_init_configuration">'periodic_planar'</option>
<option name="config_vert_levels">-1</option>
<option name="config_periodic_planar_vert_levels">250</option>
<option name="config_periodic_planar_bottom_depth">15.0</option>
<option name="config_periodic_planar_velocity_strength">0.0</option>
<option name="config_ocean_run_mode">'init'</option>
<option name="config_write_cull_cell_mask">.false.</option>
<option name="config_vertical_grid">'uniform'</option>
</namelist>
<streams name="streams.ocean" keep="immutable" mode="init">
<stream name="input_init">
<attribute name="filename_template">mesh.nc</attribute>
</stream>
<stream name="output_init">
<attribute name="type">output</attribute>
<attribute name="output_interval">0000_00:00:01</attribute>
<attribute name="clobber_mode">truncate</attribute>
<attribute name="filename_template">ocean.nc</attribute>
<add_contents>
<member name="input_init" type="stream"/>
<member name="layerThickness" type="var"/>
<member name="restingThickness" type="var"/>
<member name="refBottomDepth" type="var"/>
<member name="bottomDepth" type="var"/>
<member name="maxLevelCell" type="var"/>
<member name="vertCoordMovementWeights" type="var"/>
<member name="edgeMask" type="var"/>
</add_contents>
</stream>
</streams>
<run_script name="run.py">
<step executable="planar_hex">
<argument flag="--nx">4</argument>
<argument flag="--ny">4</argument>
<argument flag="--dc">2500.0</argument>
<argument flag="-o">grid.nc</argument>
</step>
<step executable="MpasCellCuller.x">
<argument flag="">grid.nc</argument>
<argument flag="">culled_mesh.nc</argument>
</step>
<step executable="MpasMeshConverter.x">
<argument flag="">culled_mesh.nc</argument>
<argument flag="">mesh.nc</argument>
</step>
<model_run procs="1" threads="1" namelist="namelist.ocean" streams="streams.ocean"/>
</run_script>
</config>
The XML files for the other steps look similar. We will go through these files in detail later in the tutorial.
The example test case also has a namelist file used by GOTM (gotmturb.nml
)
and a python script for plotting the results compared to analytic solutions
(plot_profile.py
).
Making a new test group¶
Okay, with those details as a reference point from legacy COMPASS, let’s jump
into developing the new test group in compass
. Use any method you like
for editing code. If you haven’t settled on a method and are working on your
own laptop or desktop, you may want to try an integrated development
environment (PyCharm is a really nice
one). They have features to make sure your code adheres to the style required
for compass (see Code Style). vim
or a similar tool will work fine
on supercomputers.
In compass
, the gotm
test group will be a new python package. We will
make a new gotm
directory in compass/ocean/tests
. In that directory,
we will make a new, initially empty file __init__.py
. Now, gotm
is a
new package in compass
that could be imported as
from compass.ocean.tests import gotm
Next, let’s make a new class for the gotm
test group in __init__.py
:
from compass.testgroup import TestGroup
class Gotm(TestGroup):
"""
A test group for General Ocean Turbulence Model (GOTM) 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='gotm')
The method (a function for a class) called __init__()
is the constructor
used to make an instance (an object) representing the test group. It needs
to know what MPAS Core it belongs to so that is passed in as the mpas_core
argument. The only thing that happens so far is that the constructor for the
base class TestGroup
gets called. In the process, we give the test group
the name gotm
. You can take a look at the base class TestGroup
in
compass/testgroup.py
if you want. That’s not necessary for the tutorial,
but some new developers have found reading the base class code to be
highly instructive.
Naming conventions in python are that we use
CamelCase for classes, which
always start with a capital letter, and all lowercase, possibly with
underscores, for variable, module, package and function names. We avoid
all-caps like GOTM
or MPAS
, even though these might seem preferable.
(We use E3SM
in a few places because E3sm
was simply too much for us to
bear.)
Our new Gotm
class defines the test group, but so far it doesn’t have any
test cases in it. We’ll come back and add them later in the tutorial. Before
we add a test case, let’s make compass
aware that the test group exists.
To do that, we need to open compass/ocean/__init__.py
, add an import for
the new test group, and add an instance of the test group to the list of test
groups in the ocean core:
from compass.mpas_core import MpasCore
from compass.ocean.tests.baroclinic_channel import BaroclinicChannel
from compass.ocean.tests.global_convergence import GlobalConvergence
from compass.ocean.tests.global_ocean import GlobalOcean
from compass.ocean.tests.gotm import Gotm
from compass.ocean.tests.ice_shelf_2d import IceShelf2d
from compass.ocean.tests.ziso import Ziso
class Ocean(MpasCore):
"""
A test group for General Ocean Turbulence Model (GOTM) test cases
"""
def __init__(self):
"""
Construct the collection of MPAS-Ocean test cases
"""
super().__init__(name='ocean')
self.add_test_group(BaroclinicChannel(mpas_core=self))
self.add_test_group(GlobalConvergence(mpas_core=self))
self.add_test_group(GlobalOcean(mpas_core=self))
self.add_test_group(Gotm(mpas_core=self))
self.add_test_group(IceShelf2d(mpas_core=self))
self.add_test_group(Ziso(mpas_core=self))
We make an instance of the Gotm
class and we immediately add it to the
Ocean
core’s list of test groups. That’s all we need to do. Now
compass
knows about the test group.
Adding a test case¶
We’ll add a test case called default
to gotm
. Unlike in legacy
COMPASS, we don’t need to specify the resolution of the test case. We want
to encourage as much Code sharing as can reasonably be achieved,
and that typically means that the code for a single test case support multiple
resolutions.
We’ll make a default
package within compass/ocean/tests/gotm
, again
with an __init__.py
file in it. As we build out this file, it will play
the same role as config_driver.xml
played in legacy COMPASS, adding the
steps in the test case and running them.
As a starting point, we’ll create a new Default
class in this file that
descends from the TestCase
base class (take a look at
compass/testcase.py
if you want to see the contents of
compass.testcase.TestCase
if you’re interested).
from compass.testcase import TestCase
class Default(TestCase):
"""
The default test case for the General Ocean Turbulence Model (GOTM) test
group creates an initial condition on a 4 x 4 cell, doubly periodic grid,
performs a short simulation, then vertical plots of the velocity and
viscosity.
"""
def __init__(self, test_group):
"""
Create the test case
Parameters
----------
test_group : compass.ocean.tests.gotm.Gotm
The test group that this test case belongs to
"""
super().__init__(test_group=test_group, name='default')
As a starting point, we just pass along the test group (Gotm
) this test
case belongs to on to the base class’s constructor (super().__init__()
)
and give the test case a name, default
.
Varying resolution (or other parameters)¶
Since the Gotm
test group only has one test case at one resolution (and the
resolution isn’t an important property of the setup—-it’s using multiple
horizontal grid cells but it’s acting like a single column), we will just
hard-code the resolution into this particular test case. Other test cases,
like those in the baroclinic channel test group, do support multiple
resolutions. It is typically convenient to define multiple versions of the
test case by passing the resolutions as a parameter to the constructor.
This tutorial won’t describe how to do a parameter study. There will be a
separate tutorial for that purpose. Instead, what is described here is how to
make different variants of a test case with a list of parameter values. So
far, this is mostly used to create test cases at different resolutions in
compass
but the ocean/global_ocean
test group includes a number of
test cases that vary base on:
whether ice-shelf cavities are included in the ocean domain
which initial condition is used
whether biogeochemistry is included in the initial condition
which time integrator (RK4 or split-explicit) to use
The details here are not important. The point is that there is little restriction on what types of parameters can be used to create variants of test cases.
Here is an example of how resolution is used in the
barotropic_channel/default
test case. This is just an excerpt:
from compass.testcase import TestCase
class Default(TestCase):
"""
The default test case for the baroclinic channel test group simply creates
the mesh and initial condition, then performs a short forward run on 4
cores.
Attributes
----------
resolution : str
The resolution of the test case
"""
def __init__(self, test_group, resolution):
"""
Create the test case
Parameters
----------
test_group : compass.ocean.tests.baroclinic_channel.BaroclinicChannel
The test group that this test case belongs to
resolution : str
The resolution of the test case
"""
name = 'default'
self.resolution = resolution
subdir = '{}/{}'.format(resolution, name)
super().__init__(test_group=test_group, name=name,
subdir=subdir)
In this test case, we make a subdirectory that includes the resolution as well
as the name of the test case, and we store the resolution
in the test case
object itself. Later on, we can access it with self.resolution
whenever
we need it. For example, we can use it to determine other parameters of the
simulation. In the following example, we use nested python dictionaries to
give different parameters for different resolution. We use the resolution to
pick the right inner dictionary, and then set config options (see
Config Files). This example is a slight modification of
baroclinic_channel/default
:
def configure(self):
"""
Modify the configuration options for this test case.
"""
resolution = self.resolution
config = self.config
res_params = {'10km': {'nx': 16,
'ny': 50,
'dc': 10e3},
'4km': {'nx': 40,
'ny': 126,
'dc': 4e3},
'1km': {'nx': 160,
'ny': 500,
'dc': 1e3}}
if resolution not in res_params:
raise ValueError('Unsupported resolution {}. Supported values are: '
'{}'.format(resolution, list(res_params)))
res_params = res_params[resolution]
for param in res_params:
config.set('baroclinic_channel', param, '{}'.format(res_params[param]))
Adding the init step¶
In legacy COMPASS, the other config_*.xml
files besides config_driver.xml
define the step in the test case. In compass
, these are defined in classes
that descend from the Step
base class in modules. The modules can be
defined within the test case package (if they are unique to the test case)
or in the test group (if they are shared among several test cases). In this
example, there is only one test case, so we will just put the steps in that
test case’s package. You can browse other ocean
and landice
test cases
to see examples of steps shared across test cases. The baroclinic_channel
test group is a good place to start.
The gotm/default
test case has 3 steps: init
, forward
and
analysis
. We’ll start with init
, which creates the grid and calls
MPAS-Ocean in “init” mode to create the initial condition. To start with,
we’ll just create a new Init
class that descends from Step
:
from compass.step import Step
class Init(Step):
"""
A step for creating a mesh and initial condition for General Ocean
Turbulence Model (GOTM) test cases
"""
def __init__(self, test_case):
"""
Create the step
Parameters
----------
test_case : compass.ocean.tests.gotm.default.Default
The test case this step belongs to
"""
super().__init__(test_case=test_case, name='forward', ntasks=1,
min_tasks=1, openmp_threads=1)
This pattern is probably starting to look familiar. The step takes the test
case it belongs to as an input to its constructor, and passes that along to
the base class’ version of the constructor, along with the name of the step.
By default, the subdirectory for the step is the same as the step name, but
just like for a test case, you can give the step a more complicated
subdirectory name, possibly with multiple levels of directories. See the
steps in the ocean/global_convergence/cosine_bell
test case for examples
of this. The init
step runs on one core (so ntasks
and min_tasks
are both 1) and one thread.
The next step is to define the namelist, streams file, outputs from the step:
super().__init__(test_case=test_case, name='forward', ntasks=1,
min_tasks=1, openmp_threads=1)
self.add_namelist_file('compass.ocean.tests.gotm.default',
'namelist.init', mode='init')
self.add_streams_file('compass.ocean.tests.gotm.default',
'streams.init', mode='init')
self.add_model_as_input()
for file in ['mesh.nc', 'graph.info', 'ocean.nc']:
self.add_output_file(file)
We will discuss the contents of the namelist and streams files below. By
calling compass.Step.add_model_as_input()
, we add the MPAS-Ocean
executable as an input to the step (meaning that a symlink to the executable
will be made in the step’s work directory, and that the step will fail right
away if the model hasn’t been built yet).
Finally, we add outputs from the step. The outputs are any files produced by
this step that any other step should be allowed to use as inputs. In this
case, the forward
step needs all three of these files as inputs, which is
how we decided which of the outputs from the test case to include in this list.
mesh.nc
is the mesh, graph.info
is the graph file used by
Metis to partition
the mesh across processors, and ocean.nc
is the initial condition.
Defining namelist options¶
In compass
, there are two main ways to set namelist options for MPAS model
runs and we will demonstrate both in this test case. First, you can define a
namelist file with the desired values. This is useful for namelist options that
are always the same for this test case and can’t be changed based on config
options from the config file (see above).
The original config_init.xml
contained:
<namelist name="namelist.ocean" mode="init">
<option name="config_init_configuration">'periodic_planar'</option>
<option name="config_vert_levels">-1</option>
<option name="config_periodic_planar_vert_levels">250</option>
<option name="config_periodic_planar_bottom_depth">15.0</option>
<option name="config_periodic_planar_velocity_strength">0.0</option>
<option name="config_ocean_run_mode">'init'</option>
<option name="config_write_cull_cell_mask">.false.</option>
<option name="config_vertical_grid">'uniform'</option>
</namelist>
In compass
the formatting is much more similar to the resulting namelist
file. Here is the namelist.init
file from our example gotm/default
test case:
config_init_configuration = 'periodic_planar'
config_vert_levels = -1
config_periodic_planar_velocity_strength = 0.0
config_write_cull_cell_mask = .false.
config_vertical_grid = 'uniform'
We do not need to specify config_ocean_run_mode = 'init'
because this will
be taken care of because we specified mode='init'
when we added the
namelist to the step above.
Though it would be possible, users are not intended to change these to customize this step of the test case.
Another way to set namelist options is to use a python dictionary and to call
compass.Step.add_namelist_options()
. This is the way to handle
namelist options that depend on parameters (such as resolution) that are not
known in advance.
We will show later on that there is yet another way to handle namelist options
that can come from config options, using
compass.Step.update_namelist_at_runtime()
. This is why we haven’t
yet included the config_periodic_planar_vert_levels
and
config_periodic_planar_bottom_depth
options from the legacy test case.
Defining streams¶
Similarly, it is convenient to define input and output streams for MPAS-Ocean
using a streams file, very similar to what you will see when the test case
is set up. The syntax in compass
for defining streams is a lot simpler
than in legacy COMPASS (where a different XML convention was used to define
streams than the XML of the streams files themselves). In legacy COMPASS,
the streams for the init
step are defined in config_init.xml
as:
<streams name="streams.ocean" keep="immutable" mode="init">
<stream name="input_init">
<attribute name="filename_template">mesh.nc</attribute>
</stream>
<stream name="output_init">
<attribute name="type">output</attribute>
<attribute name="output_interval">0000_00:00:01</attribute>
<attribute name="clobber_mode">truncate</attribute>
<attribute name="filename_template">ocean.nc</attribute>
<add_contents>
<member name="input_init" type="stream"/>
<member name="layerThickness" type="var"/>
<member name="restingThickness" type="var"/>
<member name="refBottomDepth" type="var"/>
<member name="bottomDepth" type="var"/>
<member name="maxLevelCell" type="var"/>
<member name="vertCoordMovementWeights" type="var"/>
<member name="edgeMask" type="var"/>
</add_contents>
</stream>
</streams>
In compass
, we add a streams.init
file to the default
test case:
<streams>
<immutable_stream name="input_init"
filename_template="mesh.nc"/>
<stream name="output_init"
type="output"
output_interval="0000_00:00:01"
clobber_mode="truncate"
filename_template="ocean.nc">
<stream name="input_init"/>
<var name="layerThickness"/>
<var name="restingThickness"/>
<var name="refBottomDepth"/>
<var name="bottomDepth"/>
<var name="maxLevelCell"/>
<var name="vertCoordMovementWeights"/>
<var name="edgeMask"/>
</stream>
</streams>
As in legacy COMPASS, streams that are already defined like input_init
will use the default attributes defined by the MPAS component unless they are
explicitly replaced in the streams file. On setting up the test case, the
stream in the streams.ocean
file becomes:
<immutable_stream name="input_init"
type="input"
filename_template="mesh.nc"
input_interval="initial_only"/>
Defining config options¶
The remainder of the init
step will consist of defining the run()
method that does the real work of the step. To make it easier for users to
modify the test case a little bit to suit their needs, we may want to include
parameters in the config file for the test case. To do this, we can make a
config file with the test group’s package, the test case’s package, or both.
In our example, we will just add a config file defaults.cfg
to the
defaults
test case:
# config options for General Ocean Turbulence Model (GOTM) test cases
[gotm]
# the number of grid cells in x and y
nx = 4
ny = 4
# the size of grid cells (m)
dc = 2500.0
# the number of vertical levels
vert_levels = 250
# the depth of the sea floor (m)
bottom_depth = 15.0
By default, the domain is 4 x 4 horizontal cells, each 2.5 km in size. The
ocean is 15 m deep, divided over 250 uniformly spaced levels. A user could
change any of these parameters before running the init
step to modify
the initial condition (and therefore the rest of the test case).
Since the config file has the same name (default
) as the test case, it will
be included automatically when the config file is produced when the test case gets
set up.
Defining the run method¶
With these config options, namelists and streams files defined, we will
implement the run()
method of the init
step to do the rest of the work
for this step. In legacy COMPASS, the XML for defining the run scrip was:
<run_script name="run.py">
<step executable="planar_hex">
<argument flag="--nx">4</argument>
<argument flag="--ny">4</argument>
<argument flag="--dc">2500.0</argument>
<argument flag="-o">grid.nc</argument>
</step>
<step executable="MpasCellCuller.x">
<argument flag="">grid.nc</argument>
<argument flag="">culled_mesh.nc</argument>
</step>
<step executable="MpasMeshConverter.x">
<argument flag="">culled_mesh.nc</argument>
<argument flag="">mesh.nc</argument>
</step>
<model_run procs="1" threads="1" namelist="namelist.ocean" streams="streams.ocean"/>
</run_script>
In compass
, the equivalent run()
method is:
from mpas_tools.planar_hex import make_planar_hex_mesh
from mpas_tools.io import write_netcdf
from mpas_tools.mesh.conversion import convert, cull
from compass.model import run_model
...
def run(self):
"""
Run this step of the test case
"""
config = self.config
logger = self.logger
section = config['gotm']
nx = section.getint('nx')
ny = section.getint('ny')
dc = section.getfloat('dc')
dsMesh = make_planar_hex_mesh(nx=nx, ny=ny, dc=dc, nonperiodic_x=False,
nonperiodic_y=False)
write_netcdf(dsMesh, 'grid.nc')
dsMesh = cull(dsMesh, logger=logger)
dsMesh = convert(dsMesh, graphInfoFileName='graph.info',
logger=logger)
write_netcdf(dsMesh, 'mesh.nc')
replacements = dict()
replacements['config_periodic_planar_vert_levels'] = \
config.get('gotm', 'vert_levels')
replacements['config_periodic_planar_bottom_depth'] = \
config.get('gotm', 'bottom_depth')
self.update_namelist_at_runtime(options=replacements)
run_model(self)
First, we make a doubly periodic mesh. Rather than hard-coding the mesh size,
we get the relevant config options. Legacy COMPASS used the command-line
tool planar_hex
, but compass
will typically use the
mpas_tools.planar_hex.make_planar_hex_mesh()
function instead to
avoid the complexity of subprocess
calls and unnecessary file I/O. The
result is an xarray.Dataset
containing the mesh.
Second, we make sure any land cells are culled by calling the cell culler.
In legacy COMPASS, this is done with the command-line tool MpasCellCuller.x
but in compass
, the same can be achieved with
mpas_tools.mesh.conversion.cull()
(which is a wrapper around a
subprocess`
call to MpasCellCuller.x
). cull()
has
xarray.Dataset
objects as its input an return value, and also
takes a “logger” where it can write output (sometimes a log file and sometimes
directly to the terminal via stdout). You should always pass self.logger
so compass
can figure out whether a file or stdout is the right place for
output to go.
Note
In this particular test case, there are no land cells defined and the mesh is doubly periodic, so the call to the cell culler is probably not needed, but it has been retained because many test cases will need it.
Third, we call mpas_tools.mesh.conversion.convert()
to ensure that the
mesh conforms to the MPAS conventions. In the legacy COMPASS version, the
equivalent is achieved with a call to MpasMeshConverter.x
. Then, we write
out the mesh to a file mesh.nc
.
Fourth, we use compass.Step.update_namelist_at_runtime()`to update
the ``config_periodic_planar_vert_levels`()
and
config_periodic_planar_bottom_depth
namelist options based on the
vert_levels
and bottom_depth
config options. Since config options come
from a config file in the test case’s work directory (symlinked into each
step’s work directory), a user may have decided to change these config options
before running the test case so we update the namelist file right before
running the model.
Finally, we run MPAS-Ocean by calling compass.model.run_model()
.
We pass the step itself as an argument because this is how compass
knows
how many cores and threads to run on, which namelist and streams files to use,
which MPAS core this test case belongs to, and so on.
Adding the forward step¶
The Forward
step will be conceptually similar to the Init
step. Again,
we make a Forward
class that descends from Step
with a constructor that
calls the base constructor with the name of the step as well as the requested
number of cores, minimum number of cores, and number of threads:
from compass.step import Step
class Forward(Step):
"""
A step for performing forward MPAS-Ocean runs as part of General Ocean
Turbulence Model (GOTM) test cases.
"""
def __init__(self, test_case):
"""
Create a new test case
Parameters
----------
test_case : compass.ocean.tests.gotm.default.Default
The test case this step belongs to
"""
super().__init__(test_case=test_case, name='forward', ntasks=1,
min_tasks=1, openmp_threads=1)
The following XML from legacy COMPASS:
<add_link source="../init/ocean.nc" dest="init.nc"/>
<add_link source="../init/mesh.nc" dest="mesh.nc"/>
<add_link source="../init/graph.info" dest="graph.info"/>
is replaced by these method calls within the step’s constructor in compass
:
self.add_input_file(filename='mesh.nc', target='../init/mesh.nc')
self.add_input_file(filename='init.nc', target='../init/ocean.nc')
self.add_input_file(filename='graph.info', target='../init/graph.info')
As in Init
, we want to make a link to the MPAS-Ocean executable. The
legacy COMPASS version of this was:
<add_executable source="model" dest="ocean_model"/>
and the compass
version is:
self.add_model_as_input()
This step also needs to make a link to a namelist file that is specific to the
GOTM library called from within MPAS-Ocean (i.e. a different namelist than the
MPAS-Ocean namelist.ocean
file). In legacy COMPASS, a symlink from the
script test directory to the working directory was accomplished with:
<copy_file source_path="script_test_dir" source="gotmturb.nml" dest="gotmturb.nml"/>
In compass
, this becomes:
self.add_input_file(filename='gotmturb.nml', target='gotmturb.nml',
package='compass.ocean.tests.gotm.default')
The target is the file gotmturb.nml
that we will place in the default
package that we’re currently working on.
Finally, we’ll add an output file, appropriately enough called output.nc
.
self.add_output_file(filename='output.nc')
The complete constructor looks like:
def __init__(self, test_case):
"""
Create a new test case
Parameters
----------
test_case : compass.ocean.tests.gotm.default.Default
The test case this step belongs to
"""
super().__init__(test_case=test_case, name='forward', ntasks=1,
min_tasks=1, openmp_threads=1)
self.add_namelist_file('compass.ocean.tests.gotm.default',
'namelist.forward')
self.add_streams_file('compass.ocean.tests.gotm.default',
'streams.forward')
self.add_input_file(filename='mesh.nc', target='../init/mesh.nc')
self.add_input_file(filename='init.nc', target='../init/ocean.nc')
self.add_input_file(filename='graph.info', target='../init/graph.info')
self.add_input_file(filename='gotmturb.nml', target='gotmturb.nml',
package='compass.ocean.tests.gotm.default')
self.add_model_as_input()
self.add_output_file(filename='output.nc')
We will just copy the file gotmturb.nml
from the legacy test case as it
is.
The MPAS-Ocean namelist file namelist.forward
contains the same contents
as the legacy XML:
<namelist name="namelist.ocean" mode="forward">
<option name="config_ocean_run_mode">'forward'</option>
<option name="config_dt">'000:00:25'</option>
<option name="config_btr_dt">'000:00:25'</option>
<option name="config_time_integrator">'split_explicit'</option>
<option name="config_run_duration">'0000_12:00:00'</option>
<option name="config_zonal_ssh_grad">-1.0e-5</option>
<option name="config_pressure_gradient_type">'constant_forced'</option>
<option name="config_use_cvmix">.false.</option>
<option name="config_use_gotm">.true.</option>
<option name="config_gotm_namelist_file">'gotmturb.nml'</option>
<option name="config_gotm_constant_bottom_drag_coeff">1.73e-2</option>
<option name="config_use_implicit_bottom_drag">.true.</option>
<option name="config_implicit_bottom_drag_coeff">1.73e-2</option>
</namelist>
but in a simpler, more readable form in namelist.forward
in the
gotm/default
test case in compass
:
config_dt = '000:00:25'
config_btr_dt = '000:00:25'
config_time_integrator = 'split_explicit'
config_run_duration = '0000_12:00:00'
config_zonal_ssh_grad = -1.0e-5
config_pressure_gradient_type = 'constant_forced'
config_use_cvmix = .false.
config_use_gotm = .true.
config_gotm_namelist_file = 'gotmturb.nml'
config_gotm_constant_bottom_drag_coeff = 1.73e-2
config_use_implicit_bottom_drag = .true.
config_implicit_bottom_drag_coeff = 1.73e-2
We omit config_ocean_run_mode = 'forward'
because this is taken care of
by compass
when we add a namelist with the keyword argument
mode='forward'
(which is the default mode).
Similarly, the legacy definition of the streams:
<streams name="streams.ocean" keep="immutable" mode="forward">
<stream name="mesh">
<attribute name="filename_template">mesh.nc</attribute>
</stream>
<stream name="input">
<attribute name="filename_template">init.nc</attribute>
</stream>
<stream name="output">
<attribute name="type">output</attribute>
<attribute name="filename_template">output.nc</attribute>
<attribute name="output_interval">0000-00-00_00:10:00</attribute>
<attribute name="clobber_mode">truncate</attribute>
<add_contents>
<member name="velocityZonal" type="var"/>
<member name="velocityMeridional" type="var"/>
<member name="vertViscTopOfCell" type="var"/>
<member name="mesh" type="stream"/>
<member name="xtime" type="var"/>
<member name="normalVelocity" type="var"/>
<member name="layerThickness" type="var"/>
</add_contents>
</stream>
</streams>
takes this simpler form in streams.forward
in compass
that is nearly
identical to the full streams file in the work directory:
<streams>
<immutable_stream name="mesh"
filename_template="mesh.nc"/>
<immutable_stream name="input"
filename_template="init.nc"/>
<stream name="output"
type="output"
filename_template="output.nc"
output_interval="0000-00-00_00:10:00"
clobber_mode="truncate">
<stream name="mesh"/>
<var name="velocityZonal"/>
<var name="velocityMeridional"/>
<var name="vertViscTopOfCell"/>
<var name="xtime"/>
<var name="normalVelocity"/>
<var name="layerThickness"/>
</stream>
</streams>
The run script from the legacy test case:
<run_script name="run.py">
<model_run procs="1" threads="1" namelist="namelist.ocean" streams="streams.ocean"/>
</run_script>
becomes:
from compass.model import run_model
...
def run(self):
"""
Run this step of the test case
"""
run_model(self)
Adding the analysis step¶
The legacy analysis
step is defined like this:
<config case="analysis">
<add_link source="../forward/output.nc" dest="output.nc"/>
<add_link source_path="script_test_dir" source="plot_profile.py" dest="plot_profile.py"/>
<run_script name="run.py">
<step executable="./plot_profile.py">
</step>
</run_script>
</config>
Symlinks are created to a plotting script and the output from the forward
step, and then the plot script is run.
An identical approach could be used in compass
but it is not the preferred
approach. Instead, we prefer to use function calls in place of calling
python scripts via subprocesses whenever possible. One major reason for this
is that having python scripts within a python package is confusing – there is
not a clear way to know that they aren’t python modules within the package,
but instead are meant to be symlinked and run elsewhere. Another is that
these scripts typically don’t reuse code very well, nor is it easy to use
config options from the test case within them. For all these reasons, we will
demonstrate how to convert the plot_profile.py
script into a function
instead.
We start out with the same structure as in the other two steps:
from compass.step import Step
class Analysis(Step):
"""
A step for plotting the results of the default General Ocean Turbulence
Model (GOTM) test case
"""
def __init__(self, test_case):
"""
Create a new test case
Parameters
----------
test_case : compass.ocean.tests.gotm.default.Default
The test case this step belongs to
"""
super().__init__(test_case=test_case, name='analysis', ntasks=1,
min_tasks=1, openmp_threads=1)
As before, we will define the inputs and outputs. There will be no namelists or streams files, nor an MPAS executable because we will not be calling MPAS-Ocean in this step:
self.add_input_file(filename='output.nc', target='../forward/output.nc')
self.add_output_file(filename='velocity_profile.png')
self.add_output_file(filename='viscosity_profile.png')
Next, we will put the contents of the original plot_profile.py
into the
run()
method:
import numpy as np
import xarray as xr
import matplotlib.pyplot as plt
...
def run(self):
"""
Run this step of the test case
"""
# render statically by default
plt.switch_backend('agg')
# constants
kappa = 0.4
z0b = 1.5e-3
gssh = 1e-5
g = 9.81
h = 15
# load output
ds = xr.open_dataset('output.nc')
# velocity
u = ds.velocityZonal.isel(Time=-1, nCells=0).values
# viscosity
nu = ds.vertViscTopOfCell.isel(Time=-1, nCells=0).values
# depth
bottom_depth = ds.refBottomDepth.values
z = np.zeros_like(bottom_depth)
z[0] = -0.5*bottom_depth[0]
z[1:] = -0.5*(bottom_depth[0:-1]+bottom_depth[1:])
zi = np.zeros(bottom_depth.size+1)
zi[0] = 0.0
zi[1:] = -bottom_depth[0:]
# analytical solution
ustarb = np.sqrt(g*h*gssh)
u_a = ustarb/kappa*np.log((z0b+z+h)/z0b)
nu_a = -ustarb/h*kappa*(z0b+z+h)*z
# infered drag coefficient
cd = ustarb**2/u_a[-1]**2
self.logger.info('C_d = {:6.4g}'.format(cd))
# plot velocity
plt.figure()
plt.plot(u_a, z, 'k--', label='Analytical')
plt.plot(u, z, 'k-', label='GOTM')
plt.xlabel('Velocity (m/s)')
plt.ylabel('Depth (m)')
plt.legend()
plt.savefig('velocity_profile.png')
# plot viscosity
plt.figure()
plt.plot(nu_a, z, 'k--', label='Analytical')
plt.plot(nu, zi, 'k-', label='GOTM')
plt.xlabel('Viscosity (m$^2$/s)')
plt.ylabel('Depth (m)')
plt.legend()
plt.savefig('viscosity_profile.png')
This particular function doesn’t need it, but we could access config options
from self.config
if they would be useful in the analysis. print()
statements should generally be replaced with self.logger.info()
or other
logger calls (but print()
statements are still okay, and will be captured
in log files rather than going to the terminal when multiple steps or test
cases are running).
Updating the test case and test group¶
The nearly final steps are to add the steps to the test case, and then the test case to the test group:
from compass.testcase import TestCase
from compass.ocean.tests.gotm.default.init import Init
from compass.ocean.tests.gotm.default.forward import Forward
from compass.ocean.tests.gotm.default.analysis import Analysis
class Default(TestCase):
"""
The default test case for the General Ocean Turbulence Model (GOTM) test
group creates an initial condition on a 4 x 4 cell, doubly periodic grid,
performs a short simulation, then vertical plots of the velocity and
viscosity.
"""
def __init__(self, test_group):
"""
Create the test case
Parameters
----------
test_group : compass.ocean.tests.gotm.Gotm
The test group that this test case belongs to
"""
super().__init__(test_group=test_group, name='default')
self.add_step(Init(test_case=self))
self.add_step(Forward(test_case=self))
self.add_step(Analysis(test_case=self))
and then
from compass.testgroup import TestGroup
from compass.ocean.tests.gotm.default import Default
class Gotm(TestGroup):
"""
A test group for General Ocean Turbulence Model (GOTM) 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='gotm')
self.add_test_case(Default(test_group=self))
Adding validation¶
The legacy gotm/2.5km/default
test case didn’t include any
Validation but it is a very good idea to include some. This way,
the test case can be used as part of a regression suite to determine if
unexpected changes have been introduced into the code it tests. To perform
validation, we override the validate
method from the base TestCase
class in Default
as follows:
from compass.validate import compare_variables
...
def validate(self):
"""
Validate variables against a baseline
"""
compare_variables(test_case=self,
variables=['layerThickness', 'normalVelocity'],
filename1='forward/output.nc')
If the user ran the forward
step as part of this test case (sometimes they
might run only some of the steps), the call to
from compass.validate.compare_variables()
will check whether
variables layerThickness
and normalVelocity
are exactly the same in
this run as they were in a previous run if a baseline run was provided when the
test case got set up (see Test Suites).
Set up and run¶
You’re all set! You should be able to see your new test case when you run
compass list
, set it up by running compass setup
, and run it by running
compass run
within the work directory. See Command-line interface for
more on that process.
Documentation¶
Make sure to add some documentation of your new test group. You need to add
all of the functions, classes and methods to the API documentation in
docs/developers_guide/<core>/api.rst
, following the examples for other
test groups. You also need to add a file to both the user’s guide and the
developer’s guide describing the test group and its test cases and steps.
For the user’s guide, create a file
docs/users_guide/<core>/test_groups/<test_group>.rst
. In that file, you
should describe what the test group and what its test cases do in a way that would
be relevant for a user wanting to run the test case and look at the output.
This file should include a section giving the config options for the test case
and describing what they are used for, so that users know how to modify them
if they want to. Add <test_group>
in the appropriate place (in
alphabetical order) in the list of test groups in the file
docs/users_guide/<core>/test_groups/index.rst
.
For the developer’s guide, create a file
docs/developers_guide/<core>/test_groups/<test_group>.rst
. In this file,
you will describe the test group, its test cases and steps in a way that is
relevant to developers who might want to modify the code or use it as an
example for developing their own test cases. Currently, the descriptions are
brief in part because of the daunting task of documenting nearly 100 test cases
but should be fleshed out as time goes on. It would help new developers if
newly added test cases are documented well. Add <test_group>
in the
appropriate place (in alphabetical order) in the list of test groups in
docs/developers_guide/<core>/test_groups/index.rst
.
At this point, you are ready to make a pull request with the ported test group!