Repository: MPAS-Dev/MPAS-Analysis
Pull request #1064: Add support for custom climatology maps
Author: login: xylar
Labels: name: enhancement
Created: 2025-02-18T21:52:53Z | Merged: 2025-04-07T18:01:35Z

<!--
Thank you for your pull request.
Please add a description of what is accomplished in the PR here at the top:
-->
The task `ClimatologyMapCustom` can be used to make climatology maps of 2D and 3D fields on cells, the latter with vertical dimension `nVertLevels` or `nVertLevelsP1`.

To support this, `RemapDepthSlicesSubtask` has been updated to support `nVertLevelsP1` and a new utility function has been added to compute the z coordinate at layer interfaces.

<!--
Below are a few things we ask you or your reviewers to kindly check.
***Remove checks that are not relevant by deleting the line(s) below.***
-->
Checklist
* [x] User's Guide has been updated
* [x] If this PR adds a new analysis task, it has also been added to the user's guide
* [x] Developer's Guide has been updated
* [x] API documentation in the Developer's Guide (`api.rst`) has any new or modified class, method and/or functions listed
* [x] Documentation has been [built locally](https://mpas-dev.github.io/MPAS-Analysis/develop/users_guide/quick_start.html#generating-documentation) and changes look as expected
* [x] `Testing` comment in the PR documents testing used to verify the changes

<!--
Please note any issues this fixes using closing keywords: https://help.github.com/articles/closing-issues-using-keywords
-->




Diff:
diff --git a/docs/developers_guide/api.rst b/docs/developers_guide/api.rst
index c6cb11fd6..659916754 100644
--- a/docs/developers_guide/api.rst
+++ b/docs/developers_guide/api.rst
@@ -77,6 +77,7 @@ Ocean tasks
    ClimatologyMapArgoTemperature
    ClimatologyMapArgoSalinity
    ClimatologyMapWaves
+   ClimatologyMapCustom
    IndexNino34
    MeridionalHeatTransport
    OceanHistogram
diff --git a/docs/users_guide/analysis_tasks.rst b/docs/users_guide/analysis_tasks.rst
index d5f2c83c9..d2973b43d 100644
--- a/docs/users_guide/analysis_tasks.rst
+++ b/docs/users_guide/analysis_tasks.rst
@@ -9,6 +9,7 @@ Analysis Tasks
    tasks/climatologyMapArgoTemperature
    tasks/climatologyMapBGC
    tasks/climatologyMapBSF
+   tasks/climatologyMapCustom
    tasks/climatologyMapEKE
    tasks/climatologyMapHeatFluxes
    tasks/climatologyMapMassFluxes
diff --git a/docs/users_guide/tasks/climatologyMapCustom.rst b/docs/users_guide/tasks/climatologyMapCustom.rst
new file mode 100644
index 000000000..98f33cc23
--- /dev/null
+++ b/docs/users_guide/tasks/climatologyMapCustom.rst
@@ -0,0 +1,260 @@
+.. _task_climatologyMapCustom:
+
+climatologyMapCustom
+====================
+
+An analysis task for plotting custom climatologies at various depths. This task
+can plot both 2D and 3D variables on cells, the latter with both
+``nVertlevels`` -- layer centers -- or ``nVertLevelsP1`` -- layer interfaces
+-- as the vertical dimension.  The task is designed to be highly coustomizable
+via config sections and options, as described below.
+
+Component and Tags::
+
+  component: ocean
+  tags: climatology, horizontalMap
+
+Configuration Options
+---------------------
+
+The following configuration options are available for this task::
+
+    [climatologyMapCustom]
+    ## options related to plotting climatology maps of any field at various depths
+    ## (if they include a depth dimension) without observatons for comparison
+
+    # comparison grid(s)
+    comparisonGrids = ['latlon']
+
+    # Months or seasons to plot (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct,
+    # Nov, Dec, JFM, AMJ, JAS, OND, ANN)
+    seasons =  ['ANN']
+
+    # list of depths in meters (positive up) at which to analyze, 'top' for the
+    # sea surface, 'bot' for the sea floor
+    depths = ['top', -200, -400, -600, -800, -1000, -1500, -2000, 'bot']
+
+    # a list of variables available to plot. New variables can be added as long
+    # as they correspond to a single field already found in MPAS-Ocean's
+    # timeSeriesStatsMonthly output. Add the 'name', 'title', 'units' (with $$
+    # instead a single dollar sign for the config parser), and 'mpas'(the
+    # timeSeriesStatsMonthly variable name as a single-item list) entries for each
+    # variable. Then, add a section below climatologyMapCustom<VariableName> with
+    # the colormap settings for that variable.
+    availableVariables = {
+        'temperature':
+            {'title': 'Potential Temperature',
+            'units': r'$$^\circ$$C',
+            'mpas': ['timeMonthly_avg_activeTracers_temperature']},
+        'salinity':
+            {'title': 'Salinity',
+            'units': 'PSU',
+            'mpas': ['timeMonthly_avg_activeTracers_salinity']},
+        'potentialDensity':
+            {'title': 'Potential Density',
+            'units': 'kg m$$^{-3}$$',
+            'mpas': ['timeMonthly_avg_potentialDensity']},
+        'thermalForcing':
+            {'title': 'Thermal Forcing',
+            'units': r'$$^\circ$$C',
+            'mpas': ['timeMonthly_avg_activeTracers_temperature',
+                    'timeMonthly_avg_activeTracers_salinity',
+                    'timeMonthly_avg_density',
+                    'timeMonthly_avg_activeTracers_layerThickness']},
+        'zonalVelocity':
+            {'title': 'Zonal Velocity',
+            'units': r'm s$$^{-1}$$',
+            'mpas': ['timeMonthly_avg_velocityZonal']},
+        'meridionalVelocity':
+            {'title': 'Meridional Velocity',
+            'units': r'm s$$^{-1}$$',
+            'mpas': ['timeMonthly_avg_velocityMeridional']},
+        'velocityMagnitude':
+            {'title': 'Zonal Velocity',
+            'units': r'm s$$^{-1}$$',
+            'mpas': ['timeMonthly_avg_velocityZonal',
+                    'timeMonthly_avg_velocityMeridional']},
+        'vertVelocity':
+            {'title': 'Vertical Velocity',
+            'units': r'm s$$^{-1}$$',
+            'mpas': ['timeMonthly_avg_vertVelocityTop']},
+        'vertDiff':
+            {'title': 'Vertical Diffusivity',
+            'units': r'm s$$^{-1}$$',
+            'mpas': ['timeMonthly_avg_vertDiffTopOfCell']},
+        'vertVisc':
+            {'title': 'Vertical Viscosity',
+            'units': r'm s$$^{-1}$$',
+            'mpas': ['timeMonthly_avg_vertViscTopOfCell']},
+        'mixedLayerDepth':
+            {'title': 'Mixed Layer Depth',
+            'units': 'm',
+            'mpas': ['timeMonthly_avg_dThreshMLD']},
+        }
+
+    # a list of fields top plot for each transect.  All supported fields are listed
+    # below
+    variables = []
+
+
+    [climatologyMapCustomTemperature]
+    ## options related to plotting climatology maps of potential temperature at
+    ## various levels, including the sea surface and sea floor, possibly against
+    ### control model results
+
+    # colormap for model/observations
+    colormapNameResult = RdYlBu_r
+    # whether the colormap is indexed or continuous
+    colormapTypeResult = continuous
+    # the type of norm used in the colormap
+    normTypeResult = linear
+    # A dictionary with keywords for the norm
+    normArgsResult = {'vmin': -2., 'vmax': 10.}
+    # place the ticks automatically by default
+    # colorbarTicksResult = numpy.linspace(-2., 10., 9)
+
+    # colormap for differences
+    colormapNameDifference = balance
+    # whether the colormap is indexed or continuous
+    colormapTypeDifference = continuous
+    # the type of norm used in the colormap
+    normTypeDifference = linear
+    # A dictionary with keywords for the norm
+    normArgsDifference = {'vmin': -5., 'vmax': 5.}
+    # place the ticks automatically by default
+    # colorbarTicksDifference = numpy.linspace(-5., 5., 9)
+
+    [climatologyMapCustomSalinity]
+    colormapNameResult = haline
+    colormapTypeResult = continuous
+    normTypeResult = linear
+    normArgsResult = {'vmin': 32.2, 'vmax': 35.5}
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = linear
+    normArgsDifference = {'vmin': -1.5, 'vmax': 1.5}
+
+    [climatologyMapCustomPotentialDensity]
+    colormapNameResult = Spectral_r
+    colormapTypeResult = continuous
+    normTypeResult = linear
+    normArgsResult = {'vmin': 1026.5, 'vmax': 1028.}
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = linear
+    normArgsDifference = {'vmin': -0.3, 'vmax': 0.3}
+
+    [climatologyMapCustomThermalForcing]
+    colormapNameResult = thermal
+    colormapTypeResult = continuous
+    normTypeResult = linear
+    normArgsResult = {'vmin': -1., 'vmax': 5.}
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = linear
+    normArgsDifference = {'vmin': -3., 'vmax': 3.}
+
+    [climatologyMapCustomZonalVelocity]
+    colormapNameResult = delta
+    colormapTypeResult = continuous
+    normTypeResult = linear
+    normArgsResult = {'vmin': -0.2, 'vmax': 0.2}
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = linear
+    normArgsDifference = {'vmin': -0.2, 'vmax': 0.2}
+
+    [climatologyMapCustomMeridionalVelocity]
+    colormapNameResult = delta
+    colormapTypeResult = continuous
+    normTypeResult = linear
+    normArgsResult = {'vmin': -0.2, 'vmax': 0.2}
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = linear
+    normArgsDifference = {'vmin': -0.2, 'vmax': 0.2}
+
+    [climatologyMapCustomVelocityMagnitude]
+    colormapNameResult = ice
+    colormapTypeResult = continuous
+    normTypeResult = log
+    normArgsResult = {'vmin': 1.e-3, 'vmax': 1.}
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = linear
+    normArgsDifference = {'vmin': -0.2, 'vmax': 0.2}
+
+    [climatologyMapCustomVertVelocity]
+    colormapNameResult = delta
+    colormapTypeResult = continuous
+    normTypeResult = linear
+    normArgsResult = {'vmin': -1e-5, 'vmax': 1e-5}
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = linear
+    normArgsDifference = {'vmin': -1e-5, 'vmax': 1e-5}
+
+    [climatologyMapCustomVertDiff]
+    colormapNameResult = rain
+    colormapTypeResult = continuous
+    normTypeResult = log
+    normArgsResult = {'vmin': 1e-6, 'vmax': 1.}
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = linear
+    normArgsDifference = {'vmin': -0.5, 'vmax': 0.5}
+
+    [climatologyMapCustomVertVisc]
+    colormapNameResult = rain
+    colormapTypeResult = continuous
+    normTypeResult = log
+    normArgsResult = {'vmin': 1e-6, 'vmax': 1.}
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = linear
+    normArgsDifference = {'vmin': -0.5, 'vmax': 0.5}
+
+    [climatologyMapCustomMixedLayerDepth]
+    colormapNameResult = viridis
+    colormapTypeResult = continuous
+    normTypeResult = log
+    normArgsResult = {'vmin': 10., 'vmax': 300.}
+    colorbarTicksResult = [10, 20, 40, 60, 80, 100, 200, 300]
+    colormapNameDifference = balance
+    colormapTypeDifference = continuous
+    normTypeDifference = symLog
+    normArgsDifference = {'linthresh': 10., 'linscale': 0.5, 'vmin': -200.,
+                        'vmax': 200.}
+    colorbarTicksDifference = [-200., -100., -50., -20., -10., 0., 10., 20., 50., 100., 200.]
+
+
+There is a section for options that apply to all custom climatology maps and
+one each for any available variables to plot.
+
+The option ``availableVariables`` is a dictionary with the names of the
+variables available to plot as keys and dictionaries with the title, units,
+and MPAS variable name(s) as values.  New entries can be added as long as they
+correspond to a single field already found in MPAS-Ocean's
+``timeSeriesStatsMonthly`` output.  For each variable, a section with the name
+``climatologyMapCustom<VariableName>`` should be added with the colormap
+settings for that variable, see :ref:`config_colormaps` for details.
+
+The option ``depths`` is a list of (approximate) depths at which to sample
+the potential temperature field.  A value of ``'top'`` indicates the sea
+surface (or the ice-ocean interface under ice shelves) while a value of
+``'bot'`` indicates the seafloor.
+
+By default, no fields are plotted.  A user can select which fields to plot by
+adding the desired field names to the ``variables`` list.
+
+For more details, see:
+ * :ref:`config_colormaps`
+ * :ref:`config_seasons`
+ * :ref:`config_comparison_grids`
+
+Example Result
+--------------
+
+.. image:: examples/clim_map_custom.png
+   :width: 500 px
+   :align: center
diff --git a/docs/users_guide/tasks/examples/clim_map_custom.png b/docs/users_guide/tasks/examples/clim_map_custom.png
new file mode 100644
index 000000000..2eb60063d
Binary files /dev/null and b/docs/users_guide/tasks/examples/clim_map_custom.png differ
diff --git a/mpas_analysis/__main__.py b/mpas_analysis/__main__.py
index 4bbb7c88b..ea00d46e0 100644
--- a/mpas_analysis/__main__.py
+++ b/mpas_analysis/__main__.py
@@ -186,6 +186,9 @@ def build_analysis_list(config, controlConfig):
         config, oceanClimatologyTasks['avg'], oceanRegionMasksTask,
         controlConfig))
 
