Skip to content

Add MOC stream function analysis capability - #481

Open
brian-oneill wants to merge 36 commits into
E3SM-Project:developfrom
brian-oneill:omega/analysis-moc-streamfunction
Open

Add MOC stream function analysis capability#481
brian-oneill wants to merge 36 commits into
E3SM-Project:developfrom
brian-oneill:omega/analysis-moc-streamfunction

Conversation

@brian-oneill

@brian-oneill brian-oneill commented Jul 28, 2026

Copy link
Copy Markdown

Overview

This PR introduces a complete MOC (Meridional Overturning Circulation) analysis capability to Omega, enabling computation of the MOC streamfunction using two complementary methods:

  1. Latitude-binned regional MOC: Computes MOC as a function of latitude and depth for specified ocean regions
  2. Transect-based MOC: Computes MOC across specific transects as a function of depth

The implementation adds 8 new analysis operators, enhanced analysis infrastructure with regional mask support, a new MOC analysis group, and comprehensive configuration templates.

Key Features

New Analysis Operators (8 total):

  • BinaryMultiplyOp: Element-wise field multiplication with vertical expansion support
  • BinnedAccumulatorOp: Accumulates field values into spatial bins (core MOC operator)
  • CoordinateBinningOp: Assigns mesh entities to bins based on coordinate values
  • ExtractRegionOp: Applies regional masks to fields
  • PrefixSumOp: Cumulative summation (integration) along specified dimension
  • PseudoToGeometricOp: Converts pseudo-height quantities to geometric coordinates
  • ScalarMultiplyOp: Multiplies field by scalar constant for unit conversion
  • TransectAccumulatorOp: Accumulates transport across transect edges

Infrastructure Enhancements:

  • Field Class Regional Mask Support: Fields can carry spatial mask information through operator chains with automatic propagation
  • IOStream IOName Metadata: Provides user-friendly variable names in netCDF output while maintaining internal operator chain naming
  • Enhanced AnalysisGroup Base Class: New setOutputIOName method and operator-specific configuration support

MOC Analysis Group:

  • Bundled analysis group for computing MOC streamfunction
  • Configurable latitude binning (number of bins, lat range)
  • Regional MOC computation for named ocean regions
  • Transect-based MOC computation across specified transects
  • Configurable temporal output (reduction periods and snapshots)
  • IOStream integration for netCDF output

MOC Computation Pipeline

Latitude-binned Regional MOC chain:

  1. Assign cells to latitude bins (static, initialization)
  2. Convert vertical pseudo-velocity to geometric coordinates
  3. Compute vertical flux (velocity × area)
  4. Apply regional mask (optional)
  5. Accumulate into latitude bins
  6. Horizontal integration (south→north)
  7. Convert to Sverdrups

Transect-based MOC chain:

  1. Convert pseudo-thickness to geometric layer thickness
  2. Compute transport (thickness × velocity × edge width)
  3. Accumulate across transect edges
  4. Vertical integration (bottom→top)
  5. Convert to Sverdrups

Technical Implementation

Design Features:

  • Field-level regional mask storage and automatic propagation
  • IOName metadata system separating internal from user-facing names
  • Kokkos hierarchical parallelism for 2D/3D operators
  • SFINAE compile-time optimization to prevent invalid template instantiations
  • Operator parameters passed via Config objects through chain parsing

Output Format:

  • MOC streamfunction in Sverdrups (1 Sv = 10⁶ m³/s)
  • Dimensions: latitude bins × depth levels (regional), or depth only (transect)
  • NetCDF files with user-friendly variable names (e.g., "MOC_streamfunction_Global")
  • Configurable temporal averaging and snapshot output

Limitations

  • Regional masks not yet implemented (operator infrastructure ready, placeholder in config)
  • Transect masks not yet implemented (operator infrastructure ready, placeholder in config)

Checklist

  • Documentation:

  • Linting

  • Building

    • CMake build does not produce any new warnings from changes in this PR
  • Testing

    aurora, oneapi-ifx, mpich

    • CTests Pass
    • Polaris omega_pr Pass

    chrysalis, oneapi-ifx, openmpi

    • CTests Pass
    • Polaris omega_pr Pass

    frontier, craygnu, mpich

    • CTests Pass
    • Polaris omega_pr Pass

    frontier, craygnu-mphipcc, mpich

    • CTests Pass
    • Polaris omega_pr Pass

    pm-cpu, gnu, mpich

    • CTests Pass
    • Polaris omega_pr Pass

    pm-gpu, gnugpu, mpich

    • CTests Pass
    • Polaris omega_pr Pass
  • Provide relevant details in a comment to the PR titled Testing with the following:

    • Which machines CTest unit tests
      have been run on and indicate that are all passing.
    • The Polaris omega_pr test suite
      has passed, using the Polaris e3sm_submodules/Omega baseline
    • Document machine(s), compiler(s), and the build path(s) used for -p for both the baseline (Polaris e3sm_submodules/Omega) and the PR build
    • Indicate "All tests passed" or document failing tests
    • Document testing used to verify the changes including any tests that are added/modified/impacted.
  • New tests:

    • CTest unit tests for new features have been added per the approved design.
    • Polaris tests for new features have been added per the approved design (and included in a test suite)

@brian-oneill
brian-oneill requested review from cbegeman and xylar July 28, 2026 04:01
@xylar

xylar commented Jul 28, 2026

Copy link
Copy Markdown

Sorry, @brian-oneill, I didn't get to this today. I'll try again tomorrow.

Also, let me know what you need from me regarding both the dynamic streams here in Omega and the Polaris support.

Comment on lines +405 to +406
ReductionPeriod: [1Month]
SnapshotPeriod: [1Day]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you help me understand what sets how often the MOC is computed? Is it the SnapshotPeriod? With the options above, ReductionPeriod would then be averaging daily instantaneous MOC values over 1 month?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that they are independent of one another. In practice, we would have:

  ReductionPeriod: [1Month]
  SnapshotPeriod: []

since we want monthly averages and don't need snapshots.

It's hard for me to imagine very much analysis where we want both time averages and snapshots at the same time, in practice.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, ReductionPeriod and SnapshotPeriod produce outputs independently. The ReductionPeriod outputs currently accumulate every timestep so the MOC is computed each timestep for time averages, but this can be extended to allow for a courser sampling frequency pretty easily.

# Computes spatial reduction statistics (Mean, Min, Max, StdDev)
# for a set of ocean fields. Supports temporal reduction (time-averaged
# output over a window) and instantaneous snapshots (discrete sampling).
Fields: [NormalVelocity, PseudoThickness, Temperature, Salinity]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to test that when, e.g., LayerThickness_BinaryMultiply(NormalVelocity), is present here that the MOC chain uses the available field or is this kind of thing covered by existing CTests?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I guess not present here because GlobalStats reduces spatially.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During the initial parsing, the parser checks if a Field that would be output by an operator has already been registered, to prevent building a duplicate operator. There is a unit test that checks this behavior, but it could be more robust.

@xylar xylar left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brian-oneill, I'll do some testing but here are a few comments to keep the process moving.

This looks great! A lot of the pieces are in place and just a few tweaks would be helpful, I think. Plus a few things that might be for now or might be postponed until later.

Comment thread components/omega/configs/Default.yml Outdated
Comment on lines +405 to +406
ReductionPeriod: [1Month]
SnapshotPeriod: [1Day]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that they are independent of one another. In practice, we would have:

  ReductionPeriod: [1Month]
  SnapshotPeriod: []

since we want monthly averages and don't need snapshots.

It's hard for me to imagine very much analysis where we want both time averages and snapshots at the same time, in practice.

Comment thread components/omega/src/analysis/analysisGroups/MOC.cpp
Comment thread components/omega/src/analysis/operators/PseudoToGeometricOp.h
Comment thread components/omega/src/analysis/operators/PseudoToGeometricOp.h

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could ScalarMultiply be generalized to take a model config option or known constant as its input, not just a hard-coded number? This would seem much more useful and general.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the use case here, this Op gets passed 1e-6 through a "config" that is programmatically defined in the parser. So 1e-6 is hard-coded into the MOC chain construction, but the Op itself is designed to take a configurable value to be compatible with the future composable framework.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. It's RhoSw in particular that I wanted to know about.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, that's possible but requires a little more work. Allowing users to request a defined constant from the config file would require defining a map between string labels and the variable names in GlobalConstants.h