+    analyses.append(ocean.ClimatologyMapCustom(
+        config, oceanClimatologyTasks['avg'], controlConfig))
+
     analyses.append(ocean.ConservationTask(
         config, controlConfig))
 
diff --git a/mpas_analysis/default.cfg b/mpas_analysis/default.cfg
index 3fcd11d33..a949e8d47 100755
--- a/mpas_analysis/default.cfg
+++ b/mpas_analysis/default.cfg
@@ -2151,6 +2151,215 @@ normTypeDifference = linear
 normArgsDifference = {'vmin': -0.2, 'vmax': 0.2}
 
 
+[climatologyMapCustom]
+## options related to plotting climatology maps of any field at various depths
+## (if they include a depth dimension) without observatons for comparison
+
+# comparison grid(s)
+comparisonGrids = ['latlon']
+
+# Months or seasons to plot (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct,
+# Nov, Dec, JFM, AMJ, JAS, OND, ANN)
+seasons =  ['ANN']
+
+# list of depths in meters (positive up) at which to analyze, 'top' for the
+# sea surface, 'bot' for the sea floor
+depths = ['top', -200, -400, -600, -800, -1000, -1500, -2000, 'bot']
+
+# a list of variables available to plot. New variables can be added as long
+# as they correspond to a single field already found in MPAS-Ocean's
+# timeSeriesStatsMonthly output. Add the 'name', 'title', 'units' (with $$
+# instead a single dollar sign for the config parser), and 'mpas'(the
+# timeSeriesStatsMonthly variable name as a single-item list) entries for each
+# variable. Then, add a section below climatologyMapCustom<VariableName> with
+# the colormap settings for that variable.
+availableVariables = {
+    'temperature':
+        {'title': 'Potential Temperature',
+         'units': r'$$^\circ$$C',
+         'mpas': ['timeMonthly_avg_activeTracers_temperature']},
+    'salinity':
+        {'title': 'Salinity',
+         'units': 'PSU',
+         'mpas': ['timeMonthly_avg_activeTracers_salinity']},
+    'potentialDensity':
+        {'title': 'Potential Density',
+         'units': 'kg m$$^{-3}$$',
+         'mpas': ['timeMonthly_avg_potentialDensity']},
+    'thermalForcing':
+        {'title': 'Thermal Forcing',
+         'units': r'$$^\circ$$C',
+         'mpas': ['timeMonthly_avg_activeTracers_temperature',
+                  'timeMonthly_avg_activeTracers_salinity',
+                  'timeMonthly_avg_density',
+                  'timeMonthly_avg_activeTracers_layerThickness']},
+    'zonalVelocity':
+        {'title': 'Zonal Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_velocityZonal']},
+    'meridionalVelocity':
+        {'title': 'Meridional Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_velocityMeridional']},
+    'velocityMagnitude':
+        {'title': 'Zonal Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_velocityZonal',
+                  'timeMonthly_avg_velocityMeridional']},
+     'vertVelocity':
+        {'title': 'Vertical Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_vertVelocityTop']},
+     'vertDiff':
+        {'title': 'Vertical Diffusivity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_vertDiffTopOfCell']},
+     'vertVisc':
+        {'title': 'Vertical Viscosity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_vertViscTopOfCell']},
+    'mixedLayerDepth':
+        {'title': 'Mixed Layer Depth',
+         'units': 'm',
+         'mpas': ['timeMonthly_avg_dThreshMLD'],
+         'has_depth': False},
+    }
+
+# a list of fields top plot for each transect.  All supported fields are listed
+# below
+variables = []
+
+
+[climatologyMapCustomTemperature]
+## options related to plotting climatology maps of potential temperature at
+## various levels, including the sea surface and sea floor, possibly against
+### control model results
+
+# colormap for model/observations
+colormapNameResult = RdYlBu_r
+# whether the colormap is indexed or continuous
+colormapTypeResult = continuous
+# the type of norm used in the colormap
+normTypeResult = linear
+# A dictionary with keywords for the norm
+normArgsResult = {'vmin': -2., 'vmax': 10.}
+# place the ticks automatically by default
+# colorbarTicksResult = numpy.linspace(-2., 10., 9)
+
+# colormap for differences
+colormapNameDifference = balance
+# whether the colormap is indexed or continuous
+colormapTypeDifference = continuous
+# the type of norm used in the colormap
+normTypeDifference = linear
+# A dictionary with keywords for the norm
+normArgsDifference = {'vmin': -5., 'vmax': 5.}
+# place the ticks automatically by default
+# colorbarTicksDifference = numpy.linspace(-5., 5., 9)
+
+[climatologyMapCustomSalinity]
+colormapNameResult = haline
+colormapTypeResult = continuous
+normTypeResult = linear
+normArgsResult = {'vmin': 32.2, 'vmax': 35.5}
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = linear
+normArgsDifference = {'vmin': -1.5, 'vmax': 1.5}
+
+[climatologyMapCustomPotentialDensity]
+colormapNameResult = Spectral_r
+colormapTypeResult = continuous
+normTypeResult = linear
+normArgsResult = {'vmin': 1026.5, 'vmax': 1028.}
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = linear
+normArgsDifference = {'vmin': -0.3, 'vmax': 0.3}
+
+[climatologyMapCustomThermalForcing]
+colormapNameResult = thermal
+colormapTypeResult = continuous
+normTypeResult = linear
+normArgsResult = {'vmin': -1., 'vmax': 5.}
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = linear
+normArgsDifference = {'vmin': -3., 'vmax': 3.}
+
+[climatologyMapCustomZonalVelocity]
+colormapNameResult = delta
+colormapTypeResult = continuous
+normTypeResult = linear
+normArgsResult = {'vmin': -0.2, 'vmax': 0.2}
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = linear
+normArgsDifference = {'vmin': -0.2, 'vmax': 0.2}
+
+[climatologyMapCustomMeridionalVelocity]
+colormapNameResult = delta
+colormapTypeResult = continuous
+normTypeResult = linear
+normArgsResult = {'vmin': -0.2, 'vmax': 0.2}
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = linear
+normArgsDifference = {'vmin': -0.2, 'vmax': 0.2}
+
+[climatologyMapCustomVelocityMagnitude]
+colormapNameResult = ice
+colormapTypeResult = continuous
+normTypeResult = log
+normArgsResult = {'vmin': 1.e-3, 'vmax': 1.}
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = linear
+normArgsDifference = {'vmin': -0.2, 'vmax': 0.2}
+
+[climatologyMapCustomVertVelocity]
+colormapNameResult = delta
+colormapTypeResult = continuous
+normTypeResult = linear
+normArgsResult = {'vmin': -1e-5, 'vmax': 1e-5}
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = linear
+normArgsDifference = {'vmin': -1e-5, 'vmax': 1e-5}
+
+[climatologyMapCustomVertDiff]
+colormapNameResult = rain
+colormapTypeResult = continuous
+normTypeResult = log
+normArgsResult = {'vmin': 1e-6, 'vmax': 1.}
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = linear
+normArgsDifference = {'vmin': -0.5, 'vmax': 0.5}
+
+[climatologyMapCustomVertVisc]
+colormapNameResult = rain
+colormapTypeResult = continuous
+normTypeResult = log
+normArgsResult = {'vmin': 1e-6, 'vmax': 1.}
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = linear
+normArgsDifference = {'vmin': -0.5, 'vmax': 0.5}
+
+[climatologyMapCustomMixedLayerDepth]
+colormapNameResult = viridis
+colormapTypeResult = continuous
+normTypeResult = log
+normArgsResult = {'vmin': 10., 'vmax': 300.}
+colorbarTicksResult = [10, 20, 40, 60, 80, 100, 200, 300]
+colormapNameDifference = balance
+colormapTypeDifference = continuous
+normTypeDifference = symLog
+normArgsDifference = {'linthresh': 10., 'linscale': 0.5, 'vmin': -200.,
+                      'vmax': 200.}
+colorbarTicksDifference = [-200., -100., -50., -20., -10., 0., 10., 20., 50., 100., 200.]
+
 [climatologyMapSose]
 ## options related to plotting climatology maps of Antarctic fields at various
 ## levels, including the sea floor against control model results and SOSE
diff --git a/mpas_analysis/ocean/__init__.py b/mpas_analysis/ocean/__init__.py
index 107d0785d..c99feb4b4 100644
--- a/mpas_analysis/ocean/__init__.py
+++ b/mpas_analysis/ocean/__init__.py
@@ -25,6 +25,10 @@
 from mpas_analysis.ocean.climatology_map_argo import \
     ClimatologyMapArgoTemperature, ClimatologyMapArgoSalinity
 
+from mpas_analysis.ocean.climatology_map_custom import (
+   ClimatologyMapCustom
+)
+
 from mpas_analysis.ocean.conservation import ConservationTask
 
 from mpas_analysis.ocean.time_series_temperature_anomaly import \
diff --git a/mpas_analysis/ocean/climatology_map_custom.py b/mpas_analysis/ocean/climatology_map_custom.py
new file mode 100644
index 000000000..d714ccbb6
--- /dev/null
+++ b/mpas_analysis/ocean/climatology_map_custom.py
@@ -0,0 +1,341 @@
+# This software is open source software available under the BSD-3 license.
+#
+# Copyright (c) 2022 Triad National Security, LLC. All rights reserved.
+# Copyright (c) 2022 Lawrence Livermore National Security, LLC. All rights
+# reserved.
+# Copyright (c) 2022 UT-Battelle, LLC. All rights reserved.
+#
+# Additional copyright and license information can be found in the LICENSE file
+# distributed with this code, or at
+# https://raw.githubusercontent.com/MPAS-Dev/MPAS-Analysis/main/LICENSE
+
+import numpy as np
+from mpas_tools.cime.constants import constants as cime_constants
+
+from mpas_analysis.ocean.remap_depth_slices_subtask import (
+    RemapDepthSlicesSubtask
+)
+from mpas_analysis.shared import AnalysisTask
+from mpas_analysis.shared.climatology import RemapMpasClimatologySubtask
+from mpas_analysis.shared.plot import PlotClimatologyMapSubtask
+
+
+class ClimatologyMapCustom(AnalysisTask):
+    """
+    A felxible analysis task for plotting climatologies of any MPAS-Ocean field
+    on cells from timeSeriesStatsMonthly at various depths (if the field has
+    vertical levels) and for various seasons. Various derived fields are also
+    supported:
+      * velocity magnitude
+      * thermal forcing (temperature - freezing temperature)
+    """
+
+    def __init__(self, config, mpasClimatologyTask, controlConfig=None):
+        """
+        Construct the analysis task.
+
+        Parameters
+        ----------
+        config : mpas_tools.config.MpasConfigParser
+            Configuration options
+
+        mpasClimatologyTask : ``MpasClimatologyTask``
+            The task that produced the climatology to be remapped and plotted
+
+        controlconfig : mpas_tools.config.MpasConfigParser, optional
+            Configuration options for a control run (if any)
+        """
+
+        taskName = 'climatologyMapCustom'
+
+        sectionName = taskName
+        variablesNames = config.getexpression(sectionName, 'variables')
+
+        tags = ['climatology', 'horizontalMap'] + variablesNames
+
+        # call the constructor from the base class (AnalysisTask)
+        super().__init__(
+            config=config, taskName=taskName, componentName='ocean', tags=tags)
+
+        if len(variablesNames) == 0:
+            return
+
+        variablesNames = config.getexpression(sectionName, 'variables')
+        if len(variablesNames) == 0:
+            return
+
+        availableVariables = config.getexpression(sectionName,
+                                                  'availableVariables')
+
+        variables = {varName: availableVariables[varName] for varName in
+                     variablesNames}
+
+        for varName in variablesNames:
+            if 'has_depth' not in variables[varName]:
+                # we assume variables have depth unless otherwise specified
+                variables[varName]['has_depth'] = True
+
+        variables3D = {varName: variables[varName] for varName in
+                       variables if variables[varName]['has_depth']}
+
+        variableList2D = [variables[varName]['mpas'][0] for varName in
+                          variables if not variables[varName]['has_depth']]
+
+        # read in what seasons we want to plot
+        seasons = config.getexpression(sectionName, 'seasons')
+
+        if len(seasons) == 0:
+            raise ValueError(f'config section {sectionName} does not contain '
+                             'valid list of seasons')
+
+        comparisonGridNames = config.getexpression(sectionName,
+                                                   'comparisonGrids')
+
+        if len(comparisonGridNames) == 0:
+            raise ValueError(f'config section {sectionName} does not contain '
+                             'valid list of comparison grids')
+
+        depths = config.getexpression(sectionName, 'depths')
+
+        if len(depths) == 0:
+            raise ValueError(f'config section {sectionName} does not '
+                             f'contain valid list of depths')
+
+        if variables3D:
+            remapMpasSubtask3D = RemapMpasDerivedVariableClimatology(
+                mpasClimatologyTask=mpasClimatologyTask,
+                parentTask=self,
+                climatologyName='custom3D',
+                variables=variables3D,
+                seasons=seasons,
+                depths=depths,
+                comparisonGridNames=comparisonGridNames)
+        else:
+            remapMpasSubtask3D = None
+
+        if len(variableList2D) > 0:
+            remapMpasSubtask2D = RemapMpasClimatologySubtask(
+                mpasClimatologyTask=mpasClimatologyTask,
+                parentTask=self,
+                climatologyName='custom2D',
+                variableList=variableList2D,
+                seasons=seasons,
+                comparisonGridNames=comparisonGridNames,
+                subtaskName='remap2DVariables')
+        else:
+            remapMpasSubtask2D = None
+
+        galleryGroup = 'Custom Climatology Maps'
+        groupLink = 'custclimmaps'
+
+        for varName, metadata in variables.items():
+            title = metadata['title']
+            units = metadata['units']
+            hasDepth = metadata['has_depth']
+            upperVarName = varName[0].upper() + varName[1:]
+            varSectionName = f'{self.taskName}{upperVarName}'
+
+            remapObsSubtask = None
+
+            refTitleLabel = None
+            diffTitleLabel = None
+            if controlConfig is not None:
+                controlRunName = controlConfig.get('runs', 'mainRunName')
+                refTitleLabel = f'Control: {controlRunName}'
+                diffTitleLabel = 'Main - Control'
+
+            if hasDepth:
+                localDepths = depths
+                remapMpasSubtask = remapMpasSubtask3D
+                mpasVarName = varName
+            else:
+                localDepths = [None]
+                remapMpasSubtask = remapMpasSubtask2D
+                mpasVarName = metadata['mpas'][0]
+
+            for comparisonGridName in comparisonGridNames:
+                for depth in localDepths:
+                    for season in seasons:
+
+                        subtaskName = f'plot{upperVarName}_{season}_' \
+                                      f'{comparisonGridName}'
+                        if depth is not None:
+                            subtaskName = f'{subtaskName}_depth_{depth}'
+
+                        subtask = PlotClimatologyMapSubtask(
+                            parentTask=self,
+                            season=season,
+                            comparisonGridName=comparisonGridName,
+                            remapMpasClimatologySubtask=remapMpasSubtask,
+                            remapObsClimatologySubtask=remapObsSubtask,
+                            controlConfig=controlConfig,
+                            depth=depth,
+                            subtaskName=subtaskName)
+
+                        subtask.set_plot_info(
+                            outFileLabel=varName,
+                            fieldNameInTitle=title,
+                            mpasFieldName=mpasVarName,
+                            refFieldName=mpasVarName,
+                            refTitleLabel=refTitleLabel,
+                            diffTitleLabel=diffTitleLabel,
+                            unitsLabel=units,
+                            imageCaption=title,
+                            galleryGroup=galleryGroup,
+                            groupSubtitle=None,
+                            groupLink=groupLink,
+                            galleryName=title,
+                            configSectionName=varSectionName)
+
+                        self.add_subtask(subtask)
+
+
+class RemapMpasDerivedVariableClimatology(RemapDepthSlicesSubtask):
+    """
+    A subtask for computing derived variables (such as velocity magnitude and
+    thermal forcing) as part of remapping climatologies at depth slices
+
+    Attributes
+    ----------
+    variables : dict of dict
+        A dictionary of variable definitions, with variable names as keys
+
+    """
+
+    def __init__(self, mpasClimatologyTask, parentTask, climatologyName,
+                 variables, seasons, depths, comparisonGridNames):
+
+        """
+        Construct the analysis task and adds it as a subtask of the
+        ``parentTask``.
+
+        Parameters
+        ----------
+        mpasClimatologyTask : MpasClimatologyTask
+            The task that produced the climatology to be remapped
+
+        parentTask : AnalysisTask
+            The parent task, used to get the ``taskName``, ``config`` and
+            ``componentName``
+
+        climatologyName : str
+            A name that describes the climatology (e.g. a short version of
+            the important field(s) in the climatology) used to name the
+            subdirectories for each stage of the climatology
+
+        variables : dict of dict
+            A dictionary of variable definitions, with variable names as keys
+
+        seasons : list of str, optional
+            A list of seasons (keys in ``shared.constants.monthDictionary``)
+            to be computed or ['none'] (not ``None``) if only monthly
+            climatologies are needed.
+
+        depths : list of {None, float, 'top', 'bot'}
+            A list of depths at which the climatology will be sliced in the
+            vertical.
+
+        comparisonGridNames : list of {'latlon', 'antarctic'}, optional
+            The name(s) of the comparison grid to use for remapping.
+        """
+        self.variables = variables
+
+        mpasVariables = set()
+        for variable in variables.values():
+            for mpasVariable in variable['mpas']:
+                mpasVariables.add(mpasVariable)
+
+        # call the constructor from the base class
+        # (RemapMpasClimatologySubtask)
+        super().__init__(
+            mpasClimatologyTask, parentTask, climatologyName, mpasVariables,
+            seasons, depths, comparisonGridNames, iselValues=None)
+
+    def customize_masked_climatology(self, climatology, season):
+        """
+        Construct velocity magnitude as part of the climatology
+
+        Parameters
+        ----------
+        climatology : ``xarray.Dataset`` object
+            the climatology data set
+
+        season : str
+            The name of the season to be masked
+
+        Returns
+        -------
+        climatology : ``xarray.Dataset`` object
+            the modified climatology data set
+        """
+
+        # first, call the base class's version of this function so we extract
+        # the desired slices.
+        climatology = super().customize_masked_climatology(climatology,
+                                                           season)
+        derivedVars = []
+        self._add_vel_mag(climatology, derivedVars)
+        self._add_thermal_forcing(climatology, derivedVars)
+
+        for varName, variable in self.variables.items():
+            if varName not in derivedVars:
+                # rename variables from MPAS names to shorter names
+                mpasvarNames = variable['mpas']
+                if len(mpasvarNames) == 1:
+                    mpasvarName = mpasvarNames[0]
+                    climatology[varName] = climatology[mpasvarName]
+                    climatology.drop_vars(mpasvarName)
+
+            climatology[varName].attrs['units'] = variable['units']
+            climatology[varName].attrs['description'] = variable['title']
+
+        return climatology
+
+    def _add_vel_mag(self, climatology, derivedVars):
+        """
+        Add the velocity magnitude to the climatology if requested
+        """
+        variables = self.variables
+
+        varName = 'verlocityMagnitude'
+        if varName not in variables:
+            return
+
+        derivedVars.append(varName)
+
+        zonalVel = climatology.timeMonthly_avg_velocityZonal
+        meridVel = climatology.timeMonthly_avg_velocityMeridional
+        climatology[varName] = np.sqrt(zonalVel**2 + meridVel**2)
+
+    def _add_thermal_forcing(self, climatology, derivedVars):
+        """
+        Add thermal forcing to the climatology if requested
+        """
+        variables = self.variables
+
+        varName = 'thermalForcing'
+        if varName not in variables:
+            return
+
+        derivedVars.append(varName)
+
+        c0 = self.namelist.getfloat(
+            'config_land_ice_cavity_freezing_temperature_coeff_0')
+        cs = self.namelist.getfloat(
+            'config_land_ice_cavity_freezing_temperature_coeff_S')
+        cp = self.namelist.getfloat(
+            'config_land_ice_cavity_freezing_temperature_coeff_p')
+        cps = self.namelist.getfloat(
+            'config_land_ice_cavity_freezing_temperature_coeff_pS')
+
+        temp = climatology.timeMonthly_avg_activeTracers_temperature
+        salin = climatology.timeMonthly_avg_activeTracers_salinity
+        dens = climatology.timeMonthly_avg_density
+        thick = climatology.timeMonthly_avg_layerThickness
+
+        dp = cime_constants['SHR_CONST_G']*dens*thick
+        press = dp.cumsum(dim='nVertLevels') - 0.5*dp
+
+        tempFreeze = c0 + cs*salin + cp*press + cps*press*salin
+
+        climatology[varName] = temp - tempFreeze
diff --git a/mpas_analysis/ocean/remap_depth_slices_subtask.py b/mpas_analysis/ocean/remap_depth_slices_subtask.py
index a3395916d..f145fcdb3 100644
--- a/mpas_analysis/ocean/remap_depth_slices_subtask.py
+++ b/mpas_analysis/ocean/remap_depth_slices_subtask.py
@@ -13,7 +13,10 @@
 
 from mpas_analysis.shared.climatology import RemapMpasClimatologySubtask
 
-from mpas_analysis.ocean.utility import compute_zmid
+from mpas_analysis.ocean.utility import (
+    compute_zinterface,
+    compute_zmid
+)
 
 
 class RemapDepthSlicesSubtask(RemapMpasClimatologySubtask):
@@ -27,11 +30,9 @@ class RemapDepthSlicesSubtask(RemapMpasClimatologySubtask):
         A list of depths at which the climatology will be sliced in the
         vertical.
 
-    maxLevelCell : xarray.DataArray
-        The vertical index of the bottom cell in MPAS results
-
-    verticalIndices : xarray.DataArray
-        The vertical indices of slice to be plotted
+    dsSlice : xarray.Dataset
+        A dataset containing information needed to index variables at the
+        designated depths
     """
     # Authors
     # -------
@@ -39,7 +40,7 @@ class RemapDepthSlicesSubtask(RemapMpasClimatologySubtask):
 
     def __init__(self, mpasClimatologyTask, parentTask, climatologyName,
                  variableList, seasons, depths, comparisonGridNames=['latlon'],
-                 iselValues=None):
+                 iselValues=None, subtaskName='remapDepthSlices'):
 
         """
         Construct the analysis task and adds it as a subtask of the
@@ -78,18 +79,23 @@ def __init__(self, mpasClimatologyTask, parentTask, climatologyName,
         iselValues : dict, optional
             A dictionary of dimensions and indices (or ``None``) used to
             extract a slice of the MPAS field(s).
+
+        subtaskName : str, optional
+            The name of the subtask
         """
         # Authors
         # -------
         # Xylar Asay-Davis
 
         self.depths = depths
+        self.dsSlice = xr.Dataset()
 
         # call the constructor from the base class
         # (RemapMpasClimatologySubtask)
-        super(RemapDepthSlicesSubtask, self).__init__(
+        super().__init__(
             mpasClimatologyTask, parentTask, climatologyName, variableList,
-            seasons, comparisonGridNames, iselValues)
+            seasons, comparisonGridNames, iselValues,
+            subtaskName=subtaskName)
 
     def run_task(self):
         """
@@ -110,66 +116,93 @@ def run_task(self):
         ds = ds[['maxLevelCell', 'bottomDepth', 'layerThickness']]
         ds = ds.isel(Time=0)
 
-        self.maxLevelCell = ds.maxLevelCell - 1
-
         depthNames = [str(depth) for depth in self.depths]
 
-        zMid = compute_zmid(ds.bottomDepth, ds.maxLevelCell-1,
-                            ds.layerThickness)
-        ocean_mask = (ds.maxLevelCell > 0)
+        bottomDepth = ds.bottomDepth
+        layerThickness = ds.layerThickness
+        maxLevelCell = ds.maxLevelCell - 1
+        self.dsSlice['maxLevelCell'] = maxLevelCell
+
+        zMid = compute_zmid(bottomDepth, maxLevelCell, layerThickness)
 
-        nVertLevels = zMid.shape[1]
+        zInterface = compute_zinterface(
+            bottomDepth, maxLevelCell, layerThickness)
+
+        horizontalMask = maxLevelCell >= 0
+
+        nVertLevels = ds.sizes['nVertLevels']
         zMid.coords['verticalIndex'] = \
             ('nVertLevels',
              np.arange(nVertLevels))
 
-        zTop = zMid.isel(nVertLevels=0)
+        nVertLevelsP1 = zInterface.sizes['nVertLevelsP1']
+        zInterface.coords['verticalIndex'] = \
+            ('nVertLevelsP1',
+             np.arange(nVertLevelsP1))
+
+        zLevelTop = zMid.isel(nVertLevels=0)
         # Each vertical layer has at most one non-NaN value so the "sum"
         # over the vertical is used to collapse the array in the vertical
         # dimension
-        zBot = zMid.where(zMid.verticalIndex == self.maxLevelCell).sum(
+        zLevelBot = zMid.where(zMid.verticalIndex == maxLevelCell).sum(
             dim='nVertLevels')
 
-        verticalIndices = np.zeros((len(self.depths), ds.sizes['nCells']), int)
+        zInterfaceTop = zInterface.isel(nVertLevelsP1=0)
+        zInterfaceBot = zInterface.where(
+            zInterface.verticalIndex == maxLevelCell + 1).sum(
+                dim='nVertLevelsP1')
 
-        mask = np.zeros(verticalIndices.shape, bool)
+        levelIndices = np.zeros((len(self.depths), ds.sizes['nCells']), int)
+        levelMask = np.zeros(levelIndices.shape, bool)
+        interfaceIndices = np.zeros(levelIndices.shape, int)
+        interfaceMask = np.zeros(levelIndices.shape, bool)
 
         for depthIndex, depth in enumerate(self.depths):
             depth = self.depths[depthIndex]
             if depth == 'top':
-                # switch to zero-based index
-                verticalIndices[depthIndex, :] = 0
-                mask[depthIndex, :] = self.maxLevelCell.values >= 0
+                levelIndices[depthIndex, :] = 0
+                levelMask[depthIndex, :] = horizontalMask.values
+                interfaceIndices[depthIndex, :] = 0
+                interfaceMask[depthIndex, :] = horizontalMask.values
             elif depth == 'bot':
                 # switch to zero-based index
-                verticalIndices[depthIndex, :] = self.maxLevelCell.values
-                mask[depthIndex, :] = self.maxLevelCell.values >= 0
+                levelIndices[depthIndex, :] = maxLevelCell.values
+                levelMask[depthIndex, :] = horizontalMask.values
+                interfaceIndices[depthIndex, :] = maxLevelCell.values + 1
+                interfaceMask[depthIndex, :] = horizontalMask.values
             else:
-
-                diff = np.abs(zMid - depth).where(ocean_mask, drop=True)
-                verticalIndex = diff.argmin(dim='nVertLevels')
-
-                verticalIndices[depthIndex, ocean_mask.values] = \
-                    verticalIndex.values
-                mask[depthIndex, :] = np.logical_and(depth <= zTop,
-                                                     depth >= zBot).values
-
-        self.verticalIndices = \
-            xr.DataArray.from_dict({'dims': ('depthSlice', 'nCells'),
-                                    'coords': {'depthSlice':
-                                               {'dims': ('depthSlice',),
-                                                'data': depthNames}},
-                                    'data': verticalIndices})
-        self.verticalIndexMask = \
-            xr.DataArray.from_dict({'dims': ('depthSlice', 'nCells'),
-                                    'coords': {'depthSlice':
-                                               {'dims': ('depthSlice',),
-                                                'data': depthNames}},
-                                    'data': mask})
+                levelDiff = np.abs(zMid - depth).where(horizontalMask,
+                                                       drop=True)
+                levelIndex = levelDiff.argmin(dim='nVertLevels')
+
+                levelIndices[depthIndex, horizontalMask.values] = \
+                    levelIndex.values
+                levelMask[depthIndex, :] = np.logical_and(
+                    depth <= zLevelTop, depth >= zLevelBot).values
+
+                interfaceDiff = np.abs(zInterface - depth).where(
+                    horizontalMask, drop=True)
+                interfaceIndex = interfaceDiff.argmin(dim='nVertLevelsP1')
+
+                interfaceIndices[depthIndex, horizontalMask.values] = \
+                    interfaceIndex.values
+                interfaceMask[depthIndex, :] = np.logical_and(
+                    depth <= zInterfaceTop, depth >= zInterfaceBot).values
+
+        self.dsSlice.coords['depthSlice'] = ('depthSlice', depthNames)
+
+        self.dsSlice['levelIndices'] = (('depthSlice', 'nCells'),
+                                        levelIndices)
+        self.dsSlice['levelIndexMask'] = (('depthSlice', 'nCells'),
+                                          levelMask)
+        self.dsSlice['interfaceIndices'] = (('depthSlice', 'nCells'),
+                                            interfaceIndices)
+        self.dsSlice['interfaceIndexMask'] = (('depthSlice', 'nCells'),
+                                              interfaceMask)
 
         # then, call run from the base class (RemapMpasClimatologySubtask),
         # which will perform the main function of the task
-        super(RemapDepthSlicesSubtask, self).run_task()
+        super().run_task()
 
     def customize_masked_climatology(self, climatology, season):
         """
@@ -197,29 +230,46 @@ def customize_masked_climatology(self, climatology, season):
         if self.depths is None:
             return climatology
 
-        climatology.coords['verticalIndex'] = \
-            ('nVertLevels',
-             np.arange(climatology.sizes['nVertLevels']))
+        if 'nVertLevels' in climatology.dims:
+            climatology.coords['levelIndex'] = \
+                ('nVertLevels',
+                 np.arange(climatology.sizes['nVertLevels']))
+        if 'nVertLevelsP1' in climatology.dims:
+            climatology.coords['interfaceIndex'] = \
+                ('nVertLevelsP1',
+                 np.arange(climatology.sizes['nVertLevelsP1']))
 
         depthNames = [str(depth) for depth in self.depths]
 
         climatology.coords['depthSlice'] = ('depthSlice', depthNames)
 
-        for variableName in self.variableList:
-            if 'nVertLevels' not in climatology[variableName].dims:
-                continue
+        levelIndices = self.dsSlice.levelIndices
+        levelIndexMask = self.dsSlice.levelIndexMask
+        interfaceIndices = self.dsSlice.interfaceIndices
+        interfaceIndexMask = self.dsSlice.interfaceIndexMask
 
-            # mask only the values with the right vertical index
-            da = climatology[variableName].where(
-                climatology.verticalIndex == self.verticalIndices)
-
-            # Each vertical layer has at most one non-NaN value so the "sum"
-            # over the vertical is used to collapse the array in the vertical
-            # dimension
-            climatology[variableName] = \
-                da.sum(dim='nVertLevels').where(self.verticalIndexMask)
-
-        climatology = climatology.drop_vars('verticalIndex')
+        for variableName in self.variableList:
+            if 'nVertLevels' in climatology[variableName].dims:
+                # mask only the values with the right vertical index
+                da = climatology[variableName].where(
+                    climatology.levelIndex == levelIndices)
+
+                # Each vertical layer has at most one non-NaN value so the
+                # "sum" over the vertical is used to collapse the array in the
+                # vertical dimension
+                climatology[variableName] = \
+                    da.sum(dim='nVertLevels').where(levelIndexMask)
+            elif 'nVertLevelsP1' in climatology[variableName].dims:
+                da = climatology[variableName].where(
+                    climatology.interfaceIndex == interfaceIndices)
+
+                climatology[variableName] = \
+                    da.sum(dim='nVertLevelsP1').where(interfaceIndexMask)
+
+        if 'levelIndex' in climatology.coords:
+            climatology = climatology.drop_vars('levelIndex')
+        if 'interfaceIndex' in climatology.coords:
+            climatology = climatology.drop_vars('interfaceIndex')
 
         climatology = climatology.transpose('depthSlice', 'nCells')
 
diff --git a/mpas_analysis/ocean/utility.py b/mpas_analysis/ocean/utility.py
index a96c7c3fa..66f4ec299 100644
--- a/mpas_analysis/ocean/utility.py
+++ b/mpas_analysis/ocean/utility.py
@@ -126,3 +126,54 @@ def compute_zmid(bottomDepth, maxLevelCell, layerThickness):
     zMid = zLayerBot + 0.5 * layerThickness
 
     return zMid
+
+
+def compute_zinterface(bottomDepth, maxLevelCell, layerThickness):
+    """
+    Computes zInterface given data arrays for bottomDepth, maxLevelCell and
+    layerThickness
+
+    Parameters
+    ----------
+    bottomDepth : ``xarray.DataArray``
+        the depth of the ocean bottom (positive)
+
+    maxLevelCell : ``xarray.DataArray``
+        the 0-based vertical index of the bottom of the ocean
+
+    layerThickness : ``xarray.DataArray``
+        the thickness of MPAS-Ocean layers (possibly as a function of time)
+
+    Returns
+    -------
+    zInterface : ``xarray.DataArray``
+        the vertical coordinate defining the interfaces between layers, masked
+        below the bathymetry
+    """
+    # Authors
+    # -------
+    # Xylar Asay-Davis
+
+    nVertLevels = layerThickness.sizes['nVertLevels']
+
+    vertIndex = \
+        xarray.DataArray.from_dict({'dims': ('nVertLevels',),
+                                    'data': numpy.arange(nVertLevels)})
+
+    layerThickness = layerThickness.where(vertIndex <= maxLevelCell)
+    thicknessSum = layerThickness.sum(dim='nVertLevels')
+
+    zSurface = -bottomDepth + thicknessSum
+
+    zInterfaceList = [zSurface]
+
+    zTop = zSurface
+
+    for zIndex in range(nVertLevels):
+        zBot = zTop - layerThickness.isel(nVertLevels=zIndex)
+        zInterfaceList.append(zBot)
+        zTop = zBot
+
+    zInterface = xarray.concat(zInterfaceList, dim='nVertLevelsP1').transpose(
+        'nCells', 'nVertLevelsP1')
+    return zInterface


Reviews:
user: login: irenavankova
body: I have used this and it works great. I added extra fields and it was very easy to do.

This is a really useful feature for more personalized analysis and paper writing as it does not rely on availability of observations. It would be great to have for sea ice also, where come fields (e.g. Ice thickness) can only by plotted over seasons when obs are available.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-07T15:32:19Z
user: login: xylar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T18:06:06Z
user: login: irenavankova
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T18:10:08Z

Review comments:
diff_hunk: @@ -2151,6 +2151,215 @@ normTypeDifference = linear
 normArgsDifference = {'vmin': -0.2, 'vmax': 0.2}
 
 
+[climatologyMapCustom]
+## options related to plotting climatology maps of any field at various depths
+## (if they include a depth dimension) without observatons for comparison
+
+# comparison grid(s)
+comparisonGrids = ['latlon']
+
+# Months or seasons to plot (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct,
+# Nov, Dec, JFM, AMJ, JAS, OND, ANN)
+seasons =  ['ANN']
+
+# list of depths in meters (positive up) at which to analyze, 'top' for the
+# sea surface, 'bot' for the sea floor
+depths = ['top', -200, -400, -600, -800, -1000, -1500, -2000, 'bot']
+
+# a list of variables available to plot. New variables can be added as long
+# as they correspond to a single field already found in MPAS-Ocean's
+# timeSeriesStatsMonthly output. Add the 'name', 'title', 'units' (with $$
+# instead a single dollar sign for the config parser), and 'mpas'(the
+# timeSeriesStatsMonthly variable name as a single-item list) entries for each
+# variable. Then, add a section below climatologyMapCustom<VariableName> with
+# the colormap settings for that variable.
+availableVariables = {
+    'temperature':
+        {'title': 'Potential Temperature',
+         'units': r'$$^\circ$$C',
+         'mpas': ['timeMonthly_avg_activeTracers_temperature']},
+    'salinity':
+        {'title': 'Salinity',
+         'units': 'PSU',
+         'mpas': ['timeMonthly_avg_activeTracers_salinity']},
+    'potentialDensity':
+        {'title': 'Potential Density',
+         'units': 'kg m$$^{-3}$$',
+         'mpas': ['timeMonthly_avg_potentialDensity']},
+    'thermalForcing':
+        {'title': 'Thermal Forcing',
+         'units': r'$$^\circ$$C',
+         'mpas': ['timeMonthly_avg_activeTracers_temperature',
+                  'timeMonthly_avg_activeTracers_salinity',
+                  'timeMonthly_avg_density',
+                  'timeMonthly_avg_activeTracers_layerThickness']},
+    'zonalVelocity':
+        {'title': 'Zonal Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_velocityZonal']},
+    'meridionalVelocity':
+        {'title': 'Meridional Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_velocityMeridional']},
+    'velocityMagnitude':
+        {'title': 'Zonal Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_velocityZonal',
+                  'timeMonthly_avg_velocityMeridional']},
+     'vertVelocity':
+        {'title': 'Vertical Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_vertVelocityTop']},
+     'vertDiff':
+        {'title': 'Vertical Diffusivity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_vertDiffTopOfCell']},
+     'vertVisc':
+        {'title': 'Vertical Viscosity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_vertViscTopOfCell']},
+    'mixedLayerDepth':
+        {'title': 'Mixed Layer Depth',
+         'units': 'm',
+         'mpas': ['timeMonthly_avg_dThreshMLD'],
+         'has_depth': False},
+    }
+
+# a list of fields top plot for each transect.  All supported fields are listed
path: mpas_analysis/default.cfg
user: login: xylar
body: Oops, transect should have been climatology...
created_at: 2025-04-07T18:06:06Z
author_association: COLLABORATOR
diff_hunk: @@ -2151,6 +2151,215 @@ normTypeDifference = linear
 normArgsDifference = {'vmin': -0.2, 'vmax': 0.2}
 
 
+[climatologyMapCustom]
+## options related to plotting climatology maps of any field at various depths
+## (if they include a depth dimension) without observatons for comparison
+
+# comparison grid(s)
+comparisonGrids = ['latlon']
+
+# Months or seasons to plot (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct,
+# Nov, Dec, JFM, AMJ, JAS, OND, ANN)
+seasons =  ['ANN']
+
+# list of depths in meters (positive up) at which to analyze, 'top' for the
+# sea surface, 'bot' for the sea floor
+depths = ['top', -200, -400, -600, -800, -1000, -1500, -2000, 'bot']
+
+# a list of variables available to plot. New variables can be added as long
+# as they correspond to a single field already found in MPAS-Ocean's
+# timeSeriesStatsMonthly output. Add the 'name', 'title', 'units' (with $$
+# instead a single dollar sign for the config parser), and 'mpas'(the
+# timeSeriesStatsMonthly variable name as a single-item list) entries for each
+# variable. Then, add a section below climatologyMapCustom<VariableName> with
+# the colormap settings for that variable.
+availableVariables = {
+    'temperature':
+        {'title': 'Potential Temperature',
+         'units': r'$$^\circ$$C',
+         'mpas': ['timeMonthly_avg_activeTracers_temperature']},
+    'salinity':
+        {'title': 'Salinity',
+         'units': 'PSU',
+         'mpas': ['timeMonthly_avg_activeTracers_salinity']},
+    'potentialDensity':
+        {'title': 'Potential Density',
+         'units': 'kg m$$^{-3}$$',
+         'mpas': ['timeMonthly_avg_potentialDensity']},
+    'thermalForcing':
+        {'title': 'Thermal Forcing',
+         'units': r'$$^\circ$$C',
+         'mpas': ['timeMonthly_avg_activeTracers_temperature',
+                  'timeMonthly_avg_activeTracers_salinity',
+                  'timeMonthly_avg_density',
+                  'timeMonthly_avg_activeTracers_layerThickness']},
+    'zonalVelocity':
+        {'title': 'Zonal Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_velocityZonal']},
+    'meridionalVelocity':
+        {'title': 'Meridional Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_velocityMeridional']},
+    'velocityMagnitude':
+        {'title': 'Zonal Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_velocityZonal',
+                  'timeMonthly_avg_velocityMeridional']},
+     'vertVelocity':
+        {'title': 'Vertical Velocity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_vertVelocityTop']},
+     'vertDiff':
+        {'title': 'Vertical Diffusivity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_vertDiffTopOfCell']},
+     'vertVisc':
+        {'title': 'Vertical Viscosity',
+         'units': r'm s$$^{-1}$$',
+         'mpas': ['timeMonthly_avg_vertViscTopOfCell']},
+    'mixedLayerDepth':
+        {'title': 'Mixed Layer Depth',
+         'units': 'm',
+         'mpas': ['timeMonthly_avg_dThreshMLD'],
+         'has_depth': False},
+    }
+
+# a list of fields top plot for each transect.  All supported fields are listed
path: mpas_analysis/default.cfg
user: login: irenavankova
body: Ah, ok, just a comment - I was scared for a second it is picking up something else then I thought and that my analysis is wrong :)
created_at: 2025-04-07T18:10:08Z
author_association: COLLABORATOR

PR comments:
user: login: xylar
created_at: 2025-02-18T21:56:48Z
body: @irenavankova, this is the first of 2 features you requested.  It seems to work, see:
https://web.lcrc.anl.gov/public/e3sm/diagnostic_output/ac.xylar/analysis/v3.LR.piControl/clim_custom/ocean/index.html
(config options here https://web.lcrc.anl.gov/public/e3sm/diagnostic_output/ac.xylar/analysis/v3.LR.piControl/clim_custom/v3.LR.piControl.cfg)

(Note: the web server has cached old versions of some plots.  Hopefully, these will update again in a few hours.)

I need to:
* add documentation
* test on 2D fields
* run the test suite (in progress)

In the meantime, give it a shot and let me know what you think.  Happy to add more variables to `defaults.cfg` that you think might be useful.
author_association: COLLABORATOR
user: login: xylar
created_at: 2025-02-19T14:34:47Z
body: ## Testing

Here are some update results showing both 2D and 3D fields, the latter with both `nVertLevels` and `nVertLevelsP1` as the vertical dimension:
https://web.lcrc.anl.gov/public/e3sm/diagnostic_output/ac.xylar/analysis/v3.LR.piControl/clim_custom/ocean/index.html

I have also successfully run the developer suite:
https://web.lcrc.anl.gov/public/e3sm/diagnostic_output/ac.xasay-davis/analysis_testing/chrysalis/add-climatology-map-at-depths/
author_association: COLLABORATOR
user: login: xylar
created_at: 2025-02-19T14:53:28Z
body: I think this will be ready for review as soon as v1.13.0 get released.
author_association: COLLABORATOR
user: login: xylar
created_at: 2025-04-07T00:33:59Z
body: @irenavankova, I know you have used this already.  I would like to get it merge into MPAS-Analysis soon.  Could you give it a review based on your testing?
author_association: COLLABORATOR
user: login: xylar
created_at: 2025-04-07T18:01:29Z
body: Thanks @irenavankova!
author_association: COLLABORATOR