std::map<std::string,Real> Constants = {
{"RhoSw", RhoSw},
{"Gravity", Gravity},
{"Pi", Pi},
...
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should try to figure out how to automate that somehow but, yes, that's what I was anticipating. Nothing that needs to be in this PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, automated would be best. Here's Claude's suggestion:

Yes, the X-macro pattern is exactly right. The idea: define a single list of constants once using a macro, then expand it in two ways — once to declare the constexpr values, and once to build the lookup.

In GlobalConstants.h, replace the individual constexpr declarations for the physical constants with:

// X-macro list: X(Name, Value)
#define OMEGA_PHYSICAL_CONSTANTS(X)          \
    X(RhoSw,   pcd::seawater_density_reference)           \
    X(RhoFw,   pcd::pure_water_density_reference)         \
    X(RhoAir,  pcd::dry_air_density_at_standard_temperature_and_pressure) \
    X(Gravity, pcd::standard_acceleration_of_gravity)     \
    X(RhoIce,  pcd::sea_ice_density_reference)            \
    /* ... all others ... */

// Expand to constexpr declarations (same as before)
#define DECLARE_CONST(Name, Value) constexpr Real Name = (Value);
OMEGA_PHYSICAL_CONSTANTS(DECLARE_CONST)
#undef DECLARE_CONST

Then the lookup function writes itself:

inline std::optional<Real> getConstantByName(const std::string &Name) {
#define MATCH_CONST(CName, Value) if (Name == #CName) return CName;
    OMEGA_PHYSICAL_CONSTANTS(MATCH_CONST)
#undef MATCH_CONST
    return std::nullopt;
}

#CName stringifies the identifier automatically, so the name in the config file ("RhoSw") matches the C++ variable name without any manual duplication.

Pros: Zero maintenance burden — adding a new constant to the list automatically makes it available by name to ScalarMultiplyOp and any future operator that calls getConstantByName.

Cons: Requires refactoring the existing constexpr declarations in GlobalConstants.h into the macro list format. The math-derived ones (TwoPi, SDay, etc.) are trickier since they depend on other constants — those would need to stay as regular constexpr or be added after the macro expansion.

The practical approach: put only the "leaf" physical constants (densities, heat capacities, etc.) in the X-macro list, and keep derived/compound ones (TwoPi, TkFrzSw, SDay) as regular constexpr declarations below. You could optionally add those to a second X-macro list if you want them accessible by name too.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not bad, not bad!

@xylar xylar left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I successfully ran a 5-day test with the MOC on in:
/lcrc/group/e3sm/ac.xylar/polaris_1.0/chrysalis/test_20260804/ec30to60-global-moc/ocean/spherical/realistic_global/EC30to60E2r2/analysis_members_test

I used:

    MOC:
      Enable: true
      # Meridional Overturning Circulation (MOC) streamfunction analysis group
      # Computes MOC as a function of latitude and depth for regions,
      # and as a function of depth for transects
      NumBins: 180              # Number of latitude bins (default: 180, ~1 degree)
      MinLat: -90.0             # Minimum latitude in degrees (default: -90.0)
      MaxLat: 90.0              # Maximum latitude in degrees (default: 90.0)
      Regions: [Global]         # List of region names for regional MOC
                                # NOTE: Region masks not yet implemented
      Transects: []             # List of transect names for transect-based MOC
                                # NOTE: Transect masks not yet implemented
      ReductionPeriod: [1day]       # Temporal reduction periods
      SnapshotPeriod: []  # Instantaneous output periods
      Filename: moc.$Y
      Stream:
        FileFreq: 1
        FileFreqUnits: days

So daily averaging rather than monthly for efficiency.

Here's an example plot:
Image

However, the latitude bins and depth are missing from the output file:

$ ncdump -h moc_1dayTimeStats.0001 
netcdf moc_1dayTimeStats {
dimensions:
	MaxCellsOnEdge = 2 ;
	MaxEdges = 7 ;
	MaxEdges2 = 14 ;
	NCells = 236853 ;
	NEdges = 719506 ;
	NTracers = 2 ;
	NVertLayers = 60 ;
	NVertLayersP1 = 61 ;
	NVertices = 482371 ;
	NumBinsLatCell_BinIndex = 180 ;
	Scalar = 1 ;
	VertexDegree = 3 ;
	time = UNLIMITED ; // (5 currently)
variables:
	double MOC_streamfunction_Global_TimeMean1day(time, NumBinsLatCell_BinIndex, NVertLayersP1) ;
		MOC_streamfunction_Global_TimeMean1day:Description = "Time average of VerticalPseudoVelocity_PseudoToGeometric_BinaryMultiply(AreaCell)_BinnedAccumulator(LatCell_BinIndex)_PrefixSum_ScalarMultiply(1.0e-6)" ;
		MOC_streamfunction_Global_TimeMean1day:Name = "VerticalPseudoVelocity_PseudoToGeometric_BinaryMultiply(AreaCell)_BinnedAccumulator(LatCell_BinIndex)_PrefixSum_ScalarMultiply(1.0e-6)_TimeMean1day" ;
		MOC_streamfunction_Global_TimeMean1day:StdName = "" ;
		MOC_streamfunction_Global_TimeMean1day:Units = "" ;
		MOC_streamfunction_Global_TimeMean1day:ValidMax = 1.79769313486232e+308 ;
		MOC_streamfunction_Global_TimeMean1day:ValidMin = -1.79769313486232e+308 ;
		MOC_streamfunction_Global_TimeMean1day:_FillValue = 9.96920996838687e+36 ;
		MOC_streamfunction_Global_TimeMean1day:long_name = "Time average of VerticalPseudoVelocity_PseudoToGeometric_BinaryMultiply(AreaCell)_BinnedAccumulator(LatCell_BinIndex)_PrefixSum_ScalarMultiply(1.0e-6)" ;
		MOC_streamfunction_Global_TimeMean1day:name = "VerticalPseudoVelocity_PseudoToGeometric_BinaryMultiply(AreaCell)_BinnedAccumulator(LatCell_BinIndex)_PrefixSum_ScalarMultiply(1.0e-6)_TimeMean1day" ;
		MOC_streamfunction_Global_TimeMean1day:standard_name = "" ;
		MOC_streamfunction_Global_TimeMean1day:units = "" ;
		MOC_streamfunction_Global_TimeMean1day:valid_max = 1.79769313486232e+308 ;
		MOC_streamfunction_Global_TimeMean1day:valid_min = -1.79769313486232e+308 ;
	double time(time) ;
		time:Description = "time" ;
		time:Name = "time" ;
		time:StdName = "time" ;
		time:Units = "seconds since 0001-01-01 00:00:00" ;
		time:ValidMax = 1.e+20 ;
		time:ValidMin = 0. ;
		time:_FillValue = 9.96920996838687e+36 ;
		time:calendar = "noleap" ;
		time:long_name = "time" ;
		time:name = "time" ;
		time:standard_name = "time" ;
		time:units = "seconds since 0001-01-01 00:00:00" ;
		time:valid_max = 1.e+20 ;
		time:valid_min = 0. ;

// global attributes:
		:SimulationTime = "0001-01-06_00:00:00" ;
		:SimulationTime0 = "0001-01-02_00:00:00" ;
		:SimulationTime1 = "0001-01-03_00:00:00" ;
		:SimulationTime2 = "0001-01-04_00:00:00" ;
		:SimulationTime3 = "0001-01-05_00:00:00" ;
		:SimulationTime4 = "0001-01-06_00:00:00" ;
}

Also the file is missing a .nc extension.

Happy to rerun once this is fixed.

@brian-oneill

brian-oneill commented Aug 4, 2026

Copy link
Copy Markdown
Author

Is refBottomDepth the appropriate depth to go with the output? Because that's not currently read in to Omega...

I've noticed the history and hifreq files that get created when running the omega ctests also no longer get the .nc suffix added. Looks like 1f92657 removed the lines that explicitly added the suffix to filenames with time templates. A comment says PIO should handle this, but that doesn't appear to be the case. Adding .nc to the end of the Filename option in the config does work.

@xylar

xylar commented Aug 4, 2026

Copy link
Copy Markdown

refBottomDepth is an MPAS-Ocean concept and is not going to be available in general for Omega. So, no, it is not appropriate.

The problem with the current layer-wise approach is it implicitly assumes pure z-level layers. The best way to get depths given this approach is to get the area-weighted average of zInterface and use that as the vertical coordinate. For now, that's fine. With ice-shelf cavities, sigma coordinates, etc. in the future, we'll need to do vertical binning or interpolation to a z-level or density-level grid.

@xylar

xylar commented Aug 4, 2026

Copy link
Copy Markdown

I'm fine with whatever fix to the .nc extension but we do need them to be put back somehow. I can understand the desire to support arbitrary format and not explicitly require NetCDF, though.

# List of field names to compute statistics for
SpatialStats: [Max, Min, Mean, StdDev]
# Spatial statistics to compute (one per field)
ReductionPeriod: [1Day, 1Month]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ReductionPeriod: [1Day, 1Month]
ReductionPeriod: []

Should we remove reductions altogether from this analysis member to save compute time?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want 1Month reduction for the climatology plot, don't we?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it depends on what configuration Default.yml is aimed at -- typical standalone testing or a longer production run.

# Spatial statistics to compute (one per field)
ReductionPeriod: [1Day, 1Month]
# Temporal reduction periods (time-averaged stats)
SnapshotPeriod: [6Hours]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MPAS-O is equivalent to 1Day but I like increasing the freq to 6 hours

@cbegeman

Copy link
Copy Markdown

However, the latitude bins and depth are missing from the output file:

@brian-oneill Is this ready for testing with the fix to this comment?

@brian-oneill

Copy link
Copy Markdown
Author

However, the latitude bins and depth are missing from the output file:

@brian-oneill Is this ready for testing with the fix to this comment?

@cbegeman I added the latitude bins. The depths will require a bit more thought and work, and I won’t have time to complete that piece until I’m back at the end of the week.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants