From fc789b329592d36be22e1511151a788fca019fa0 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 4 Nov 2025 08:42:30 -0600 Subject: [PATCH 01/53] Fix single_column docstring --- polaris/tasks/ocean/single_column/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/polaris/tasks/ocean/single_column/__init__.py b/polaris/tasks/ocean/single_column/__init__.py index 9999db60f4..5c84a5c574 100644 --- a/polaris/tasks/ocean/single_column/__init__.py +++ b/polaris/tasks/ocean/single_column/__init__.py @@ -8,6 +8,8 @@ def add_single_column_tasks(component): """ Add tasks for various single-column tests + Parameters + ---------- component : polaris.tasks.ocean.Ocean the ocean component that the tasks will be added to """ From 8ee4d6200eb378e53011db69a008556576045f17 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 4 Nov 2025 08:43:23 -0600 Subject: [PATCH 02/53] Add two_column test for TEOS-10 For now, just creates initial conditions in each column, including specific volume. --- polaris/tasks/ocean/add_tasks.py | 4 + polaris/tasks/ocean/two_column/__init__.py | 13 ++ polaris/tasks/ocean/two_column/init.py | 188 ++++++++++++++++++ .../tasks/ocean/two_column/teos10/__init__.py | 34 ++++ .../tasks/ocean/two_column/teos10/teos10.cfg | 32 +++ polaris/tasks/ocean/two_column/two_column.cfg | 52 +++++ 6 files changed, 323 insertions(+) create mode 100644 polaris/tasks/ocean/two_column/__init__.py create mode 100644 polaris/tasks/ocean/two_column/init.py create mode 100644 polaris/tasks/ocean/two_column/teos10/__init__.py create mode 100644 polaris/tasks/ocean/two_column/teos10/teos10.cfg create mode 100644 polaris/tasks/ocean/two_column/two_column.cfg diff --git a/polaris/tasks/ocean/add_tasks.py b/polaris/tasks/ocean/add_tasks.py index 26d35beaa5..dfe1290c4e 100644 --- a/polaris/tasks/ocean/add_tasks.py +++ b/polaris/tasks/ocean/add_tasks.py @@ -21,6 +21,7 @@ from polaris.tasks.ocean.seamount import add_seamount_tasks from polaris.tasks.ocean.single_column import add_single_column_tasks from polaris.tasks.ocean.sphere_transport import add_sphere_transport_tasks +from polaris.tasks.ocean.two_column import add_two_column_tasks def add_ocean_tasks(component): @@ -48,6 +49,9 @@ def add_ocean_tasks(component): # single column tasks add_single_column_tasks(component=component) + # two column tasks + add_two_column_tasks(component=component) + # spherical tasks add_customizable_viz_tasks(component=component) add_cosine_bell_tasks(component=component) diff --git a/polaris/tasks/ocean/two_column/__init__.py b/polaris/tasks/ocean/two_column/__init__.py new file mode 100644 index 0000000000..f716235f64 --- /dev/null +++ b/polaris/tasks/ocean/two_column/__init__.py @@ -0,0 +1,13 @@ +from polaris.tasks.ocean.two_column.teos10 import Teos10 + + +def add_two_column_tasks(component): + """ + Add tasks for various tests involving two adjacent ocean columns + + Parameters + ---------- + component : polaris.tasks.ocean.Ocean + the ocean component that the tasks will be added to + """ + component.add_task(Teos10(component=component)) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py new file mode 100644 index 0000000000..3fa464c19e --- /dev/null +++ b/polaris/tasks/ocean/two_column/init.py @@ -0,0 +1,188 @@ +import numpy as np +import xarray as xr +from mpas_tools.io import write_netcdf +from mpas_tools.mesh.conversion import convert, cull +from mpas_tools.planar_hex import make_planar_hex_mesh + +from polaris.ocean.eos import compute_specvol +from polaris.ocean.model import OceanIOStep +from polaris.ocean.vertical import init_vertical_coord +from polaris.ocean.vertical.ztilde import pressure_from_z_tilde + + +class Init(OceanIOStep): + """ + A step for creating a mesh and initial condition for two column + test cases + """ + + def __init__(self, component, indir): + """ + Create the step + + Parameters + ---------- + component : polaris.Component + The component the step belongs to + + indir : str + The subdirectory that the task belongs to, that this step will + go into a subdirectory of + """ + super().__init__(component=component, name='init', indir=indir) + for file in [ + 'base_mesh.nc', + 'culled_mesh.nc', + 'culled_graph.info', + 'initial_state.nc', + ]: + self.add_output_file(file) + + def run(self): + """ + Run this step of the test case + """ + logger = self.logger + config = self.config + if config.get('ocean', 'model') != 'omega': + raise ValueError( + 'The two_column test case is only supported for the ' + 'Omega ocean model.' + ) + + section = config['two_column'] + resolution = section.getfloat('resolution') + nx = 2 + ny = 2 + dc = 1e3 * resolution + ds_mesh = make_planar_hex_mesh( + nx=nx, ny=ny, dc=dc, nonperiodic_x=True, nonperiodic_y=True + ) + + # cull one more row of cells so we're only left with 2 + cull_cell = ds_mesh.cullCell.values + ncells = ds_mesh.sizes['nCells'] + # remove the last 2 rows in y + cull_cell[ncells - 2 * (nx + 2) : ncells + 1] = 1 + ds_mesh['cullCell'] = xr.DataArray(data=cull_cell, dims=['nCells']) + + write_netcdf(ds_mesh, 'base_mesh.nc') + ds_mesh = cull(ds_mesh, logger=logger) + ds_mesh = convert( + ds_mesh, graphInfoFileName='culled_graph.info', logger=logger + ) + write_netcdf(ds_mesh, 'culled_mesh.nc') + + if ds_mesh.sizes['nCells'] != 2: + raise ValueError( + 'The two-column test case requires a mesh with exactly ' + f'2 cells, but the culled mesh has ' + f'{ds_mesh.sizes["nCells"]} cells.' + ) + + ssh_list = config.getexpression('two_column', 'ssh') + ssh = xr.DataArray( + data=np.array(ssh_list, dtype=np.float32), + dims=['nCells'], + ) + + ds = ds_mesh.copy() + x_cell = ds_mesh.xCell + bottom_depth = config.getfloat('vertical_grid', 'bottom_depth') + ds['bottomDepth'] = bottom_depth * xr.ones_like(x_cell) + ds['ssh'] = ssh + init_vertical_coord(config, ds) + + rho0 = config.getfloat('z_tilde', 'rho0') + + p_mid = pressure_from_z_tilde(ds.zMid, rho0=rho0) + + ncells = ds.sizes['nCells'] + nedges = ds.sizes['nEdges'] + nvertlevels = ds.sizes['nVertLevels'] + + lists = {} + for name in ['depths', 'temperatures', 'salinities']: + lists[name] = config.getexpression('two_column', name) + if not isinstance(lists[name], list): + raise ValueError( + f'The "{name}" configuration option must be a list of ' + f'lists, one per column.' + ) + if len(lists[name]) != ncells: + raise ValueError( + f'The "{name}" configuration option must have one entry ' + f'per column ({ncells} columns in the mesh).' + ) + + temperature = np.zeros((1, ncells, nvertlevels), dtype=np.float32) + salinity = np.zeros((1, ncells, nvertlevels), dtype=np.float32) + + for icell in range(ncells): + depths = np.array(lists['depths'][icell]) + temperatures = np.array(lists['temperatures'][icell]) + salinities = np.array(lists['salinities'][icell]) + z_mid = ds.zMid.isel(nCells=icell).values + + if len(depths) < 2: + raise ValueError( + 'At least two depth points are required to ' + 'define piecewise linear initial conditions.' + ) + + if len(depths) != len(temperatures) or len(depths) != len( + salinities + ): + raise ValueError( + 'The number of depth, temperature and salinity ' + 'points must be the same in each column.' + ) + + temperature[0, icell, :] = np.interp(-z_mid, -depths, temperatures) + salinity[0, icell, :] = np.interp(-z_mid, -depths, salinities) + + ds['temperature'] = xr.DataArray( + data=temperature, + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'conservative temperature', + 'units': 'degC', + }, + ) + ds['salinity'] = xr.DataArray( + data=salinity, + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'absolute salinity', + 'units': 'g kg-1', + }, + ) + + ds['PMid'] = p_mid + + spec_vol = compute_specvol( + config=config, + temperature=ds.temperature, + salinity=ds.salinity, + pressure=ds.PMid, + ) + ds['SpecVol'] = spec_vol + ds.SpecVol.attrs['long_name'] = 'specific volume' + ds.SpecVol.attrs['units'] = 'm3 kg-1' + + ds['normalVelocity'] = xr.DataArray( + data=np.zeros((1, nedges, nvertlevels), dtype=np.float32), + dims=['Time', 'nEdges', 'nVertLevels'], + attrs={ + 'long_name': 'normal velocity', + 'units': 'm s-1', + }, + ) + ds['fCell'] = xr.zeros_like(x_cell) + ds['fEdge'] = xr.zeros_like(ds_mesh.xEdge) + ds['fVertex'] = xr.zeros_like(ds_mesh.xVertex) + + ds.attrs['nx'] = nx + ds.attrs['ny'] = ny + ds.attrs['dc'] = dc + self.write_model_dataset(ds, 'initial_state.nc') diff --git a/polaris/tasks/ocean/two_column/teos10/__init__.py b/polaris/tasks/ocean/two_column/teos10/__init__.py new file mode 100644 index 0000000000..2616f1e9b1 --- /dev/null +++ b/polaris/tasks/ocean/two_column/teos10/__init__.py @@ -0,0 +1,34 @@ +import os + +from polaris import Task +from polaris.tasks.ocean.two_column.init import Init + + +class Teos10(Task): + """ + The TEOS-10 two-column test case creates the mesh and initial condition, + then computes a quasi-analytic solution to the specific volume and + geopotential. + """ + + def __init__(self, component): + """ + Create the test case + + Parameters + ---------- + component : polaris.tasks.ocean.Ocean + The ocean component that this task belongs to + """ + name = 'teos10' + subdir = os.path.join('two_column', name) + super().__init__(component=component, name=name, subdir=subdir) + + self.config.add_from_package( + 'polaris.tasks.ocean.two_column', 'two_column.cfg' + ) + self.config.add_from_package( + 'polaris.tasks.ocean.two_column.teos10', 'teos10.cfg' + ) + + self.add_step(Init(component=component, indir=self.subdir)) diff --git a/polaris/tasks/ocean/two_column/teos10/teos10.cfg b/polaris/tasks/ocean/two_column/teos10/teos10.cfg new file mode 100644 index 0000000000..4987fcd13e --- /dev/null +++ b/polaris/tasks/ocean/two_column/teos10/teos10.cfg @@ -0,0 +1,32 @@ +# Options related the ocean component +[ocean] +# Which model, MPAS-Ocean or Omega, is used +model = mpas-ocean + +# Equation of state type, defaults to mpas-ocean default +eos_type = teos-10 + + +# config options for two column testcases +[two_column] + +# resolution in km (the distance between the two columns) +resolution = 1.0 + +# sea surface height for each column +ssh = [0.0, 0.0] + +# depths, temperatures and salinities for piecewise linear initial conditions +# in each column +depths = [ + [-5.0, -55.0, -105.0, -495.0], + [-5.0, -55.0, -105.0, -495.0], + ] +temperatures = [ + [20.0, 15.0, 10.0, 5.0], + [20.0, 15.0, 10.0, 5.0], + ] +salinities = [ + [35.0, 34.0, 33.0, 32.0], + [34.0, 33.0, 32.0, 31.0], + ] diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg new file mode 100644 index 0000000000..63ab42c46b --- /dev/null +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -0,0 +1,52 @@ +# Options related to the vertical grid +[vertical_grid] + +# the type of vertical grid +grid_type = uniform + +# Number of vertical levels +vert_levels = 50 + +# Depth of the bottom of the ocean +bottom_depth = 500.0 + +# The type of vertical coordinate (e.g. z-level, z-star) +coord_type = z-star + +# Whether to use "partial" or "full", or "None" to not alter the topography +partial_cell_type = None + +# The minimum fraction of a layer for partial cells +min_pc_fraction = 0.1 + + +# Options related to Omega's pseudo-height z_tilde +[z_tilde] + +# reference density defining the pseudo-height +rho0 = 1035.0 + + +# config options for two column testcases +[two_column] + +# resolution in km (the distance between the two columns) +resolution = 1.0 + +# sea surface height for each column +ssh = [0.0, 0.0] + +# depths, temperatures and salinities for piecewise linear initial conditions +# in each column +depths = [ + [-5.0, -55.0, -105.0, -495.0], + [-5.0, -55.0, -105.0, -495.0], + ] +temperatures = [ + [20.0, 15.0, 10.0, 5.0], + [20.0, 15.0, 10.0, 5.0], + ] +salinities = [ + [35.0, 34.0, 33.0, 32.0], + [35.0, 34.0, 33.0, 32.0], + ] From 1130fc4f1f1a7ec138dcd089ab3635b36222f248 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 5 Nov 2025 05:27:46 -0600 Subject: [PATCH 03/53] Use z-tilde rather than z-star in two_column --- polaris/tasks/ocean/two_column/two_column.cfg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index 63ab42c46b..1f2cdc1381 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -10,8 +10,8 @@ vert_levels = 50 # Depth of the bottom of the ocean bottom_depth = 500.0 -# The type of vertical coordinate (e.g. z-level, z-star) -coord_type = z-star +# The type of vertical coordinate (e.g. z-level, z-star, z-tilde, sigma) +coord_type = z-tilde # Whether to use "partial" or "full", or "None" to not alter the topography partial_cell_type = None From b97962e670c6a5c0d2ce1f989dda9e9c580d686d Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 5 Nov 2025 05:28:36 -0600 Subject: [PATCH 04/53] Compute and output geometric z in two_column init --- polaris/tasks/ocean/two_column/init.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 3fa464c19e..1df1340463 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -7,7 +7,7 @@ from polaris.ocean.eos import compute_specvol from polaris.ocean.model import OceanIOStep from polaris.ocean.vertical import init_vertical_coord -from polaris.ocean.vertical.ztilde import pressure_from_z_tilde +from polaris.ocean.vertical.ztilde import pressure_from_z_tilde, z_from_z_tilde class Init(OceanIOStep): @@ -170,6 +170,23 @@ def run(self): ds.SpecVol.attrs['long_name'] = 'specific volume' ds.SpecVol.attrs['units'] = 'm3 kg-1' + z_geom_inter, z_geom_mid = z_from_z_tilde( + layer_thickness=ds.layerThickness, + bottom_depth=ds.bottomDepth, + spec_vol=ds.SpecVol, + rho0=rho0, + ) + + ds['zGeomMid'] = z_geom_mid + ds.zGeomMid.attrs['long_name'] = 'geometric height at layer midpoints' + ds.zGeomMid.attrs['units'] = 'm' + + ds['zGeomInter'] = z_geom_inter + ds.zGeomInter.attrs['long_name'] = ( + 'geometric height at layer interfaces' + ) + ds.zGeomInter.attrs['units'] = 'm' + ds['normalVelocity'] = xr.DataArray( data=np.zeros((1, nedges, nvertlevels), dtype=np.float32), dims=['Time', 'nEdges', 'nVertLevels'], From 01e0b072b221803aa597ed0450b4e96a3528f9cb Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 5 Nov 2025 07:41:22 -0600 Subject: [PATCH 05/53] Iteratively compute p and spec vol in two_column --- polaris/tasks/ocean/two_column/init.py | 68 ++++++++++++------- polaris/tasks/ocean/two_column/two_column.cfg | 7 +- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 1df1340463..a56fadf94c 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -4,10 +4,12 @@ from mpas_tools.mesh.conversion import convert, cull from mpas_tools.planar_hex import make_planar_hex_mesh -from polaris.ocean.eos import compute_specvol from polaris.ocean.model import OceanIOStep from polaris.ocean.vertical import init_vertical_coord -from polaris.ocean.vertical.ztilde import pressure_from_z_tilde, z_from_z_tilde +from polaris.ocean.vertical.ztilde import ( + pressure_and_spec_vol_from_state_at_geom_height, + z_tilde_from_pressure, +) class Init(OceanIOStep): @@ -93,10 +95,6 @@ def run(self): ds['ssh'] = ssh init_vertical_coord(config, ds) - rho0 = config.getfloat('z_tilde', 'rho0') - - p_mid = pressure_from_z_tilde(ds.zMid, rho0=rho0) - ncells = ds.sizes['nCells'] nedges = ds.sizes['nEdges'] nvertlevels = ds.sizes['nVertLevels'] @@ -158,34 +156,52 @@ def run(self): }, ) + rho0 = config.getfloat('vertical_grid', 'rho0') + eos_iter_count = config.getint('two_column', 'eos_iter_count') + + geom_layer_thickness = ds.layerThickness.copy() + surf_pressure = xr.zeros_like(geom_layer_thickness.isel(nVertLevels=0)) + + p_interface, p_mid, spec_vol = ( + pressure_and_spec_vol_from_state_at_geom_height( + config=config, + geom_layer_thickness=geom_layer_thickness, + temperature=ds.temperature, + salinity=ds.salinity, + surf_pressure=surf_pressure, + iter_count=eos_iter_count, + logger=logger, + ) + ) + + pseudo_thickness = geom_layer_thickness / (rho0 * spec_vol) + ds['PMid'] = p_mid + ds.PMid.attrs['long_name'] = 'sea pressure at layer midpoints' + ds.PMid.attrs['units'] = 'Pa' - spec_vol = compute_specvol( - config=config, - temperature=ds.temperature, - salinity=ds.salinity, - pressure=ds.PMid, - ) ds['SpecVol'] = spec_vol ds.SpecVol.attrs['long_name'] = 'specific volume' ds.SpecVol.attrs['units'] = 'm3 kg-1' - z_geom_inter, z_geom_mid = z_from_z_tilde( - layer_thickness=ds.layerThickness, - bottom_depth=ds.bottomDepth, - spec_vol=ds.SpecVol, - rho0=rho0, - ) + z_tilde_mid = z_tilde_from_pressure(p_mid, rho0) + z_tilde_interface = z_tilde_from_pressure(p_interface, rho0) - ds['zGeomMid'] = z_geom_mid - ds.zGeomMid.attrs['long_name'] = 'geometric height at layer midpoints' - ds.zGeomMid.attrs['units'] = 'm' + ds['ZTildeMid'] = z_tilde_mid + ds.ZTildeMid.attrs['long_name'] = 'pseudo-height at layer midpoints' + ds.ZTildeMid.attrs['units'] = 'm' - ds['zGeomInter'] = z_geom_inter - ds.zGeomInter.attrs['long_name'] = ( - 'geometric height at layer interfaces' - ) - ds.zGeomInter.attrs['units'] = 'm' + ds['ZTildeInter'] = z_tilde_interface + ds.ZTildeInter.attrs['long_name'] = 'pseudo-height at layer interfaces' + ds.ZTildeInter.attrs['units'] = 'm' + + ds['GeomLayerThickness'] = geom_layer_thickness + ds.GeomLayerThickness.attrs['long_name'] = 'geometric layer thickness' + ds.GeomLayerThickness.attrs['units'] = 'm' + + ds['layerThickness'] = pseudo_thickness + ds.layerThickness.attrs['long_name'] = 'pseudo-layer thickness' + ds.layerThickness.attrs['units'] = 'm' ds['normalVelocity'] = xr.DataArray( data=np.zeros((1, nedges, nvertlevels), dtype=np.float32), diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index 1f2cdc1381..82e418f285 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -19,10 +19,6 @@ partial_cell_type = None # The minimum fraction of a layer for partial cells min_pc_fraction = 0.1 - -# Options related to Omega's pseudo-height z_tilde -[z_tilde] - # reference density defining the pseudo-height rho0 = 1035.0 @@ -50,3 +46,6 @@ salinities = [ [35.0, 34.0, 33.0, 32.0], [35.0, 34.0, 33.0, 32.0], ] + +# number of iterations over which to allow the equation of state to converge +eos_iter_count = 6 From b75290b6f467c346564da7ebcca8e401f444801c Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Thu, 6 Nov 2025 12:43:22 -0600 Subject: [PATCH 06/53] Find iteratively find z-tilde of sea floor The vertical coordinate is z-tilde, but the z-tilde at the sea floor has to be found iteratively such that the geometric water-column thickness is as expected --- polaris/tasks/ocean/two_column/init.py | 274 ++++++++++++------ .../tasks/ocean/two_column/teos10/teos10.cfg | 4 +- polaris/tasks/ocean/two_column/two_column.cfg | 24 +- 3 files changed, 204 insertions(+), 98 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index a56fadf94c..88892e8c48 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -4,12 +4,13 @@ from mpas_tools.mesh.conversion import convert, cull from mpas_tools.planar_hex import make_planar_hex_mesh +from polaris.ocean.eos import compute_specvol from polaris.ocean.model import OceanIOStep -from polaris.ocean.vertical import init_vertical_coord -from polaris.ocean.vertical.ztilde import ( - pressure_and_spec_vol_from_state_at_geom_height, - z_tilde_from_pressure, +from polaris.ocean.vertical import ( + compute_zint_zmid_from_layer_thickness, + init_vertical_coord, ) +from polaris.ocean.vertical.ztilde import pressure_from_z_tilde class Init(OceanIOStep): @@ -54,6 +55,16 @@ def run(self): section = config['two_column'] resolution = section.getfloat('resolution') + assert resolution is not None, ( + 'The "resolution" configuration option must be set in the ' + '"two_column" section.' + ) + rho0 = config.getfloat('vertical_grid', 'rho0') + assert rho0 is not None, ( + 'The "rho0" configuration option must be set in the ' + '"vertical_grid" section.' + ) + nx = 2 ny = 2 dc = 1e3 * resolution @@ -82,99 +93,82 @@ def run(self): f'{ds_mesh.sizes["nCells"]} cells.' ) - ssh_list = config.getexpression('two_column', 'ssh') - ssh = xr.DataArray( + ssh_list = config.getexpression('two_column', 'geom_ssh') + geom_ssh = xr.DataArray( data=np.array(ssh_list, dtype=np.float32), dims=['nCells'], ) - ds = ds_mesh.copy() + geom_z_bot_list = config.getexpression('two_column', 'geom_z_bot') + geom_z_bot = xr.DataArray( + data=np.array(geom_z_bot_list, dtype=np.float32), + dims=['nCells'], + ) + x_cell = ds_mesh.xCell - bottom_depth = config.getfloat('vertical_grid', 'bottom_depth') - ds['bottomDepth'] = bottom_depth * xr.ones_like(x_cell) - ds['ssh'] = ssh - init_vertical_coord(config, ds) + goal_geom_water_column_thickness = geom_ssh - geom_z_bot - ncells = ds.sizes['nCells'] - nedges = ds.sizes['nEdges'] - nvertlevels = ds.sizes['nVertLevels'] + # first guess at the pseudo bottom depth is the geometric + # water column thickness + pseudo_bottom_depth = goal_geom_water_column_thickness - lists = {} - for name in ['depths', 'temperatures', 'salinities']: - lists[name] = config.getexpression('two_column', name) - if not isinstance(lists[name], list): - raise ValueError( - f'The "{name}" configuration option must be a list of ' - f'lists, one per column.' - ) - if len(lists[name]) != ncells: - raise ValueError( - f'The "{name}" configuration option must have one entry ' - f'per column ({ncells} columns in the mesh).' - ) + water_col_adjust_iter_count = config.getint( + 'two_column', 'water_col_adjust_iter_count' + ) - temperature = np.zeros((1, ncells, nvertlevels), dtype=np.float32) - salinity = np.zeros((1, ncells, nvertlevels), dtype=np.float32) + for iter in range(water_col_adjust_iter_count): + ds = self._init_z_tilde_vert_coord(ds_mesh, pseudo_bottom_depth) - for icell in range(ncells): - depths = np.array(lists['depths'][icell]) - temperatures = np.array(lists['temperatures'][icell]) - salinities = np.array(lists['salinities'][icell]) - z_mid = ds.zMid.isel(nCells=icell).values + ncells = ds.sizes['nCells'] + nvertlevels = ds.sizes['nVertLevels'] + nedges = ds.sizes['nEdges'] - if len(depths) < 2: - raise ValueError( - 'At least two depth points are required to ' - 'define piecewise linear initial conditions.' - ) + z_tilde_mid = ds.zMid - if len(depths) != len(temperatures) or len(depths) != len( - salinities - ): - raise ValueError( - 'The number of depth, temperature and salinity ' - 'points must be the same in each column.' - ) + # compute temperature, salinity, pressure and specific volume on + # z~ midpoints for this iteration + temperature, salinity, p_mid, spec_vol = ( + self._compute_t_s_spec_vol(ds, z_tilde_mid) + ) - temperature[0, icell, :] = np.interp(-z_mid, -depths, temperatures) - salinity[0, icell, :] = np.interp(-z_mid, -depths, salinities) + geom_layer_thickness = rho0 * spec_vol * ds.layerThickness - ds['temperature'] = xr.DataArray( - data=temperature, - dims=['Time', 'nCells', 'nVertLevels'], - attrs={ - 'long_name': 'conservative temperature', - 'units': 'degC', - }, - ) - ds['salinity'] = xr.DataArray( - data=salinity, - dims=['Time', 'nCells', 'nVertLevels'], - attrs={ - 'long_name': 'absolute salinity', - 'units': 'g kg-1', - }, - ) + geom_water_column_thickness = geom_layer_thickness.sum( + dim='nVertLevels' + ).isel(Time=0) - rho0 = config.getfloat('vertical_grid', 'rho0') - eos_iter_count = config.getint('two_column', 'eos_iter_count') - - geom_layer_thickness = ds.layerThickness.copy() - surf_pressure = xr.zeros_like(geom_layer_thickness.isel(nVertLevels=0)) - - p_interface, p_mid, spec_vol = ( - pressure_and_spec_vol_from_state_at_geom_height( - config=config, - geom_layer_thickness=geom_layer_thickness, - temperature=ds.temperature, - salinity=ds.salinity, - surf_pressure=surf_pressure, - iter_count=eos_iter_count, - logger=logger, + # scale the pseudo bottom depth proportional to how far off we are + # in the geometric water column thickness from the goal + scaling_factor = ( + goal_geom_water_column_thickness / geom_water_column_thickness + ) + + max_scaling_factor = scaling_factor.max().item() + min_scaling_factor = scaling_factor.min().item() + logger.info( + f'Iteration {iter}: min scaling factor = ' + f'{min_scaling_factor:.6f}, ' + f'max scaling factor = {max_scaling_factor:.6f}' + ) + + pseudo_bottom_depth = pseudo_bottom_depth * scaling_factor + + logger.info( + f'Iteration {iter}: pseudo bottom depths = ' + f'{pseudo_bottom_depth.values}' ) + + min_level_cell = ds.minLevelCell - 1 + max_level_cell = ds.maxLevelCell - 1 + geom_z_inter, geom_z_mid = compute_zint_zmid_from_layer_thickness( + layer_thickness=geom_layer_thickness, + bottom_depth=-geom_z_bot, + min_level_cell=min_level_cell, + max_level_cell=max_level_cell, ) - pseudo_thickness = geom_layer_thickness / (rho0 * spec_vol) + ds['temperature'] = temperature + ds['salinity'] = salinity ds['PMid'] = p_mid ds.PMid.attrs['long_name'] = 'sea pressure at layer midpoints' @@ -184,22 +178,28 @@ def run(self): ds.SpecVol.attrs['long_name'] = 'specific volume' ds.SpecVol.attrs['units'] = 'm3 kg-1' - z_tilde_mid = z_tilde_from_pressure(p_mid, rho0) - z_tilde_interface = z_tilde_from_pressure(p_interface, rho0) + ds['Density'] = 1.0 / spec_vol + ds.Density.attrs['long_name'] = 'density' + ds.Density.attrs['units'] = 'kg m-3' ds['ZTildeMid'] = z_tilde_mid ds.ZTildeMid.attrs['long_name'] = 'pseudo-height at layer midpoints' ds.ZTildeMid.attrs['units'] = 'm' - ds['ZTildeInter'] = z_tilde_interface - ds.ZTildeInter.attrs['long_name'] = 'pseudo-height at layer interfaces' - ds.ZTildeInter.attrs['units'] = 'm' + ds['GeomZMid'] = geom_z_mid + ds.GeomZMid.attrs['long_name'] = 'geometric height at layer midpoints' + ds.GeomZMid.attrs['units'] = 'm' + + ds['GeomZInter'] = geom_z_inter + ds.GeomZInter.attrs['long_name'] = ( + 'geometric height at layer interfaces' + ) + ds.GeomZInter.attrs['units'] = 'm' ds['GeomLayerThickness'] = geom_layer_thickness ds.GeomLayerThickness.attrs['long_name'] = 'geometric layer thickness' ds.GeomLayerThickness.attrs['units'] = 'm' - ds['layerThickness'] = pseudo_thickness ds.layerThickness.attrs['long_name'] = 'pseudo-layer thickness' ds.layerThickness.attrs['units'] = 'm' @@ -219,3 +219,105 @@ def run(self): ds.attrs['ny'] = ny ds.attrs['dc'] = dc self.write_model_dataset(ds, 'initial_state.nc') + + def _init_z_tilde_vert_coord( + self, ds_mesh: xr.Dataset, pseudo_bottom_depth: xr.DataArray + ) -> xr.Dataset: + """ + Initialize variables for a z-tilde vertical coordinate. + """ + config = self.config + + ds = ds_mesh.copy() + + ds['bottomDepth'] = pseudo_bottom_depth + # the pseudo-ssh is always zero (like the surface pressure) + ds['ssh'] = xr.zeros_like(pseudo_bottom_depth) + init_vertical_coord(config, ds) + return ds + + def _compute_t_s_spec_vol( + self, ds: xr.Dataset, z_tilde_mid: xr.DataArray + ) -> tuple[xr.DataArray, xr.DataArray, xr.DataArray, xr.DataArray]: + """ + Compute temperature, salinity, pressure and specific volume given + z-tilde + """ + + config = self.config + ncells = ds.sizes['nCells'] + nvertlevels = ds.sizes['nVertLevels'] + + rho0 = config.getfloat('vertical_grid', 'rho0') + + p_mid = pressure_from_z_tilde( + z_tilde=z_tilde_mid, + rho0=rho0, + ) + + lists = {} + for name in ['z_tilde', 'temperatures', 'salinities']: + lists[name] = config.getexpression('two_column', name) + if not isinstance(lists[name], list): + raise ValueError( + f'The "{name}" configuration option must be a list of ' + f'lists, one per column.' + ) + if len(lists[name]) != ncells: + raise ValueError( + f'The "{name}" configuration option must have one entry ' + f'per column ({ncells} columns in the mesh).' + ) + + temperature_np = np.zeros((1, ncells, nvertlevels), dtype=np.float32) + salinity_np = np.zeros((1, ncells, nvertlevels), dtype=np.float32) + + for icell in range(ncells): + z_tilde = np.array(lists['z_tilde'][icell]) + temperatures = np.array(lists['temperatures'][icell]) + salinities = np.array(lists['salinities'][icell]) + z_mid = z_tilde_mid.isel(nCells=icell).values + + if len(z_tilde) < 2: + raise ValueError( + 'At least two z_tilde points are required to ' + 'define piecewise linear initial conditions.' + ) + + if len(z_tilde) != len(temperatures) or len(z_tilde) != len( + salinities + ): + raise ValueError( + 'The number of z_tilde, temperature and salinity ' + 'points must be the same in each column.' + ) + + temperature_np[0, icell, :] = np.interp( + -z_mid, -z_tilde, temperatures + ) + salinity_np[0, icell, :] = np.interp(-z_mid, -z_tilde, salinities) + + temperature = xr.DataArray( + data=temperature_np, + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'conservative temperature', + 'units': 'degC', + }, + ) + salinity = xr.DataArray( + data=salinity_np, + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'absolute salinity', + 'units': 'g kg-1', + }, + ) + + spec_vol = compute_specvol( + config=config, + temperature=temperature, + salinity=salinity, + pressure=p_mid, + ) + return temperature, salinity, p_mid, spec_vol diff --git a/polaris/tasks/ocean/two_column/teos10/teos10.cfg b/polaris/tasks/ocean/two_column/teos10/teos10.cfg index 4987fcd13e..9e2fce25ce 100644 --- a/polaris/tasks/ocean/two_column/teos10/teos10.cfg +++ b/polaris/tasks/ocean/two_column/teos10/teos10.cfg @@ -16,9 +16,9 @@ resolution = 1.0 # sea surface height for each column ssh = [0.0, 0.0] -# depths, temperatures and salinities for piecewise linear initial conditions +# z_tilde, temperatures and salinities for piecewise linear initial conditions # in each column -depths = [ +z_tilde = [ [-5.0, -55.0, -105.0, -495.0], [-5.0, -55.0, -105.0, -495.0], ] diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index 82e418f285..1becd5f28f 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -5,10 +5,11 @@ grid_type = uniform # Number of vertical levels -vert_levels = 50 +vert_levels = 60 -# Depth of the bottom of the ocean -bottom_depth = 500.0 +# Depth of the bottom of the ocean (with some buffer compared with geometric +# sea floor height) +bottom_depth = 600.0 # The type of vertical coordinate (e.g. z-level, z-star, z-tilde, sigma) coord_type = z-tilde @@ -29,12 +30,15 @@ rho0 = 1035.0 # resolution in km (the distance between the two columns) resolution = 1.0 -# sea surface height for each column -ssh = [0.0, 0.0] +# geometric sea surface height for each column +geom_ssh = [0.0, 0.0] -# depths, temperatures and salinities for piecewise linear initial conditions -# in each column -depths = [ +# geometric sea floor height for each column +geom_z_bot = [-500.0, -500.0] + +# pseudo-height, temperatures and salinities for piecewise linear initial +# conditions in each column +z_tilde = [ [-5.0, -55.0, -105.0, -495.0], [-5.0, -55.0, -105.0, -495.0], ] @@ -47,5 +51,5 @@ salinities = [ [35.0, 34.0, 33.0, 32.0], ] -# number of iterations over which to allow the equation of state to converge -eos_iter_count = 6 +# number of iterations over which to allow water column adjustments +water_col_adjust_iter_count = 6 From 7dcf082f98b0c9377593bc0f1b47ad2c0a5893c1 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 10 Nov 2025 15:37:20 +0100 Subject: [PATCH 07/53] Add a function for computing geometric height by quadrature --- polaris/ocean/hydrostatic/__init__.py | 0 polaris/ocean/hydrostatic/teos10.py | 364 ++++++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 polaris/ocean/hydrostatic/__init__.py create mode 100644 polaris/ocean/hydrostatic/teos10.py diff --git a/polaris/ocean/hydrostatic/__init__.py b/polaris/ocean/hydrostatic/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/polaris/ocean/hydrostatic/teos10.py b/polaris/ocean/hydrostatic/teos10.py new file mode 100644 index 0000000000..b6e410d415 --- /dev/null +++ b/polaris/ocean/hydrostatic/teos10.py @@ -0,0 +1,364 @@ +r""" +Hydrostatic integration utilities for the Omega ocean model. + +This module currently provides functionality for converting from the +Omega pseudo-height coordinate :math:`\tilde z` (``z_tilde``) to true +geometric height ``z`` by numerically integrating the hydrostatic +relation + +.. math:: + + \frac{\partial z}{\partial \tilde z} = \rho_0\,\nu( S_A, \Theta, p ) + +where :math:`\nu` is the specific volume (``spec_vol``) computed from the +TEOS-10 equation of state, :math:`S_A` is Absolute Salinity, :math:`\Theta` +is Conservative Temperature, :math:`p` is sea pressure (positive downward), +and :math:`\rho_0` is a reference density used in the definition of +``z_tilde = - p / (\rho_0 g)`. The conversion therefore requires an +integral of the form + +.. math:: + + z(\tilde z) = z_b + \int_{\tilde z_b}^{\tilde z} + \rho_0\,\nu\big(S_A(\tilde z'),\Theta(\tilde z'),p(\tilde z')\big)\; + d\tilde z' , + +with :math:`z_b = -\text{bottom\_depth}` at the pseudo-height +``z_tilde_b`` at the seafloor, typically the minimum (most negative) value +of the pseudo-height domain for a given water column. + +The primary entry point is :func:`integrate_geometric_height`. +""" + +from __future__ import annotations + +from typing import Callable, Literal, Sequence + +import gsw +import numpy as np +from mpas_tools.cime.constants import constants + +__all__ = [ + 'integrate_geometric_height', +] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def integrate_geometric_height( + z_tilde_interfaces: Sequence[float] | np.ndarray, + z_tilde_nodes: Sequence[float] | np.ndarray, + sa_nodes: Sequence[float] | np.ndarray, + ct_nodes: Sequence[float] | np.ndarray, + bottom_depth: float, + rho0: float, + method: Literal[ + 'midpoint', 'trapezoid', 'simpson', 'gauss2', 'gauss4', 'adaptive' + ] = 'gauss4', + subdivisions: int = 2, + rel_tol: float = 5e-8, + abs_tol: float = 5e-5, + max_recurs_depth: int = 12, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Integrate the hydrostatic relation to obtain geometric height. + + Integrates upward from the seafloor using + ``dz/dz_tilde = rho0 * spec_vol(SA, CT, p)`` to obtain geometric + heights ``z`` at requested pseudo-heights ``z_tilde_interfaces``. + + The salinity and temperature profiles are supplied as *piecewise + linear* functions of pseudo-height via collocation nodes and their + values. Outside the node range, profiles are held constant at the + end values (``numpy.interp`` behavior), permitting targets to extend + above or below the collocation range. + + Methods + ------- + The integral over each interface interval can be evaluated with one of + the following schemes (set with ``method``): + + - 'midpoint': composite midpoint rule with ``subdivisions`` panels. + - 'trapezoid': composite trapezoidal rule with ``subdivisions`` panels. + - 'simpson': composite Simpson's rule; requires an even number of + panels. If ``subdivisions`` is odd, one is added internally. + - 'gauss2': 2-point Gauss-Legendre per panel (higher accuracy than + midpoint/trapezoid at similar cost). + - 'gauss4': 4-point Gauss-Legendre per panel (default; high accuracy + for smooth integrands). + - 'adaptive': adaptive recursive Simpson integration controlled by + ``rel_tol``, ``abs_tol`` and ``max_depth``. + + Parameters + ---------- + z_tilde_interfaces : sequence of float + Monotonic non-increasing layer-interface pseudo-heights ordered + from sea surface to seafloor. The first value corresponds to the + sea surface (typically near 0) and the last to the seafloor + (most negative). Values may extend outside the node range. + z_tilde_nodes : sequence of float + Strictly increasing collocation nodes for SA and CT. + sa_nodes, ct_nodes : sequence of float + Absolute Salinity (g/kg) and Conservative Temperature (degC) at + ``z_tilde_nodes``. + bottom_depth : float + Positive depth (m); geometric height at seafloor is ``-bottom_depth``. + rho0 : float + Reference density used in the pseudo-height definition. + method : str, optional + Quadrature method ('midpoint','trapezoid','simpson','gauss2', + 'gauss4','adaptive'). Default 'gauss4'. + subdivisions : int, optional + Subdivisions per interval for fixed-step methods (>=1). Ignored + for 'adaptive'. + rel_tol, abs_tol : float, optional + Relative/absolute tolerances for adaptive Simpson. + max_recurs_depth : int, optional + Max recursion depth for adaptive Simpson. + + Returns + ------- + z : ndarray + Geometric heights at ``z_tilde_interfaces``. + spec_vol : ndarray + Specific volume at targets. + ct : ndarray + Conservative temperature at targets. + sa : ndarray + Absolute salinity at targets. + """ + + z_tilde_interfaces = np.asarray(z_tilde_interfaces, dtype=float) + z_tilde_nodes = np.asarray(z_tilde_nodes, dtype=float) + sa_nodes = np.asarray(sa_nodes, dtype=float) + ct_nodes = np.asarray(ct_nodes, dtype=float) + + if not ( + z_tilde_nodes.ndim == sa_nodes.ndim == ct_nodes.ndim == 1 + and z_tilde_interfaces.ndim == 1 + ): + raise ValueError('All inputs must be one-dimensional.') + if len(z_tilde_nodes) != len(sa_nodes) or len(z_tilde_nodes) != len( + ct_nodes + ): + raise ValueError( + 'Lengths of z_tilde_nodes, sa_nodes, ct_nodes differ.' + ) + if len(z_tilde_nodes) < 2: + raise ValueError('Need at least two collocation nodes.') + if not np.all(np.diff(z_tilde_nodes) > 0): + raise ValueError('z_tilde_nodes must be strictly increasing.') + if not np.all(np.diff(z_tilde_interfaces) <= 0): + raise ValueError('z_tilde_interfaces must be non-increasing.') + if subdivisions < 1: + raise ValueError('subdivisions must be >= 1.') + + g = constants['SHR_CONST_G'] + + def spec_vol_ct_sa_at( + z_tilde: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + sa = np.interp(z_tilde, z_tilde_nodes, sa_nodes) + ct = np.interp(z_tilde, z_tilde_nodes, ct_nodes) + p_pa = -rho0 * g * z_tilde + # gsw expects pressure in dbar + spec_vol = gsw.specvol(sa, ct, p_pa * 1.0e-4) + return spec_vol, ct, sa + + def integrand(z_tilde: np.ndarray) -> np.ndarray: + spec_vol, _, _ = spec_vol_ct_sa_at(z_tilde) + return rho0 * spec_vol + + # fill interface heights: anchor bottom, integrate upward (reverse) + n_interfaces = len(z_tilde_interfaces) + if n_interfaces < 2: + raise ValueError('Need at least two interfaces (surface and bottom).') + z = np.empty_like(z_tilde_interfaces) + z[-1] = -bottom_depth + for i in range(n_interfaces - 1, 0, -1): + a = z_tilde_interfaces[i - 1] # shallower + b = z_tilde_interfaces[i] # deeper + if a == b: + z[i - 1] = z[i] + continue + if method == 'adaptive': + inc = _adaptive_simpson( + integrand, a, b, rel_tol, abs_tol, max_recurs_depth + ) + else: + nsub = subdivisions + if method == 'simpson' and nsub % 2 == 1: + nsub += 1 + inc = _fixed_quadrature(integrand, a, b, nsub, method) + z[i - 1] = z[i] - inc + + spec_vol, ct, sa = spec_vol_ct_sa_at(z_tilde_interfaces) + return z, spec_vol, ct, sa + + +# --------------------------------------------------------------------------- +# Helper functions (non-public) +# --------------------------------------------------------------------------- + + +def _fixed_quadrature( + integrand: Callable[[np.ndarray], np.ndarray], + a: float, + b: float, + nsub: int, + method: str, +) -> float: + """Composite fixed-step quadrature over [a,b].""" + h = (b - a) / nsub + total = 0.0 + if method == 'midpoint': + mids = a + (np.arange(nsub) + 0.5) * h + total = np.sum(integrand(mids)) * h + elif method == 'trapezoid': + x = a + np.arange(nsub + 1) * h + fx = integrand(x) + total = h * (0.5 * fx[0] + fx[1:-1].sum() + 0.5 * fx[-1]) + elif method == 'simpson': + if nsub % 2 != 0: + raise ValueError('Simpson requires even nsub.') + x = a + np.arange(nsub + 1) * h + fx = integrand(x) + total = ( + h + / 3.0 + * ( + fx[0] + + fx[-1] + + 4.0 * fx[1:-1:2].sum() + + 2.0 * fx[2:-2:2].sum() + ) + ) + elif method in {'gauss2', 'gauss4'}: + total = _gauss_composite(integrand, a, b, nsub, method) + else: # pragma: no cover - defensive + raise ValueError(f'Unknown quadrature method: {method}') + return float(total) + + +def _gauss_composite( + integrand: Callable[[np.ndarray], np.ndarray], + a: float, + b: float, + nsub: int, + method: str, +) -> float: + """Composite Gauss-Legendre quadrature (2- or 4-point).""" + h = (b - a) / nsub + total = 0.0 + if method == 'gauss2': + xi = np.array([-1.0 / np.sqrt(3.0), 1.0 / np.sqrt(3.0)]) + wi = np.array([1.0, 1.0]) + else: # gauss4 + xi = np.array( + [ + -0.8611363115940526, + -0.3399810435848563, + 0.3399810435848563, + 0.8611363115940526, + ] + ) + wi = np.array( + [ + 0.34785484513745385, + 0.6521451548625461, + 0.6521451548625461, + 0.34785484513745385, + ] + ) + for k in range(nsub): + a_k = a + k * h + b_k = a_k + h + mid = 0.5 * (a_k + b_k) + half = 0.5 * h + xk = mid + half * xi + fx = integrand(xk) + total += half * np.sum(wi * fx) + return float(total) + + +def _adaptive_simpson( + integrand: Callable[[np.ndarray], np.ndarray], + a: float, + b: float, + rel_tol: float, + abs_tol: float, + max_depth: int, +) -> float: + """Adaptive Simpson integration over [a,b].""" + fa = integrand(np.array([a]))[0] + fb = integrand(np.array([b]))[0] + m = 0.5 * (a + b) + fm = integrand(np.array([m]))[0] + whole = _simpson_basic(fa, fm, fb, a, b) + return _adaptive_simpson_recursive( + integrand, a, b, fa, fm, fb, whole, rel_tol, abs_tol, max_depth, 0 + ) + + +def _simpson_basic( + fa: float, fm: float, fb: float, a: float, b: float +) -> float: + """Single Simpson panel.""" + return (b - a) / 6.0 * (fa + 4.0 * fm + fb) + + +def _adaptive_simpson_recursive( + integrand: Callable[[np.ndarray], np.ndarray], + a: float, + b: float, + fa: float, + fm: float, + fb: float, + whole: float, + rel_tol: float, + abs_tol: float, + max_depth: int, + depth: int, +) -> float: + m = 0.5 * (a + b) + lm = 0.5 * (a + m) + rm = 0.5 * (m + b) + flm = integrand(np.array([lm]))[0] + frm = integrand(np.array([rm]))[0] + left = _simpson_basic(fa, flm, fm, a, m) + right = _simpson_basic(fm, frm, fb, m, b) + S2 = left + right + err = S2 - whole + tol = max(abs_tol, rel_tol * max(abs(S2), 1e-15)) + if depth >= max_depth: + return S2 + if abs(err) < 15.0 * tol: + return S2 + err / 15.0 # Richardson extrapolation + return _adaptive_simpson_recursive( + integrand, + a, + m, + fa, + flm, + fm, + left, + rel_tol, + abs_tol, + max_depth, + depth + 1, + ) + _adaptive_simpson_recursive( + integrand, + m, + b, + fm, + frm, + fb, + right, + rel_tol, + abs_tol, + max_depth, + depth + 1, + ) From cb17a8c21b1f022b85e66de06334f3c33cb2991a Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 10 Nov 2025 15:37:55 +0100 Subject: [PATCH 08/53] Compute geometric height in two-column test by quadrature --- polaris/tasks/ocean/two_column/init.py | 211 +++++++++++++++++-------- 1 file changed, 144 insertions(+), 67 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 88892e8c48..05380a7243 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -5,11 +5,9 @@ from mpas_tools.planar_hex import make_planar_hex_mesh from polaris.ocean.eos import compute_specvol +from polaris.ocean.hydrostatic.teos10 import integrate_geometric_height from polaris.ocean.model import OceanIOStep -from polaris.ocean.vertical import ( - compute_zint_zmid_from_layer_thickness, - init_vertical_coord, -) +from polaris.ocean.vertical import init_vertical_coord from polaris.ocean.vertical.ztilde import pressure_from_z_tilde @@ -119,23 +117,17 @@ def run(self): for iter in range(water_col_adjust_iter_count): ds = self._init_z_tilde_vert_coord(ds_mesh, pseudo_bottom_depth) - ncells = ds.sizes['nCells'] - nvertlevels = ds.sizes['nVertLevels'] - nedges = ds.sizes['nEdges'] - - z_tilde_mid = ds.zMid - - # compute temperature, salinity, pressure and specific volume on - # z~ midpoints for this iteration - temperature, salinity, p_mid, spec_vol = ( - self._compute_t_s_spec_vol(ds, z_tilde_mid) + z, spec_vol, ct, sa = self._compute_z_t_s_spec_vol_quadrature( + ds=ds, + geom_z_bot=geom_z_bot, ) - geom_layer_thickness = rho0 * spec_vol * ds.layerThickness + geom_z_inter = z[:, 0::2] + geom_z_mid = z[:, 1::2] - geom_water_column_thickness = geom_layer_thickness.sum( - dim='nVertLevels' - ).isel(Time=0) + geom_water_column_thickness = ( + geom_z_inter[:, -1] - geom_z_inter[:, 0] + ) # scale the pseudo bottom depth proportional to how far off we are # in the geometric water column thickness from the goal @@ -158,31 +150,36 @@ def run(self): f'{pseudo_bottom_depth.values}' ) - min_level_cell = ds.minLevelCell - 1 - max_level_cell = ds.maxLevelCell - 1 - geom_z_inter, geom_z_mid = compute_zint_zmid_from_layer_thickness( - layer_thickness=geom_layer_thickness, - bottom_depth=-geom_z_bot, - min_level_cell=min_level_cell, - max_level_cell=max_level_cell, + ds['temperature'] = xr.DataArray( + data=ct[np.newaxis, :, 1::2], + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'conservative temperature', + 'units': 'degC', + }, + ) + ds['salinity'] = xr.DataArray( + data=sa[np.newaxis, :, 1::2], + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'salinity', + 'units': 'g kg-1', + }, ) - ds['temperature'] = temperature - ds['salinity'] = salinity - - ds['PMid'] = p_mid - ds.PMid.attrs['long_name'] = 'sea pressure at layer midpoints' - ds.PMid.attrs['units'] = 'Pa' - - ds['SpecVol'] = spec_vol - ds.SpecVol.attrs['long_name'] = 'specific volume' - ds.SpecVol.attrs['units'] = 'm3 kg-1' - - ds['Density'] = 1.0 / spec_vol + ds['SpecVol'] = xr.DataArray( + data=spec_vol[np.newaxis, :, 1::2], + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'specific volume', + 'units': 'm3 kg-1', + }, + ) + ds['Density'] = 1.0 / ds['SpecVol'] ds.Density.attrs['long_name'] = 'density' ds.Density.attrs['units'] = 'kg m-3' - ds['ZTildeMid'] = z_tilde_mid + ds['ZTildeMid'] = ds.zMid ds.ZTildeMid.attrs['long_name'] = 'pseudo-height at layer midpoints' ds.ZTildeMid.attrs['units'] = 'm' @@ -196,13 +193,12 @@ def run(self): ) ds.GeomZInter.attrs['units'] = 'm' - ds['GeomLayerThickness'] = geom_layer_thickness - ds.GeomLayerThickness.attrs['long_name'] = 'geometric layer thickness' - ds.GeomLayerThickness.attrs['units'] = 'm' - ds.layerThickness.attrs['long_name'] = 'pseudo-layer thickness' ds.layerThickness.attrs['units'] = 'm' + nedges = ds_mesh.sizes['nEdges'] + nvertlevels = ds.sizes['nVertLevels'] + ds['normalVelocity'] = xr.DataArray( data=np.zeros((1, nedges, nvertlevels), dtype=np.float32), dims=['Time', 'nEdges', 'nVertLevels'], @@ -236,6 +232,91 @@ def _init_z_tilde_vert_coord( init_vertical_coord(config, ds) return ds + def _get_z_tilde_t_s_nodes( + self, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Get the z-tilde, temperature and salinity node values from the + configuration. + """ + + config = self.config + + lists = {} + for name in ['z_tilde', 'temperatures', 'salinities']: + lists[name] = config.getexpression('two_column', name) + if not isinstance(lists[name], list): + raise ValueError( + f'The "{name}" configuration option must be a list of ' + f'lists, one per column.' + ) + + z_tilde_node = np.array(lists['z_tilde'], dtype=np.float32) + t_node = np.array(lists['temperatures'], dtype=np.float32) + s_node = np.array(lists['salinities'], dtype=np.float32) + + if ( + z_tilde_node.shape != t_node.shape + or z_tilde_node.shape != s_node.shape + ): + raise ValueError( + 'The number of z_tilde, temperature and salinity ' + 'points must be the same in each column.' + ) + + return z_tilde_node, t_node, s_node + + def _compute_z_t_s_spec_vol_quadrature( + self, + ds: xr.Dataset, + geom_z_bot: xr.DataArray, + ): + """ + Compute temperature, salinity, pressure and specific volume at + quadrature points (layer interfaces and layer midpoints) given z-tilde + """ + config = self.config + rho0 = config.getfloat('vertical_grid', 'rho0') + ncells = ds.sizes['nCells'] + nvertlevels = ds.sizes['nVertLevels'] + z_tilde_inter = ds.zInterface + z_tilde_mid = ds.zMid + z_tilde_node, t_node, s_node = self._get_z_tilde_t_s_nodes() + + z = np.nan * np.ones((ncells, 2 * nvertlevels + 1), dtype=np.float32) + spec_vol = np.nan * np.ones( + (ncells, 2 * nvertlevels), dtype=np.float32 + ) + ct = np.nan * np.ones((ncells, 2 * nvertlevels), dtype=np.float32) + sa = np.nan * np.ones((ncells, 2 * nvertlevels), dtype=np.float32) + + for icell in range(ncells): + min_level_cell = ds.minLevelCell.isel(nCells=icell).item() - 1 + max_level_cell = ds.maxLevelCell.isel(nCells=icell).item() - 1 + + # both interfaces and midpoints + z_tilde = np.zeros(2 * nvertlevels + 1, dtype=np.float32) + z_tilde[0::2] = z_tilde_inter.isel(nCells=icell).values + z_tilde[1::2] = z_tilde_mid.isel(nCells=icell).values + indices = np.arange(2 * min_level_cell, 2 * max_level_cell + 2) + + z_loc, spec_vol_loc, ct_loc, sa_loc = integrate_geometric_height( + z_tilde_interfaces=z_tilde[indices], + z_tilde_nodes=z_tilde_node[icell, :], + sa_nodes=s_node[icell, :], + ct_nodes=t_node[icell, :], + bottom_depth=-geom_z_bot.isel(nCells=icell).items(), + rho0=rho0, + method='gauss4', + subdivisions=2, + ) + z[icell, indices] = z_loc + spec_vol[icell, indices] = spec_vol_loc + ct[icell, indices] = ct_loc + sa[icell, indices] = sa_loc + + return z, spec_vol, ct, sa + def _compute_t_s_spec_vol( self, ds: xr.Dataset, z_tilde_mid: xr.DataArray ) -> tuple[xr.DataArray, xr.DataArray, xr.DataArray, xr.DataArray]: @@ -244,6 +325,8 @@ def _compute_t_s_spec_vol( z-tilde """ + z_tilde_node, t_node, s_node = self._get_z_tilde_t_s_nodes() + config = self.config ncells = ds.sizes['nCells'] nvertlevels = ds.sizes['nVertLevels'] @@ -255,27 +338,29 @@ def _compute_t_s_spec_vol( rho0=rho0, ) - lists = {} - for name in ['z_tilde', 'temperatures', 'salinities']: - lists[name] = config.getexpression('two_column', name) - if not isinstance(lists[name], list): - raise ValueError( - f'The "{name}" configuration option must be a list of ' - f'lists, one per column.' - ) - if len(lists[name]) != ncells: - raise ValueError( - f'The "{name}" configuration option must have one entry ' - f'per column ({ncells} columns in the mesh).' - ) - temperature_np = np.zeros((1, ncells, nvertlevels), dtype=np.float32) salinity_np = np.zeros((1, ncells, nvertlevels), dtype=np.float32) + if z_tilde_node.shape[0] != ncells: + raise ValueError( + 'The number of z_tilde columns provided must match the ' + 'number of mesh columns.' + ) + if t_node.shape[0] != ncells: + raise ValueError( + 'The number of temperature columns provided must match the ' + 'number of mesh columns.' + ) + if s_node.shape[0] != ncells: + raise ValueError( + 'The number of salinity columns provided must match the ' + 'number of mesh columns.' + ) + for icell in range(ncells): - z_tilde = np.array(lists['z_tilde'][icell]) - temperatures = np.array(lists['temperatures'][icell]) - salinities = np.array(lists['salinities'][icell]) + z_tilde = z_tilde_node[icell, :] + temperatures = t_node[icell, :] + salinities = s_node[icell, :] z_mid = z_tilde_mid.isel(nCells=icell).values if len(z_tilde) < 2: @@ -284,14 +369,6 @@ def _compute_t_s_spec_vol( 'define piecewise linear initial conditions.' ) - if len(z_tilde) != len(temperatures) or len(z_tilde) != len( - salinities - ): - raise ValueError( - 'The number of z_tilde, temperature and salinity ' - 'points must be the same in each column.' - ) - temperature_np[0, icell, :] = np.interp( -z_mid, -z_tilde, temperatures ) From 351faf14ecb947cc689d9e67ddfa0fc489f81488 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Sun, 7 Dec 2025 10:16:22 +0100 Subject: [PATCH 09/53] Fix sign for z_tilde_nodes in integrate_geometric_height() --- polaris/ocean/hydrostatic/teos10.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/polaris/ocean/hydrostatic/teos10.py b/polaris/ocean/hydrostatic/teos10.py index b6e410d415..4cb3a601cd 100644 --- a/polaris/ocean/hydrostatic/teos10.py +++ b/polaris/ocean/hydrostatic/teos10.py @@ -149,8 +149,8 @@ def integrate_geometric_height( ) if len(z_tilde_nodes) < 2: raise ValueError('Need at least two collocation nodes.') - if not np.all(np.diff(z_tilde_nodes) > 0): - raise ValueError('z_tilde_nodes must be strictly increasing.') + if not np.all(np.diff(z_tilde_nodes) <= 0): + raise ValueError('z_tilde_nodes must be strictly non-increasing.') if not np.all(np.diff(z_tilde_interfaces) <= 0): raise ValueError('z_tilde_interfaces must be non-increasing.') if subdivisions < 1: @@ -161,8 +161,11 @@ def integrate_geometric_height( def spec_vol_ct_sa_at( z_tilde: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - sa = np.interp(z_tilde, z_tilde_nodes, sa_nodes) - ct = np.interp(z_tilde, z_tilde_nodes, ct_nodes) + # np.interp requires xp to be increasing; our + # z_tilde_nodes are decreasing, so flip the signs of + # z_tilde and z_tilde_nodes for the interpolation + sa = np.interp(-z_tilde, -z_tilde_nodes, sa_nodes) + ct = np.interp(-z_tilde, -z_tilde_nodes, ct_nodes) p_pa = -rho0 * g * z_tilde # gsw expects pressure in dbar spec_vol = gsw.specvol(sa, ct, p_pa * 1.0e-4) From 99494545d01d2f2a53b295cfed0652100cfa3854 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Fri, 19 Dec 2025 15:23:20 +0100 Subject: [PATCH 10/53] Switch to reference step and sequence of init steps The init steps support a sequence of resolutions for a convergence test. --- polaris/tasks/ocean/two_column/column.py | 57 +++ polaris/tasks/ocean/two_column/init.py | 232 +++++----- polaris/tasks/ocean/two_column/reference.py | 400 ++++++++++++++++++ .../tasks/ocean/two_column/teos10/__init__.py | 34 +- .../tasks/ocean/two_column/teos10/teos10.cfg | 25 +- polaris/tasks/ocean/two_column/two_column.cfg | 50 ++- 6 files changed, 634 insertions(+), 164 deletions(-) create mode 100644 polaris/tasks/ocean/two_column/column.py create mode 100644 polaris/tasks/ocean/two_column/reference.py diff --git a/polaris/tasks/ocean/two_column/column.py b/polaris/tasks/ocean/two_column/column.py new file mode 100644 index 0000000000..8bc45754fc --- /dev/null +++ b/polaris/tasks/ocean/two_column/column.py @@ -0,0 +1,57 @@ +import numpy as np + +from polaris.config import PolarisConfigParser + + +def get_array_from_mid_grad( + config: PolarisConfigParser, name: str, x: np.ndarray +) -> np.ndarray: + """ + Get an array at a given set of horizontal points based on values defined + at the midpoint (x=0) and their constant gradient with respect to x. + + Parameters + ---------- + config : PolarisConfigParser + The configuration parser containing the options "{name}_mid" and + "{name}_grad" in the "two_column" section. + x : np.ndarray + The x-coordinates at which to evaluate the array + name : str + The base name of the configuration options + + Returns + ------- + array : np.ndarray + The array evaluated at the given x-coordinates + """ + section = config['two_column'] + mid = section.getnumpy(f'{name}_mid') + grad = section.getnumpy(f'{name}_grad') + + assert mid is not None, ( + f'The "{name}_mid" configuration option must be set in the ' + '"two_column" section.' + ) + assert grad is not None, ( + f'The "{name}_grad" configuration option must be set in the ' + '"two_column" section.' + ) + + if isinstance(mid, (list, tuple, np.ndarray)): + col_count = len(x) + node_count = len(mid) + + array = np.zeros((col_count, node_count), dtype=float) + + for i in range(col_count): + array[i, :] = np.array(mid) + x[i] * np.array(grad) + elif np.isscalar(mid): + array = mid + x * grad + else: + raise ValueError( + f'The "{name}_mid" configuration option must be a scalar or a ' + 'list, tuple or numpy.ndarray.' + ) + + return array diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 05380a7243..6b66c504c1 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -5,19 +5,28 @@ from mpas_tools.planar_hex import make_planar_hex_mesh from polaris.ocean.eos import compute_specvol -from polaris.ocean.hydrostatic.teos10 import integrate_geometric_height from polaris.ocean.model import OceanIOStep from polaris.ocean.vertical import init_vertical_coord -from polaris.ocean.vertical.ztilde import pressure_from_z_tilde +from polaris.ocean.vertical.ztilde import ( + geom_height_from_pseudo_height, + pressure_from_z_tilde, +) +from polaris.resolution import resolution_to_string +from polaris.tasks.ocean.two_column.column import get_array_from_mid_grad class Init(OceanIOStep): """ A step for creating a mesh and initial condition for two column test cases + + Attributes + ---------- + resolution : float + The horizontal resolution in km """ - def __init__(self, component, indir): + def __init__(self, component, resolution, indir): """ Create the step @@ -26,11 +35,16 @@ def __init__(self, component, indir): component : polaris.Component The component the step belongs to + resolution : float + The horizontal resolution in km + indir : str The subdirectory that the task belongs to, that this step will go into a subdirectory of """ - super().__init__(component=component, name='init', indir=indir) + self.resolution = resolution + name = f'init_{resolution_to_string(resolution)}' + super().__init__(component=component, name=name, indir=indir) for file in [ 'base_mesh.nc', 'culled_mesh.nc', @@ -51,12 +65,7 @@ def run(self): 'Omega ocean model.' ) - section = config['two_column'] - resolution = section.getfloat('resolution') - assert resolution is not None, ( - 'The "resolution" configuration option must be set in the ' - '"two_column" section.' - ) + resolution = self.resolution rho0 = config.getfloat('vertical_grid', 'rho0') assert rho0 is not None, ( 'The "rho0" configuration option must be set in the ' @@ -84,26 +93,17 @@ def run(self): ) write_netcdf(ds_mesh, 'culled_mesh.nc') - if ds_mesh.sizes['nCells'] != 2: + ncells = ds_mesh.sizes['nCells'] + if ncells != 2: raise ValueError( 'The two-column test case requires a mesh with exactly ' f'2 cells, but the culled mesh has ' - f'{ds_mesh.sizes["nCells"]} cells.' + f'{ncells} cells.' ) - ssh_list = config.getexpression('two_column', 'geom_ssh') - geom_ssh = xr.DataArray( - data=np.array(ssh_list, dtype=np.float32), - dims=['nCells'], - ) - - geom_z_bot_list = config.getexpression('two_column', 'geom_z_bot') - geom_z_bot = xr.DataArray( - data=np.array(geom_z_bot_list, dtype=np.float32), - dims=['nCells'], - ) + x = resolution * np.array([-0.5, 0.5], dtype=float) + geom_ssh, geom_z_bot = self._get_geom_ssh_z_bot(x) - x_cell = ds_mesh.xCell goal_geom_water_column_thickness = geom_ssh - geom_z_bot # first guess at the pseudo bottom depth is the geometric @@ -114,21 +114,54 @@ def run(self): 'two_column', 'water_col_adjust_iter_count' ) + if water_col_adjust_iter_count is None: + raise ValueError( + 'The "water_col_adjust_iter_count" configuration option ' + 'must be set in the "two_column" section.' + ) + for iter in range(water_col_adjust_iter_count): ds = self._init_z_tilde_vert_coord(ds_mesh, pseudo_bottom_depth) - z, spec_vol, ct, sa = self._compute_z_t_s_spec_vol_quadrature( + z_tilde_mid = ds.zMid + h_tilde = ds.layerThickness + + ct, sa = self._interpolate_t_s( ds=ds, - geom_z_bot=geom_z_bot, + z_tilde_mid=z_tilde_mid, + x=x, + rho0=rho0, + ) + p_mid = pressure_from_z_tilde( + z_tilde=z_tilde_mid, + rho0=rho0, ) - geom_z_inter = z[:, 0::2] - geom_z_mid = z[:, 1::2] + spec_vol = compute_specvol( + config=config, + temperature=ct, + salinity=sa, + pressure=p_mid, + ) - geom_water_column_thickness = ( - geom_z_inter[:, -1] - geom_z_inter[:, 0] + geom_z_inter, geom_z_mid = geom_height_from_pseudo_height( + geom_z_bot=geom_z_bot, + h_tilde=h_tilde, + spec_vol=spec_vol, + rho0=rho0, ) + min_level_cell = ds.minLevelCell.values - 1 + max_level_cell = ds.maxLevelCell.values - 1 + + geom_water_column_thickness = np.zeros(ncells) + + for icell in range(ncells): + geom_water_column_thickness[icell] = ( + geom_z_inter[icell, min_level_cell[icell]] + - geom_z_inter[icell, max_level_cell[icell] + 1] + ) + # scale the pseudo bottom depth proportional to how far off we are # in the geometric water column thickness from the goal scaling_factor = ( @@ -183,15 +216,23 @@ def run(self): ds.ZTildeMid.attrs['long_name'] = 'pseudo-height at layer midpoints' ds.ZTildeMid.attrs['units'] = 'm' - ds['GeomZMid'] = geom_z_mid - ds.GeomZMid.attrs['long_name'] = 'geometric height at layer midpoints' - ds.GeomZMid.attrs['units'] = 'm' + ds['GeomZMid'] = xr.DataArray( + data=geom_z_mid[np.newaxis, :, :], + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'geometric height at layer midpoints', + 'units': 'm', + }, + ) - ds['GeomZInter'] = geom_z_inter - ds.GeomZInter.attrs['long_name'] = ( - 'geometric height at layer interfaces' + ds['GeomZInter'] = xr.DataArray( + data=geom_z_inter[np.newaxis, :, :], + dims=['Time', 'nCells', 'nVertLevelsP1'], + attrs={ + 'long_name': 'geometric height at layer interfaces', + 'units': 'm', + }, ) - ds.GeomZInter.attrs['units'] = 'm' ds.layerThickness.attrs['long_name'] = 'pseudo-layer thickness' ds.layerThickness.attrs['units'] = 'm' @@ -200,14 +241,14 @@ def run(self): nvertlevels = ds.sizes['nVertLevels'] ds['normalVelocity'] = xr.DataArray( - data=np.zeros((1, nedges, nvertlevels), dtype=np.float32), + data=np.zeros((1, nedges, nvertlevels), dtype=float), dims=['Time', 'nEdges', 'nVertLevels'], attrs={ 'long_name': 'normal velocity', 'units': 'm s-1', }, ) - ds['fCell'] = xr.zeros_like(x_cell) + ds['fCell'] = xr.zeros_like(ds_mesh.xCell) ds['fEdge'] = xr.zeros_like(ds_mesh.xEdge) ds['fVertex'] = xr.zeros_like(ds_mesh.xVertex) @@ -216,6 +257,21 @@ def run(self): ds.attrs['dc'] = dc self.write_model_dataset(ds, 'initial_state.nc') + def _get_geom_ssh_z_bot( + self, x: np.ndarray + ) -> tuple[xr.DataArray, xr.DataArray]: + """ + Get the geometric sea surface height and sea floor height for each + column from the configuration. + """ + config = self.config + geom_ssh = get_array_from_mid_grad(config, 'geom_ssh', x) + geom_z_bot = get_array_from_mid_grad(config, 'geom_z_bot', x) + return ( + xr.DataArray(data=geom_ssh, dims=['nCells']), + xr.DataArray(data=geom_z_bot, dims=['nCells']), + ) + def _init_z_tilde_vert_coord( self, ds_mesh: xr.Dataset, pseudo_bottom_depth: xr.DataArray ) -> xr.Dataset: @@ -233,27 +289,16 @@ def _init_z_tilde_vert_coord( return ds def _get_z_tilde_t_s_nodes( - self, + self, x: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Get the z-tilde, temperature and salinity node values from the configuration. """ - config = self.config - - lists = {} - for name in ['z_tilde', 'temperatures', 'salinities']: - lists[name] = config.getexpression('two_column', name) - if not isinstance(lists[name], list): - raise ValueError( - f'The "{name}" configuration option must be a list of ' - f'lists, one per column.' - ) - - z_tilde_node = np.array(lists['z_tilde'], dtype=np.float32) - t_node = np.array(lists['temperatures'], dtype=np.float32) - s_node = np.array(lists['salinities'], dtype=np.float32) + z_tilde_node = get_array_from_mid_grad(config, 'z_tilde', x) + t_node = get_array_from_mid_grad(config, 'temperature', x) + s_node = get_array_from_mid_grad(config, 'salinity', x) if ( z_tilde_node.shape != t_node.shape @@ -266,80 +311,25 @@ def _get_z_tilde_t_s_nodes( return z_tilde_node, t_node, s_node - def _compute_z_t_s_spec_vol_quadrature( + def _interpolate_t_s( self, ds: xr.Dataset, - geom_z_bot: xr.DataArray, - ): - """ - Compute temperature, salinity, pressure and specific volume at - quadrature points (layer interfaces and layer midpoints) given z-tilde - """ - config = self.config - rho0 = config.getfloat('vertical_grid', 'rho0') - ncells = ds.sizes['nCells'] - nvertlevels = ds.sizes['nVertLevels'] - z_tilde_inter = ds.zInterface - z_tilde_mid = ds.zMid - z_tilde_node, t_node, s_node = self._get_z_tilde_t_s_nodes() - - z = np.nan * np.ones((ncells, 2 * nvertlevels + 1), dtype=np.float32) - spec_vol = np.nan * np.ones( - (ncells, 2 * nvertlevels), dtype=np.float32 - ) - ct = np.nan * np.ones((ncells, 2 * nvertlevels), dtype=np.float32) - sa = np.nan * np.ones((ncells, 2 * nvertlevels), dtype=np.float32) - - for icell in range(ncells): - min_level_cell = ds.minLevelCell.isel(nCells=icell).item() - 1 - max_level_cell = ds.maxLevelCell.isel(nCells=icell).item() - 1 - - # both interfaces and midpoints - z_tilde = np.zeros(2 * nvertlevels + 1, dtype=np.float32) - z_tilde[0::2] = z_tilde_inter.isel(nCells=icell).values - z_tilde[1::2] = z_tilde_mid.isel(nCells=icell).values - indices = np.arange(2 * min_level_cell, 2 * max_level_cell + 2) - - z_loc, spec_vol_loc, ct_loc, sa_loc = integrate_geometric_height( - z_tilde_interfaces=z_tilde[indices], - z_tilde_nodes=z_tilde_node[icell, :], - sa_nodes=s_node[icell, :], - ct_nodes=t_node[icell, :], - bottom_depth=-geom_z_bot.isel(nCells=icell).items(), - rho0=rho0, - method='gauss4', - subdivisions=2, - ) - z[icell, indices] = z_loc - spec_vol[icell, indices] = spec_vol_loc - ct[icell, indices] = ct_loc - sa[icell, indices] = sa_loc - - return z, spec_vol, ct, sa - - def _compute_t_s_spec_vol( - self, ds: xr.Dataset, z_tilde_mid: xr.DataArray - ) -> tuple[xr.DataArray, xr.DataArray, xr.DataArray, xr.DataArray]: + z_tilde_mid: xr.DataArray, + x: np.ndarray, + rho0: float, + ) -> tuple[xr.DataArray, xr.DataArray]: """ Compute temperature, salinity, pressure and specific volume given z-tilde """ - z_tilde_node, t_node, s_node = self._get_z_tilde_t_s_nodes() + z_tilde_node, t_node, s_node = self._get_z_tilde_t_s_nodes(x) - config = self.config ncells = ds.sizes['nCells'] nvertlevels = ds.sizes['nVertLevels'] - rho0 = config.getfloat('vertical_grid', 'rho0') - - p_mid = pressure_from_z_tilde( - z_tilde=z_tilde_mid, - rho0=rho0, - ) - - temperature_np = np.zeros((1, ncells, nvertlevels), dtype=np.float32) - salinity_np = np.zeros((1, ncells, nvertlevels), dtype=np.float32) + temperature_np = np.zeros((1, ncells, nvertlevels), dtype=float) + salinity_np = np.zeros((1, ncells, nvertlevels), dtype=float) if z_tilde_node.shape[0] != ncells: raise ValueError( @@ -391,10 +381,4 @@ def _compute_t_s_spec_vol( }, ) - spec_vol = compute_specvol( - config=config, - temperature=temperature, - salinity=salinity, - pressure=p_mid, - ) - return temperature, salinity, p_mid, spec_vol + return temperature, salinity diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py new file mode 100644 index 0000000000..006dfe36ae --- /dev/null +++ b/polaris/tasks/ocean/two_column/reference.py @@ -0,0 +1,400 @@ +import numpy as np +import xarray as xr +from mpas_tools.cime.constants import constants + +from polaris.ocean.hydrostatic.teos10 import integrate_geometric_height +from polaris.ocean.model import OceanIOStep +from polaris.tasks.ocean.two_column.column import get_array_from_mid_grad + + +class Reference(OceanIOStep): + """ + A step for creating a high-fidelity reference solution for two column + test cases + """ + + def __init__(self, component, indir): + """ + Create the step + + Parameters + ---------- + component : polaris.Component + The component the step belongs to + + indir : str + The subdirectory that the task belongs to, that this step will + go into a subdirectory of + """ + name = 'reference' + super().__init__(component=component, name=name, indir=indir) + for file in [ + 'reference_solution.nc', + ]: + self.add_output_file(file) + + def run(self): + """ + Run this step of the test case + """ + logger = self.logger + # logger.setLevel(logging.DEBUG) + config = self.config + if config.get('ocean', 'model') != 'omega': + raise ValueError( + 'The two_column test case is only supported for the ' + 'Omega ocean model.' + ) + + resolution = config.getfloat('two_column', 'reference_resolution') + assert resolution is not None, ( + 'The "reference_resolution" configuration option must be set in ' + 'the "two_column" section.' + ) + rho0 = config.getfloat('vertical_grid', 'rho0') + assert rho0 is not None, ( + 'The "rho0" configuration option must be set in the ' + '"vertical_grid" section.' + ) + + x = resolution * np.array([-1.5, -0.5, 0.0, 0.5, 1.5], dtype=float) + + geom_ssh, geom_z_bot = self._get_geom_ssh_z_bot(x) + + vert_levels = config.getint('vertical_grid', 'vert_levels') + if vert_levels is None: + raise ValueError( + 'The "vert_levels" configuration option must be set in the ' + '"vertical_grid" section.' + ) + + vert_levs_inters = 2 * vert_levels + 1 + z_tilde = np.nan * np.ones((len(x), vert_levs_inters), dtype=float) + z = np.nan * np.ones((len(x), vert_levs_inters), dtype=float) + spec_vol = np.nan * np.ones((len(x), vert_levs_inters), dtype=float) + ct = np.nan * np.ones((len(x), vert_levs_inters), dtype=float) + sa = np.nan * np.ones((len(x), vert_levs_inters), dtype=float) + + z_tilde_node, temperature_node, salinity_node = ( + self._get_z_tilde_t_s_nodes(x) + ) + + for icol in range(len(x)): + logger.info(f'Computing column {icol}, x = {x[icol]:.3f} km') + ( + z_tilde[icol, :], + z[icol, :], + spec_vol[icol, :], + ct[icol, :], + sa[icol, :], + ) = self._compute_column( + z_tilde_node=z_tilde_node[icol, :], + temperature_node=temperature_node[icol, :], + salinity_node=salinity_node[icol, :], + geom_ssh=geom_ssh.isel(nCells=icol).item(), + geom_z_bot=geom_z_bot.isel(nCells=icol).item(), + ) + + # compute montgomery potential M = alpha * p + g * z + g = constants['SHR_CONST_G'] + montgomery = g * (rho0 * spec_vol * z_tilde + z) + + # the HPGF is grad(M) - p * grad(alpha) + # Here we just compute the gradient at x=0 using a 4th-order + # finite-difference stencil + p0 = rho0 * g * 0.5 * z_tilde[2, :] + # indices for -1.5dx, -0.5dx, 0.5dx, 1.5dx + grad_indices = [0, 1, 3, 4] + dM_dx = _compute_4th_order_gradient( + montgomery[grad_indices, :], resolution + ) + dalpha_dx = _compute_4th_order_gradient( + spec_vol[grad_indices, :], resolution + ) + hpga = dM_dx - p0 * dalpha_dx + + ds = xr.Dataset() + ds['temperature'] = xr.DataArray( + data=ct[np.newaxis, 1:2, 1::2], + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'conservative temperature', + 'units': 'degC', + }, + ) + ds['salinity'] = xr.DataArray( + data=sa[np.newaxis, 1:2, 1::2], + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'salinity', + 'units': 'g kg-1', + }, + ) + + ds['SpecVol'] = xr.DataArray( + data=spec_vol[np.newaxis, 1:2, 1::2], + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'specific volume', + 'units': 'm3 kg-1', + }, + ) + ds['Density'] = 1.0 / ds['SpecVol'] + ds.Density.attrs['long_name'] = 'density' + ds.Density.attrs['units'] = 'kg m-3' + + ds['ZTildeMid'] = xr.DataArray( + data=z_tilde[np.newaxis, 1:2, 1::2], + dims=['Time', 'nCells', 'nVertLevels'], + ) + ds.ZTildeMid.attrs['long_name'] = 'pseudo-height at layer midpoints' + ds.ZTildeMid.attrs['units'] = 'm' + + ds['ZTildeInter'] = xr.DataArray( + data=z_tilde[np.newaxis, 1:2, 0::2], + dims=['Time', 'nCells', 'nVertLevelsP1'], + ) + ds.ZTildeInter.attrs['long_name'] = 'pseudo-height at layer interfaces' + ds.ZTildeInter.attrs['units'] = 'm' + + ds['GeomZMid'] = xr.DataArray( + data=z[np.newaxis, 1:2, 1::2], + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'geometric height at layer midpoints', + 'units': 'm', + }, + ) + + ds['GeomZInter'] = xr.DataArray( + data=z[np.newaxis, 1:2, 0::2], + dims=['Time', 'nCells', 'nVertLevelsP1'], + attrs={ + 'long_name': 'geometric height at layer interfaces', + 'units': 'm', + }, + ) + + ds['MontgomeryMid'] = xr.DataArray( + data=montgomery[np.newaxis, 1:2, 1::2], + dims=['Time', 'nCells', 'nVertLevels'], + attrs={ + 'long_name': 'Montgomery potential at layer midpoints', + 'units': 'm2 s-2', + }, + ) + + ds['MontgomeryInter'] = xr.DataArray( + data=montgomery[np.newaxis, 1:2, 0::2], + dims=['Time', 'nCells', 'nVertLevelsP1'], + attrs={ + 'long_name': 'Montgomery potential at layer interfaces', + 'units': 'm2 s-2', + }, + ) + + ds['HPGAMid'] = xr.DataArray( + data=hpga[np.newaxis, 1::2], + dims=['Time', 'nVertLevels'], + attrs={ + 'long_name': 'along-layer pressure gradient acceleration at ' + 'midpoints', + 'units': 'm s-2', + }, + ) + + ds['HPGAInter'] = xr.DataArray( + data=hpga[np.newaxis, 0::2], + dims=['Time', 'nVertLevelsP1'], + attrs={ + 'long_name': 'along-layer pressure gradient acceleration at ' + 'interfaces', + 'units': 'm s-2', + }, + ) + + self.write_model_dataset(ds, 'reference_solution.nc') + + def _get_geom_ssh_z_bot( + self, x: np.ndarray + ) -> tuple[xr.DataArray, xr.DataArray]: + """ + Get the geometric sea surface height and sea floor height for each + column from the configuration. + """ + config = self.config + geom_ssh = get_array_from_mid_grad(config, 'geom_ssh', x) + geom_z_bot = get_array_from_mid_grad(config, 'geom_z_bot', x) + return ( + xr.DataArray(data=geom_ssh, dims=['nCells']), + xr.DataArray(data=geom_z_bot, dims=['nCells']), + ) + + def _init_z_tilde_interface( + self, pseudo_bottom_depth: float + ) -> tuple[np.ndarray, int]: + """ + Compute z-tilde vertical interfaces. + """ + section = self.config['vertical_grid'] + vert_levels = section.getint('vert_levels') + bottom_depth = section.getfloat('bottom_depth') + z_tilde_interface = np.linspace( + 0.0, -bottom_depth, vert_levels + 1, dtype=float + ) + z_tilde_interface = np.maximum(z_tilde_interface, -pseudo_bottom_depth) + dz = z_tilde_interface[0:-1] - z_tilde_interface[1:] + mask = dz == 0.0 + z_tilde_interface[1:][mask] = np.nan + + # max_layer is the index of the deepest non-nan layer interface + max_layer = np.where(~mask)[0][-1] + 1 + return z_tilde_interface, max_layer + + def _get_z_tilde_t_s_nodes( + self, x: np.ndarray + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Get the z-tilde, temperature and salinity node values from the + configuration. + """ + config = self.config + z_tilde_node = get_array_from_mid_grad(config, 'z_tilde', x) + t_node = get_array_from_mid_grad(config, 'temperature', x) + s_node = get_array_from_mid_grad(config, 'salinity', x) + + if ( + z_tilde_node.shape != t_node.shape + or z_tilde_node.shape != s_node.shape + ): + raise ValueError( + 'The number of z_tilde, temperature and salinity ' + 'points must be the same in each column.' + ) + + self.logger.debug('z_tilde nodes:') + self.logger.debug(z_tilde_node) + self.logger.debug('temperature nodes:') + self.logger.debug(t_node) + self.logger.debug('salinity nodes:') + self.logger.debug(s_node) + + return z_tilde_node, t_node, s_node + + def _compute_column( + self, + z_tilde_node: np.ndarray, + temperature_node: np.ndarray, + salinity_node: np.ndarray, + geom_ssh: float, + geom_z_bot: float, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + config = self.config + logger = self.logger + section = config['two_column'] + method = section.get('reference_quadrature_method') + assert method is not None, ( + 'The "reference_quadrature_method" configuration option must be ' + 'set in the "two_column" section.' + ) + rho0 = config.getfloat('vertical_grid', 'rho0') + assert rho0 is not None, ( + 'The "rho0" configuration option must be set in the ' + '"vertical_grid" section.' + ) + water_col_adjust_iter_count = section.getint( + 'water_col_adjust_iter_count' + ) + assert water_col_adjust_iter_count is not None, ( + 'The "water_col_adjust_iter_count" configuration option must be ' + 'set in the "two_column" section.' + ) + + goal_geom_water_column_thickness = geom_ssh - geom_z_bot + + # first guess at the pseudo bottom depth is the geometric + # water column thickness + pseudo_bottom_depth = goal_geom_water_column_thickness + + logger.debug( + f'goal_geom_water_column_thickness = ' + f'{goal_geom_water_column_thickness:.12f}' + ) + + for iter in range(water_col_adjust_iter_count): + z_tilde_inter, max_layer = self._init_z_tilde_interface( + pseudo_bottom_depth=pseudo_bottom_depth + ) + vert_levels = len(z_tilde_inter) - 1 + + z_tilde_mid = 0.5 * (z_tilde_inter[0:-1] + z_tilde_inter[1:]) + + # z_tilde has both interfaces and midpoints + z_tilde = np.zeros(2 * vert_levels + 1, dtype=float) + z_tilde[0::2] = z_tilde_inter + z_tilde[1::2] = z_tilde_mid + + valid = slice(0, 2 * max_layer + 1) + + z_tilde_valid = z_tilde[valid] + logger.debug(f'z_tilde_valid = {z_tilde_valid}') + logger.debug(f'z_tilde invalid = {z_tilde[2 * max_layer + 1 :]}') + + z = np.nan * np.ones_like(z_tilde) + spec_vol = np.nan * np.ones_like(z_tilde) + ct = np.nan * np.ones_like(z_tilde) + sa = np.nan * np.ones_like(z_tilde) + + ( + z[valid], + spec_vol[valid], + ct[valid], + sa[valid], + ) = integrate_geometric_height( + z_tilde_interfaces=z_tilde_valid, + z_tilde_nodes=z_tilde_node, + sa_nodes=salinity_node, + ct_nodes=temperature_node, + bottom_depth=-geom_z_bot, + rho0=rho0, + method=method, + ) + + geom_water_column_thickness = z[0] - z[2 * max_layer] + + scaling_factor = ( + goal_geom_water_column_thickness / geom_water_column_thickness + ) + logger.info( + f' Iteration {iter}: ' + f'scaling factor = {scaling_factor:.12f}, ' + f'scaling factor - 1 = {scaling_factor - 1.0:.12g}, ' + f'pseudo bottom depth = {pseudo_bottom_depth:.12f}, ' + f'max layer = {max_layer}' + ) + pseudo_bottom_depth *= scaling_factor + logger.info('') + + return z_tilde, z, spec_vol, ct, sa + + +def _compute_4th_order_gradient(f: np.ndarray, dx: float) -> np.ndarray: + """ + Compute a 4th-order finite-difference gradient of f with respect to x + at x=0, assuming values at x = dx * [-1.5, -0.5, 0.5, 1.5]. + + The stencil is: + f'(0) ≈ [-f(1.5dx) + 9 f(0.5dx) - 9 f(-0.5dx) + f(-1.5dx)] / (8 dx) + + Here we assume f[0,:], f[1,:], f[2,:], f[3,:] correspond to + x = -1.5dx, -0.5dx, 0.5dx, 1.5dx respectively. + """ + assert f.shape[0] == 4, ( + 'Input array must have exactly 4 entries in its first dimension ' + 'for the 4th-order gradient.' + ) + + # gradient at x = 0 using the non-uniform 4-point stencil + df_dx = (-f[3, :] + 9.0 * f[2, :] - 9.0 * f[1, :] + f[0, :]) / (8.0 * dx) + + return df_dx diff --git a/polaris/tasks/ocean/two_column/teos10/__init__.py b/polaris/tasks/ocean/two_column/teos10/__init__.py index 2616f1e9b1..9017932c22 100644 --- a/polaris/tasks/ocean/two_column/teos10/__init__.py +++ b/polaris/tasks/ocean/two_column/teos10/__init__.py @@ -2,6 +2,7 @@ from polaris import Task from polaris.tasks.ocean.two_column.init import Init +from polaris.tasks.ocean.two_column.reference import Reference class Teos10(Task): @@ -31,4 +32,35 @@ def __init__(self, component): 'polaris.tasks.ocean.two_column.teos10', 'teos10.cfg' ) - self.add_step(Init(component=component, indir=self.subdir)) + self._setup_steps() + + def configure(self): + """ + Set config options for the test case + """ + super().configure() + + # set up the steps again in case a user has provided new resolutions + self._setup_steps() + + def _setup_steps(self): + """ + setup steps given resolutions + """ + section = self.config['two_column'] + resolutions = section.getexpression('resolutions') + + # start fresh with no steps + for step in list(self.steps.values()): + self.remove_step(step) + + self.add_step(Reference(component=self.component, indir=self.subdir)) + + for resolution in resolutions: + self.add_step( + Init( + component=self.component, + resolution=resolution, + indir=self.subdir, + ) + ) diff --git a/polaris/tasks/ocean/two_column/teos10/teos10.cfg b/polaris/tasks/ocean/two_column/teos10/teos10.cfg index 9e2fce25ce..f9a5828ed6 100644 --- a/polaris/tasks/ocean/two_column/teos10/teos10.cfg +++ b/polaris/tasks/ocean/two_column/teos10/teos10.cfg @@ -10,23 +10,8 @@ eos_type = teos-10 # config options for two column testcases [two_column] -# resolution in km (the distance between the two columns) -resolution = 1.0 - -# sea surface height for each column -ssh = [0.0, 0.0] - -# z_tilde, temperatures and salinities for piecewise linear initial conditions -# in each column -z_tilde = [ - [-5.0, -55.0, -105.0, -495.0], - [-5.0, -55.0, -105.0, -495.0], - ] -temperatures = [ - [20.0, 15.0, 10.0, 5.0], - [20.0, 15.0, 10.0, 5.0], - ] -salinities = [ - [35.0, 34.0, 33.0, 32.0], - [34.0, 33.0, 32.0, 31.0], - ] +# salinities for piecewise linear initial conditions at the midpoint between +# the two columns and their gradients +salinity_mid = [35.0, 34.0, 33.0, 32.0] +# g/kg / km +salinity_grad = [0.0, 0.0, 0.0, 0.0] diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index 1becd5f28f..183fb748a9 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -27,29 +27,41 @@ rho0 = 1035.0 # config options for two column testcases [two_column] -# resolution in km (the distance between the two columns) -resolution = 1.0 +# resolutions in km (the distance between the two columns) +resolutions = [16.0, 8.0, 4.0, 2.0, 1.0] -# geometric sea surface height for each column -geom_ssh = [0.0, 0.0] +# geometric sea surface height at the midpoint between the two columns +# and its gradient +geom_ssh_mid = 0.0 +# m / km +geom_ssh_grad = 0.0 -# geometric sea floor height for each column -geom_z_bot = [-500.0, -500.0] +# geometric sea floor height at the midpoint between the two columns +# and its gradient +geom_z_bot_mid = -500.0 +# m / km +geom_z_bot_grad = 0.0 # pseudo-height, temperatures and salinities for piecewise linear initial -# conditions in each column -z_tilde = [ - [-5.0, -55.0, -105.0, -495.0], - [-5.0, -55.0, -105.0, -495.0], - ] -temperatures = [ - [20.0, 15.0, 10.0, 5.0], - [20.0, 15.0, 10.0, 5.0], - ] -salinities = [ - [35.0, 34.0, 33.0, 32.0], - [35.0, 34.0, 33.0, 32.0], - ] +# conditions at the midpoint between the two columns and their gradients +z_tilde_mid = [-5.0, -55.0, -105.0, -495.0] +# m / km +z_tilde_grad = [0.0, 0.0, 0.0, 0.0] + +temperature_mid = [20.0, 15.0, 10.0, 5.0] +# deg C / km +temperature_grad = [0.0, 0.0, 0.0, 0.0] + +salinity_mid = [35.0, 34.0, 33.0, 32.0] +# g/kg / km +salinity_grad = [0.0, 0.0, 0.0, 0.0] # number of iterations over which to allow water column adjustments water_col_adjust_iter_count = 6 + + +# reference solution quadrature method +reference_quadrature_method = gauss4 + +# reference solution's horizontal resolution in km +reference_resolution = 1.0 From 3c72b9b24718bb33ca7c1416d7f6dc07c0955d9c Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 19 Jan 2026 13:48:09 +0100 Subject: [PATCH 11/53] Move integration into reference step module At least for now, it will be too confusing to have this capability in the ocean component's framework since it is used for verification only. --- polaris/ocean/hydrostatic/__init__.py | 0 polaris/ocean/hydrostatic/teos10.py | 367 -------------------- polaris/tasks/ocean/two_column/reference.py | 354 ++++++++++++++++++- 3 files changed, 352 insertions(+), 369 deletions(-) delete mode 100644 polaris/ocean/hydrostatic/__init__.py delete mode 100644 polaris/ocean/hydrostatic/teos10.py diff --git a/polaris/ocean/hydrostatic/__init__.py b/polaris/ocean/hydrostatic/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/polaris/ocean/hydrostatic/teos10.py b/polaris/ocean/hydrostatic/teos10.py deleted file mode 100644 index 4cb3a601cd..0000000000 --- a/polaris/ocean/hydrostatic/teos10.py +++ /dev/null @@ -1,367 +0,0 @@ -r""" -Hydrostatic integration utilities for the Omega ocean model. - -This module currently provides functionality for converting from the -Omega pseudo-height coordinate :math:`\tilde z` (``z_tilde``) to true -geometric height ``z`` by numerically integrating the hydrostatic -relation - -.. math:: - - \frac{\partial z}{\partial \tilde z} = \rho_0\,\nu( S_A, \Theta, p ) - -where :math:`\nu` is the specific volume (``spec_vol``) computed from the -TEOS-10 equation of state, :math:`S_A` is Absolute Salinity, :math:`\Theta` -is Conservative Temperature, :math:`p` is sea pressure (positive downward), -and :math:`\rho_0` is a reference density used in the definition of -``z_tilde = - p / (\rho_0 g)`. The conversion therefore requires an -integral of the form - -.. math:: - - z(\tilde z) = z_b + \int_{\tilde z_b}^{\tilde z} - \rho_0\,\nu\big(S_A(\tilde z'),\Theta(\tilde z'),p(\tilde z')\big)\; - d\tilde z' , - -with :math:`z_b = -\text{bottom\_depth}` at the pseudo-height -``z_tilde_b`` at the seafloor, typically the minimum (most negative) value -of the pseudo-height domain for a given water column. - -The primary entry point is :func:`integrate_geometric_height`. -""" - -from __future__ import annotations - -from typing import Callable, Literal, Sequence - -import gsw -import numpy as np -from mpas_tools.cime.constants import constants - -__all__ = [ - 'integrate_geometric_height', -] - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def integrate_geometric_height( - z_tilde_interfaces: Sequence[float] | np.ndarray, - z_tilde_nodes: Sequence[float] | np.ndarray, - sa_nodes: Sequence[float] | np.ndarray, - ct_nodes: Sequence[float] | np.ndarray, - bottom_depth: float, - rho0: float, - method: Literal[ - 'midpoint', 'trapezoid', 'simpson', 'gauss2', 'gauss4', 'adaptive' - ] = 'gauss4', - subdivisions: int = 2, - rel_tol: float = 5e-8, - abs_tol: float = 5e-5, - max_recurs_depth: int = 12, -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """ - Integrate the hydrostatic relation to obtain geometric height. - - Integrates upward from the seafloor using - ``dz/dz_tilde = rho0 * spec_vol(SA, CT, p)`` to obtain geometric - heights ``z`` at requested pseudo-heights ``z_tilde_interfaces``. - - The salinity and temperature profiles are supplied as *piecewise - linear* functions of pseudo-height via collocation nodes and their - values. Outside the node range, profiles are held constant at the - end values (``numpy.interp`` behavior), permitting targets to extend - above or below the collocation range. - - Methods - ------- - The integral over each interface interval can be evaluated with one of - the following schemes (set with ``method``): - - - 'midpoint': composite midpoint rule with ``subdivisions`` panels. - - 'trapezoid': composite trapezoidal rule with ``subdivisions`` panels. - - 'simpson': composite Simpson's rule; requires an even number of - panels. If ``subdivisions`` is odd, one is added internally. - - 'gauss2': 2-point Gauss-Legendre per panel (higher accuracy than - midpoint/trapezoid at similar cost). - - 'gauss4': 4-point Gauss-Legendre per panel (default; high accuracy - for smooth integrands). - - 'adaptive': adaptive recursive Simpson integration controlled by - ``rel_tol``, ``abs_tol`` and ``max_depth``. - - Parameters - ---------- - z_tilde_interfaces : sequence of float - Monotonic non-increasing layer-interface pseudo-heights ordered - from sea surface to seafloor. The first value corresponds to the - sea surface (typically near 0) and the last to the seafloor - (most negative). Values may extend outside the node range. - z_tilde_nodes : sequence of float - Strictly increasing collocation nodes for SA and CT. - sa_nodes, ct_nodes : sequence of float - Absolute Salinity (g/kg) and Conservative Temperature (degC) at - ``z_tilde_nodes``. - bottom_depth : float - Positive depth (m); geometric height at seafloor is ``-bottom_depth``. - rho0 : float - Reference density used in the pseudo-height definition. - method : str, optional - Quadrature method ('midpoint','trapezoid','simpson','gauss2', - 'gauss4','adaptive'). Default 'gauss4'. - subdivisions : int, optional - Subdivisions per interval for fixed-step methods (>=1). Ignored - for 'adaptive'. - rel_tol, abs_tol : float, optional - Relative/absolute tolerances for adaptive Simpson. - max_recurs_depth : int, optional - Max recursion depth for adaptive Simpson. - - Returns - ------- - z : ndarray - Geometric heights at ``z_tilde_interfaces``. - spec_vol : ndarray - Specific volume at targets. - ct : ndarray - Conservative temperature at targets. - sa : ndarray - Absolute salinity at targets. - """ - - z_tilde_interfaces = np.asarray(z_tilde_interfaces, dtype=float) - z_tilde_nodes = np.asarray(z_tilde_nodes, dtype=float) - sa_nodes = np.asarray(sa_nodes, dtype=float) - ct_nodes = np.asarray(ct_nodes, dtype=float) - - if not ( - z_tilde_nodes.ndim == sa_nodes.ndim == ct_nodes.ndim == 1 - and z_tilde_interfaces.ndim == 1 - ): - raise ValueError('All inputs must be one-dimensional.') - if len(z_tilde_nodes) != len(sa_nodes) or len(z_tilde_nodes) != len( - ct_nodes - ): - raise ValueError( - 'Lengths of z_tilde_nodes, sa_nodes, ct_nodes differ.' - ) - if len(z_tilde_nodes) < 2: - raise ValueError('Need at least two collocation nodes.') - if not np.all(np.diff(z_tilde_nodes) <= 0): - raise ValueError('z_tilde_nodes must be strictly non-increasing.') - if not np.all(np.diff(z_tilde_interfaces) <= 0): - raise ValueError('z_tilde_interfaces must be non-increasing.') - if subdivisions < 1: - raise ValueError('subdivisions must be >= 1.') - - g = constants['SHR_CONST_G'] - - def spec_vol_ct_sa_at( - z_tilde: np.ndarray, - ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - # np.interp requires xp to be increasing; our - # z_tilde_nodes are decreasing, so flip the signs of - # z_tilde and z_tilde_nodes for the interpolation - sa = np.interp(-z_tilde, -z_tilde_nodes, sa_nodes) - ct = np.interp(-z_tilde, -z_tilde_nodes, ct_nodes) - p_pa = -rho0 * g * z_tilde - # gsw expects pressure in dbar - spec_vol = gsw.specvol(sa, ct, p_pa * 1.0e-4) - return spec_vol, ct, sa - - def integrand(z_tilde: np.ndarray) -> np.ndarray: - spec_vol, _, _ = spec_vol_ct_sa_at(z_tilde) - return rho0 * spec_vol - - # fill interface heights: anchor bottom, integrate upward (reverse) - n_interfaces = len(z_tilde_interfaces) - if n_interfaces < 2: - raise ValueError('Need at least two interfaces (surface and bottom).') - z = np.empty_like(z_tilde_interfaces) - z[-1] = -bottom_depth - for i in range(n_interfaces - 1, 0, -1): - a = z_tilde_interfaces[i - 1] # shallower - b = z_tilde_interfaces[i] # deeper - if a == b: - z[i - 1] = z[i] - continue - if method == 'adaptive': - inc = _adaptive_simpson( - integrand, a, b, rel_tol, abs_tol, max_recurs_depth - ) - else: - nsub = subdivisions - if method == 'simpson' and nsub % 2 == 1: - nsub += 1 - inc = _fixed_quadrature(integrand, a, b, nsub, method) - z[i - 1] = z[i] - inc - - spec_vol, ct, sa = spec_vol_ct_sa_at(z_tilde_interfaces) - return z, spec_vol, ct, sa - - -# --------------------------------------------------------------------------- -# Helper functions (non-public) -# --------------------------------------------------------------------------- - - -def _fixed_quadrature( - integrand: Callable[[np.ndarray], np.ndarray], - a: float, - b: float, - nsub: int, - method: str, -) -> float: - """Composite fixed-step quadrature over [a,b].""" - h = (b - a) / nsub - total = 0.0 - if method == 'midpoint': - mids = a + (np.arange(nsub) + 0.5) * h - total = np.sum(integrand(mids)) * h - elif method == 'trapezoid': - x = a + np.arange(nsub + 1) * h - fx = integrand(x) - total = h * (0.5 * fx[0] + fx[1:-1].sum() + 0.5 * fx[-1]) - elif method == 'simpson': - if nsub % 2 != 0: - raise ValueError('Simpson requires even nsub.') - x = a + np.arange(nsub + 1) * h - fx = integrand(x) - total = ( - h - / 3.0 - * ( - fx[0] - + fx[-1] - + 4.0 * fx[1:-1:2].sum() - + 2.0 * fx[2:-2:2].sum() - ) - ) - elif method in {'gauss2', 'gauss4'}: - total = _gauss_composite(integrand, a, b, nsub, method) - else: # pragma: no cover - defensive - raise ValueError(f'Unknown quadrature method: {method}') - return float(total) - - -def _gauss_composite( - integrand: Callable[[np.ndarray], np.ndarray], - a: float, - b: float, - nsub: int, - method: str, -) -> float: - """Composite Gauss-Legendre quadrature (2- or 4-point).""" - h = (b - a) / nsub - total = 0.0 - if method == 'gauss2': - xi = np.array([-1.0 / np.sqrt(3.0), 1.0 / np.sqrt(3.0)]) - wi = np.array([1.0, 1.0]) - else: # gauss4 - xi = np.array( - [ - -0.8611363115940526, - -0.3399810435848563, - 0.3399810435848563, - 0.8611363115940526, - ] - ) - wi = np.array( - [ - 0.34785484513745385, - 0.6521451548625461, - 0.6521451548625461, - 0.34785484513745385, - ] - ) - for k in range(nsub): - a_k = a + k * h - b_k = a_k + h - mid = 0.5 * (a_k + b_k) - half = 0.5 * h - xk = mid + half * xi - fx = integrand(xk) - total += half * np.sum(wi * fx) - return float(total) - - -def _adaptive_simpson( - integrand: Callable[[np.ndarray], np.ndarray], - a: float, - b: float, - rel_tol: float, - abs_tol: float, - max_depth: int, -) -> float: - """Adaptive Simpson integration over [a,b].""" - fa = integrand(np.array([a]))[0] - fb = integrand(np.array([b]))[0] - m = 0.5 * (a + b) - fm = integrand(np.array([m]))[0] - whole = _simpson_basic(fa, fm, fb, a, b) - return _adaptive_simpson_recursive( - integrand, a, b, fa, fm, fb, whole, rel_tol, abs_tol, max_depth, 0 - ) - - -def _simpson_basic( - fa: float, fm: float, fb: float, a: float, b: float -) -> float: - """Single Simpson panel.""" - return (b - a) / 6.0 * (fa + 4.0 * fm + fb) - - -def _adaptive_simpson_recursive( - integrand: Callable[[np.ndarray], np.ndarray], - a: float, - b: float, - fa: float, - fm: float, - fb: float, - whole: float, - rel_tol: float, - abs_tol: float, - max_depth: int, - depth: int, -) -> float: - m = 0.5 * (a + b) - lm = 0.5 * (a + m) - rm = 0.5 * (m + b) - flm = integrand(np.array([lm]))[0] - frm = integrand(np.array([rm]))[0] - left = _simpson_basic(fa, flm, fm, a, m) - right = _simpson_basic(fm, frm, fb, m, b) - S2 = left + right - err = S2 - whole - tol = max(abs_tol, rel_tol * max(abs(S2), 1e-15)) - if depth >= max_depth: - return S2 - if abs(err) < 15.0 * tol: - return S2 + err / 15.0 # Richardson extrapolation - return _adaptive_simpson_recursive( - integrand, - a, - m, - fa, - flm, - fm, - left, - rel_tol, - abs_tol, - max_depth, - depth + 1, - ) + _adaptive_simpson_recursive( - integrand, - m, - b, - fm, - frm, - fb, - right, - rel_tol, - abs_tol, - max_depth, - depth + 1, - ) diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index 006dfe36ae..d4fedcb7c2 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -1,8 +1,12 @@ +from __future__ import annotations + +from typing import Callable, Literal, Sequence + +import gsw import numpy as np import xarray as xr from mpas_tools.cime.constants import constants -from polaris.ocean.hydrostatic.teos10 import integrate_geometric_height from polaris.ocean.model import OceanIOStep from polaris.tasks.ocean.two_column.column import get_array_from_mid_grad @@ -11,6 +15,38 @@ class Reference(OceanIOStep): """ A step for creating a high-fidelity reference solution for two column test cases + + The reference solution is computed by first converting from the + Omega pseudo-height coordinate :math:`\tilde z` (``z_tilde``) to true + geometric height ``z`` by numerically integrating the hydrostatic + relation + + .. math:: + + \frac{\\partial z}{\\partial \tilde z} = + \rho_0\\,\nu( S_A, \\Theta, p ) + + where :math:`\nu` is the specific volume (``spec_vol``) computed from the + TEOS-10 equation of state, :math:`S_A` is Absolute Salinity, + :math:`\\Theta` is Conservative Temperature, :math:`p` is sea pressure + (positive downward), and :math:`\rho_0` is a reference density used in the + definition of ``z_tilde = - p / (\rho_0 g)`. The conversion therefore + requires an integral of the form + + .. math:: + + z(\tilde z) = z_b + \\int_{\tilde z_b}^{\tilde z} + \rho_0\\,\nu\big(S_A(\tilde z'),\\Theta(\tilde z'),p(\tilde z')\big)\\; + d\tilde z' , + + with :math:`z_b = -\text{bottom\\_depth}` at the pseudo-height + ``z_tilde_b`` at the seafloor, typically the minimum (most negative) value + of the pseudo-height domain for a given water column. + + Then, the horizontal gradient is computed using a 4th-order + finite-difference stencil at the center column (x=0) to obtain the + high-fidelity reference solution for the hydrostatic pressure gradient + error. """ def __init__(self, component, indir): @@ -350,7 +386,7 @@ def _compute_column( spec_vol[valid], ct[valid], sa[valid], - ) = integrate_geometric_height( + ) = _integrate_geometric_height( z_tilde_interfaces=z_tilde_valid, z_tilde_nodes=z_tilde_node, sa_nodes=salinity_node, @@ -378,6 +414,320 @@ def _compute_column( return z_tilde, z, spec_vol, ct, sa +def _integrate_geometric_height( + z_tilde_interfaces: Sequence[float] | np.ndarray, + z_tilde_nodes: Sequence[float] | np.ndarray, + sa_nodes: Sequence[float] | np.ndarray, + ct_nodes: Sequence[float] | np.ndarray, + bottom_depth: float, + rho0: float, + method: Literal[ + 'midpoint', 'trapezoid', 'simpson', 'gauss2', 'gauss4', 'adaptive' + ] = 'gauss4', + subdivisions: int = 2, + rel_tol: float = 5e-8, + abs_tol: float = 5e-5, + max_recurs_depth: int = 12, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Integrate the hydrostatic relation to obtain geometric height. + + Integrates upward from the seafloor using + ``dz/dz_tilde = rho0 * spec_vol(SA, CT, p)`` to obtain geometric + heights ``z`` at requested pseudo-heights ``z_tilde_interfaces``. + + The salinity and temperature profiles are supplied as *piecewise + linear* functions of pseudo-height via collocation nodes and their + values. Outside the node range, profiles are held constant at the + end values (``numpy.interp`` behavior), permitting targets to extend + above or below the collocation range. + + Methods + ------- + The integral over each interface interval can be evaluated with one of + the following schemes (set with ``method``): + + - 'midpoint': composite midpoint rule with ``subdivisions`` panels. + - 'trapezoid': composite trapezoidal rule with ``subdivisions`` panels. + - 'simpson': composite Simpson's rule; requires an even number of + panels. If ``subdivisions`` is odd, one is added internally. + - 'gauss2': 2-point Gauss-Legendre per panel (higher accuracy than + midpoint/trapezoid at similar cost). + - 'gauss4': 4-point Gauss-Legendre per panel (default; high accuracy + for smooth integrands). + - 'adaptive': adaptive recursive Simpson integration controlled by + ``rel_tol``, ``abs_tol`` and ``max_depth``. + + Parameters + ---------- + z_tilde_interfaces : sequence of float + Monotonic non-increasing layer-interface pseudo-heights ordered + from sea surface to seafloor. The first value corresponds to the + sea surface (typically near 0) and the last to the seafloor + (most negative). Values may extend outside the node range. + z_tilde_nodes : sequence of float + Strictly increasing collocation nodes for SA and CT. + sa_nodes, ct_nodes : sequence of float + Absolute Salinity (g/kg) and Conservative Temperature (degC) at + ``z_tilde_nodes``. + bottom_depth : float + Positive depth (m); geometric height at seafloor is ``-bottom_depth``. + rho0 : float + Reference density used in the pseudo-height definition. + method : str, optional + Quadrature method ('midpoint','trapezoid','simpson','gauss2', + 'gauss4','adaptive'). Default 'gauss4'. + subdivisions : int, optional + Subdivisions per interval for fixed-step methods (>=1). Ignored + for 'adaptive'. + rel_tol, abs_tol : float, optional + Relative/absolute tolerances for adaptive Simpson. + max_recurs_depth : int, optional + Max recursion depth for adaptive Simpson. + + Returns + ------- + z : ndarray + Geometric heights at ``z_tilde_interfaces``. + spec_vol : ndarray + Specific volume at targets. + ct : ndarray + Conservative temperature at targets. + sa : ndarray + Absolute salinity at targets. + """ + + z_tilde_interfaces = np.asarray(z_tilde_interfaces, dtype=float) + z_tilde_nodes = np.asarray(z_tilde_nodes, dtype=float) + sa_nodes = np.asarray(sa_nodes, dtype=float) + ct_nodes = np.asarray(ct_nodes, dtype=float) + + if not ( + z_tilde_nodes.ndim == sa_nodes.ndim == ct_nodes.ndim == 1 + and z_tilde_interfaces.ndim == 1 + ): + raise ValueError('All inputs must be one-dimensional.') + if len(z_tilde_nodes) != len(sa_nodes) or len(z_tilde_nodes) != len( + ct_nodes + ): + raise ValueError( + 'Lengths of z_tilde_nodes, sa_nodes, ct_nodes differ.' + ) + if len(z_tilde_nodes) < 2: + raise ValueError('Need at least two collocation nodes.') + if not np.all(np.diff(z_tilde_nodes) <= 0): + raise ValueError('z_tilde_nodes must be strictly non-increasing.') + if not np.all(np.diff(z_tilde_interfaces) <= 0): + raise ValueError('z_tilde_interfaces must be non-increasing.') + if subdivisions < 1: + raise ValueError('subdivisions must be >= 1.') + + g = constants['SHR_CONST_G'] + + def spec_vol_ct_sa_at( + z_tilde: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + # np.interp requires xp to be increasing; our + # z_tilde_nodes are decreasing, so flip the signs of + # z_tilde and z_tilde_nodes for the interpolation + sa = np.interp(-z_tilde, -z_tilde_nodes, sa_nodes) + ct = np.interp(-z_tilde, -z_tilde_nodes, ct_nodes) + p_pa = -rho0 * g * z_tilde + # gsw expects pressure in dbar + spec_vol = gsw.specvol(sa, ct, p_pa * 1.0e-4) + return spec_vol, ct, sa + + def integrand(z_tilde: np.ndarray) -> np.ndarray: + spec_vol, _, _ = spec_vol_ct_sa_at(z_tilde) + return rho0 * spec_vol + + # fill interface heights: anchor bottom, integrate upward (reverse) + n_interfaces = len(z_tilde_interfaces) + if n_interfaces < 2: + raise ValueError('Need at least two interfaces (surface and bottom).') + z = np.empty_like(z_tilde_interfaces) + z[-1] = -bottom_depth + for i in range(n_interfaces - 1, 0, -1): + a = z_tilde_interfaces[i - 1] # shallower + b = z_tilde_interfaces[i] # deeper + if a == b: + z[i - 1] = z[i] + continue + if method == 'adaptive': + inc = _adaptive_simpson( + integrand, a, b, rel_tol, abs_tol, max_recurs_depth + ) + else: + nsub = subdivisions + if method == 'simpson' and nsub % 2 == 1: + nsub += 1 + inc = _fixed_quadrature(integrand, a, b, nsub, method) + z[i - 1] = z[i] - inc + + spec_vol, ct, sa = spec_vol_ct_sa_at(z_tilde_interfaces) + return z, spec_vol, ct, sa + + +def _fixed_quadrature( + integrand: Callable[[np.ndarray], np.ndarray], + a: float, + b: float, + nsub: int, + method: str, +) -> float: + """Composite fixed-step quadrature over [a,b].""" + h = (b - a) / nsub + total = 0.0 + if method == 'midpoint': + mids = a + (np.arange(nsub) + 0.5) * h + total = np.sum(integrand(mids)) * h + elif method == 'trapezoid': + x = a + np.arange(nsub + 1) * h + fx = integrand(x) + total = h * (0.5 * fx[0] + fx[1:-1].sum() + 0.5 * fx[-1]) + elif method == 'simpson': + if nsub % 2 != 0: + raise ValueError('Simpson requires even nsub.') + x = a + np.arange(nsub + 1) * h + fx = integrand(x) + total = ( + h + / 3.0 + * ( + fx[0] + + fx[-1] + + 4.0 * fx[1:-1:2].sum() + + 2.0 * fx[2:-2:2].sum() + ) + ) + elif method in {'gauss2', 'gauss4'}: + total = _gauss_composite(integrand, a, b, nsub, method) + else: # pragma: no cover - defensive + raise ValueError(f'Unknown quadrature method: {method}') + return float(total) + + +def _gauss_composite( + integrand: Callable[[np.ndarray], np.ndarray], + a: float, + b: float, + nsub: int, + method: str, +) -> float: + """Composite Gauss-Legendre quadrature (2- or 4-point).""" + h = (b - a) / nsub + total = 0.0 + if method == 'gauss2': + xi = np.array([-1.0 / np.sqrt(3.0), 1.0 / np.sqrt(3.0)]) + wi = np.array([1.0, 1.0]) + else: # gauss4 + xi = np.array( + [ + -0.8611363115940526, + -0.3399810435848563, + 0.3399810435848563, + 0.8611363115940526, + ] + ) + wi = np.array( + [ + 0.34785484513745385, + 0.6521451548625461, + 0.6521451548625461, + 0.34785484513745385, + ] + ) + for k in range(nsub): + a_k = a + k * h + b_k = a_k + h + mid = 0.5 * (a_k + b_k) + half = 0.5 * h + xk = mid + half * xi + fx = integrand(xk) + total += half * np.sum(wi * fx) + return float(total) + + +def _adaptive_simpson( + integrand: Callable[[np.ndarray], np.ndarray], + a: float, + b: float, + rel_tol: float, + abs_tol: float, + max_depth: int, +) -> float: + """Adaptive Simpson integration over [a,b].""" + fa = integrand(np.array([a]))[0] + fb = integrand(np.array([b]))[0] + m = 0.5 * (a + b) + fm = integrand(np.array([m]))[0] + whole = _simpson_basic(fa, fm, fb, a, b) + return _adaptive_simpson_recursive( + integrand, a, b, fa, fm, fb, whole, rel_tol, abs_tol, max_depth, 0 + ) + + +def _simpson_basic( + fa: float, fm: float, fb: float, a: float, b: float +) -> float: + """Single Simpson panel.""" + return (b - a) / 6.0 * (fa + 4.0 * fm + fb) + + +def _adaptive_simpson_recursive( + integrand: Callable[[np.ndarray], np.ndarray], + a: float, + b: float, + fa: float, + fm: float, + fb: float, + whole: float, + rel_tol: float, + abs_tol: float, + max_depth: int, + depth: int, +) -> float: + m = 0.5 * (a + b) + lm = 0.5 * (a + m) + rm = 0.5 * (m + b) + flm = integrand(np.array([lm]))[0] + frm = integrand(np.array([rm]))[0] + left = _simpson_basic(fa, flm, fm, a, m) + right = _simpson_basic(fm, frm, fb, m, b) + S2 = left + right + err = S2 - whole + tol = max(abs_tol, rel_tol * max(abs(S2), 1e-15)) + if depth >= max_depth: + return S2 + if abs(err) < 15.0 * tol: + return S2 + err / 15.0 # Richardson extrapolation + return _adaptive_simpson_recursive( + integrand, + a, + m, + fa, + flm, + fm, + left, + rel_tol, + abs_tol, + max_depth, + depth + 1, + ) + _adaptive_simpson_recursive( + integrand, + m, + b, + fm, + frm, + fb, + right, + rel_tol, + abs_tol, + max_depth, + depth + 1, + ) + + def _compute_4th_order_gradient(f: np.ndarray, dx: float) -> np.ndarray: """ Compute a 4th-order finite-difference gradient of f with respect to x From 406429e801713e8f343667c54bb02043eade9749 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 19 Jan 2026 14:27:27 +0100 Subject: [PATCH 12/53] Rename two-column task to SalinityGradient Also, switch to a more realistic CT and SA profile. --- polaris/tasks/ocean/two_column/__init__.py | 4 +-- .../{teos10 => salinity_gradient}/__init__.py | 26 ++++++++++++++----- .../salinity_gradient/salinity_gradient.cfg | 8 ++++++ .../tasks/ocean/two_column/teos10/teos10.cfg | 17 ------------ polaris/tasks/ocean/two_column/two_column.cfg | 21 ++++++++++----- 5 files changed, 45 insertions(+), 31 deletions(-) rename polaris/tasks/ocean/two_column/{teos10 => salinity_gradient}/__init__.py (58%) create mode 100644 polaris/tasks/ocean/two_column/salinity_gradient/salinity_gradient.cfg delete mode 100644 polaris/tasks/ocean/two_column/teos10/teos10.cfg diff --git a/polaris/tasks/ocean/two_column/__init__.py b/polaris/tasks/ocean/two_column/__init__.py index f716235f64..16c633abcb 100644 --- a/polaris/tasks/ocean/two_column/__init__.py +++ b/polaris/tasks/ocean/two_column/__init__.py @@ -1,4 +1,4 @@ -from polaris.tasks.ocean.two_column.teos10 import Teos10 +from polaris.tasks.ocean.two_column.salinity_gradient import SalinityGradient def add_two_column_tasks(component): @@ -10,4 +10,4 @@ def add_two_column_tasks(component): component : polaris.tasks.ocean.Ocean the ocean component that the tasks will be added to """ - component.add_task(Teos10(component=component)) + component.add_task(SalinityGradient(component=component)) diff --git a/polaris/tasks/ocean/two_column/teos10/__init__.py b/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py similarity index 58% rename from polaris/tasks/ocean/two_column/teos10/__init__.py rename to polaris/tasks/ocean/two_column/salinity_gradient/__init__.py index 9017932c22..de34b1a060 100644 --- a/polaris/tasks/ocean/two_column/teos10/__init__.py +++ b/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py @@ -5,11 +5,24 @@ from polaris.tasks.ocean.two_column.reference import Reference -class Teos10(Task): +class SalinityGradient(Task): """ - The TEOS-10 two-column test case creates the mesh and initial condition, - then computes a quasi-analytic solution to the specific volume and - geopotential. + The salinity gradient two-column test case tests convergence of the TEOS-10 + pressure-gradient computation in Omega at various horizontal resolutions. + The test uses a fixed horizontal gradient in salinity between two adjacent + ocean columns, with no horizontal gradient in temperature or pseudo-height. + + The test includes a a quasi-analytic solution to horizontal + pressure-gradient force (HPGF) used for verification. It also includes a + set of Omega two-column initial conditions at various resolutions. + + TODO: + Soon, the test will also include single-time-step forward model runs at + each resolution to output Omega's version of the HPGF, followed by an + analysis step to compute the error between Omega's HPGF and the + quasi-analytic solution. We will also compare Omega's HPGF with a python + computation as part of the initial condition that is expected to match + Omega's HPGF to high precision. """ def __init__(self, component): @@ -21,7 +34,7 @@ def __init__(self, component): component : polaris.tasks.ocean.Ocean The ocean component that this task belongs to """ - name = 'teos10' + name = 'salinity_gradient' subdir = os.path.join('two_column', name) super().__init__(component=component, name=name, subdir=subdir) @@ -29,7 +42,8 @@ def __init__(self, component): 'polaris.tasks.ocean.two_column', 'two_column.cfg' ) self.config.add_from_package( - 'polaris.tasks.ocean.two_column.teos10', 'teos10.cfg' + 'polaris.tasks.ocean.two_column.salinity_gradient', + 'salinity_gradient.cfg', ) self._setup_steps() diff --git a/polaris/tasks/ocean/two_column/salinity_gradient/salinity_gradient.cfg b/polaris/tasks/ocean/two_column/salinity_gradient/salinity_gradient.cfg new file mode 100644 index 0000000000..e28b4bdeb8 --- /dev/null +++ b/polaris/tasks/ocean/two_column/salinity_gradient/salinity_gradient.cfg @@ -0,0 +1,8 @@ +# config options for two column testcases +[two_column] + +# salinities for piecewise linear initial conditions at the midpoint between +# the two columns and their gradients +salinity_mid = [35.6, 35.4, 35.0, 34.8, 34.75] +# g/kg / km +salinity_grad = [0.2, 0.2, 0.2, 0.2, 0.2] diff --git a/polaris/tasks/ocean/two_column/teos10/teos10.cfg b/polaris/tasks/ocean/two_column/teos10/teos10.cfg deleted file mode 100644 index f9a5828ed6..0000000000 --- a/polaris/tasks/ocean/two_column/teos10/teos10.cfg +++ /dev/null @@ -1,17 +0,0 @@ -# Options related the ocean component -[ocean] -# Which model, MPAS-Ocean or Omega, is used -model = mpas-ocean - -# Equation of state type, defaults to mpas-ocean default -eos_type = teos-10 - - -# config options for two column testcases -[two_column] - -# salinities for piecewise linear initial conditions at the midpoint between -# the two columns and their gradients -salinity_mid = [35.0, 34.0, 33.0, 32.0] -# g/kg / km -salinity_grad = [0.0, 0.0, 0.0, 0.0] diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index 183fb748a9..e4f3934f75 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -24,6 +24,15 @@ min_pc_fraction = 0.1 rho0 = 1035.0 +# Options related the ocean component +[ocean] +# Which model, MPAS-Ocean or Omega, is used +model = mpas-ocean + +# Equation of state type, defaults to mpas-ocean default +eos_type = teos-10 + + # config options for two column testcases [two_column] @@ -44,17 +53,17 @@ geom_z_bot_grad = 0.0 # pseudo-height, temperatures and salinities for piecewise linear initial # conditions at the midpoint between the two columns and their gradients -z_tilde_mid = [-5.0, -55.0, -105.0, -495.0] +z_tilde_mid = [-5.0, -50.0, -150.0, -300.0, -495.0] # m / km -z_tilde_grad = [0.0, 0.0, 0.0, 0.0] +z_tilde_grad = [0.0, 0.0, 0.0, 0.0, 0.0] -temperature_mid = [20.0, 15.0, 10.0, 5.0] +temperature_mid = [22.0, 20.0, 14.0, 8.0, 5.0] # deg C / km -temperature_grad = [0.0, 0.0, 0.0, 0.0] +temperature_grad = [0.0, 0.0, 0.0, 0.0, 0.0] -salinity_mid = [35.0, 34.0, 33.0, 32.0] +salinity_mid = [35.6, 35.4, 35.0, 34.8, 34.75] # g/kg / km -salinity_grad = [0.0, 0.0, 0.0, 0.0] +salinity_grad = [0.0, 0.0, 0.0, 0.0, 0.0] # number of iterations over which to allow water column adjustments water_col_adjust_iter_count = 6 From d8352edf6776ede18176fad451edb938becc4266 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 20 Jan 2026 14:42:19 +0100 Subject: [PATCH 13/53] Debug and fix two-column Init step --- polaris/tasks/ocean/two_column/init.py | 90 ++++++++++++-------------- 1 file changed, 43 insertions(+), 47 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 6b66c504c1..986005f6d7 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -58,6 +58,7 @@ def run(self): Run this step of the test case """ logger = self.logger + # logger.setLevel(logging.INFO) config = self.config if config.get('ocean', 'model') != 'omega': raise ValueError( @@ -126,6 +127,9 @@ def run(self): z_tilde_mid = ds.zMid h_tilde = ds.layerThickness + logger.debug(f'z_tilde_mid = {z_tilde_mid}') + logger.debug(f'h_tilde = {h_tilde}') + ct, sa = self._interpolate_t_s( ds=ds, z_tilde_mid=z_tilde_mid, @@ -137,6 +141,10 @@ def run(self): rho0=rho0, ) + logger.debug(f'ct = {ct}') + logger.debug(f'sa = {sa}') + logger.debug(f'p_mid = {p_mid}') + spec_vol = compute_specvol( config=config, temperature=ct, @@ -144,23 +152,40 @@ def run(self): pressure=p_mid, ) + logger.debug(f'geom_z_bot = {geom_z_bot}') + logger.debug(f'spec_vol = {spec_vol}') + + min_level_cell = ds.minLevelCell - 1 + max_level_cell = ds.maxLevelCell - 1 + logger.debug(f'min_level_cell = {min_level_cell}') + logger.debug(f'max_level_cell = {max_level_cell}') + geom_z_inter, geom_z_mid = geom_height_from_pseudo_height( geom_z_bot=geom_z_bot, h_tilde=h_tilde, spec_vol=spec_vol, + min_level_cell=min_level_cell, + max_level_cell=max_level_cell, rho0=rho0, ) - min_level_cell = ds.minLevelCell.values - 1 - max_level_cell = ds.maxLevelCell.values - 1 + logger.debug(f'geom_z_inter = {geom_z_inter}') + logger.debug(f'geom_z_mid = {geom_z_mid}') - geom_water_column_thickness = np.zeros(ncells) + # the water column thickness is the difference in the geometric + # height between the first and last valid valid interfaces - for icell in range(ncells): - geom_water_column_thickness[icell] = ( - geom_z_inter[icell, min_level_cell[icell]] - - geom_z_inter[icell, max_level_cell[icell] + 1] - ) + geom_z_min = geom_z_inter.isel( + Time=0, nVertLevelsP1=min_level_cell + ) + geom_z_max = geom_z_inter.isel( + Time=0, nVertLevelsP1=max_level_cell + 1 + ) + # the min is shallower (less negative) than the max + geom_water_column_thickness = geom_z_min - geom_z_max + logger.debug( + f'geom_water_column_thickness = {geom_water_column_thickness}' + ) # scale the pseudo bottom depth proportional to how far off we are # in the geometric water column thickness from the goal @@ -183,31 +208,10 @@ def run(self): f'{pseudo_bottom_depth.values}' ) - ds['temperature'] = xr.DataArray( - data=ct[np.newaxis, :, 1::2], - dims=['Time', 'nCells', 'nVertLevels'], - attrs={ - 'long_name': 'conservative temperature', - 'units': 'degC', - }, - ) - ds['salinity'] = xr.DataArray( - data=sa[np.newaxis, :, 1::2], - dims=['Time', 'nCells', 'nVertLevels'], - attrs={ - 'long_name': 'salinity', - 'units': 'g kg-1', - }, - ) + ds['temperature'] = ct + ds['salinity'] = sa + ds['SpecVol'] = spec_vol - ds['SpecVol'] = xr.DataArray( - data=spec_vol[np.newaxis, :, 1::2], - dims=['Time', 'nCells', 'nVertLevels'], - attrs={ - 'long_name': 'specific volume', - 'units': 'm3 kg-1', - }, - ) ds['Density'] = 1.0 / ds['SpecVol'] ds.Density.attrs['long_name'] = 'density' ds.Density.attrs['units'] = 'kg m-3' @@ -216,23 +220,15 @@ def run(self): ds.ZTildeMid.attrs['long_name'] = 'pseudo-height at layer midpoints' ds.ZTildeMid.attrs['units'] = 'm' - ds['GeomZMid'] = xr.DataArray( - data=geom_z_mid[np.newaxis, :, :], - dims=['Time', 'nCells', 'nVertLevels'], - attrs={ - 'long_name': 'geometric height at layer midpoints', - 'units': 'm', - }, - ) + ds['GeomZMid'] = geom_z_mid + ds.GeomZMid.attrs['long_name'] = 'geometric height at layer midpoints' + ds.GeomZMid.attrs['units'] = 'm' - ds['GeomZInter'] = xr.DataArray( - data=geom_z_inter[np.newaxis, :, :], - dims=['Time', 'nCells', 'nVertLevelsP1'], - attrs={ - 'long_name': 'geometric height at layer interfaces', - 'units': 'm', - }, + ds['GeomZInter'] = geom_z_inter + ds.GeomZInter.attrs['long_name'] = ( + 'geometric height at layer interfaces' ) + ds.GeomZInter.attrs['units'] = 'm' ds.layerThickness.attrs['long_name'] = 'pseudo-layer thickness' ds.layerThickness.attrs['units'] = 'm' From 4d11128886c8e1a7b0fa3011e7d029bd8dca062f Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 20 Jan 2026 15:26:47 +0100 Subject: [PATCH 14/53] Add HPGA and Montgomery potential to init --- polaris/tasks/ocean/two_column/init.py | 114 +++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 986005f6d7..2f8d01067d 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -1,5 +1,6 @@ import numpy as np import xarray as xr +from mpas_tools.cime.constants import constants from mpas_tools.io import write_netcdf from mpas_tools.mesh.conversion import convert, cull from mpas_tools.planar_hex import make_planar_hex_mesh @@ -230,6 +231,10 @@ def run(self): ) ds.GeomZInter.attrs['units'] = 'm' + self._compute_montgomery_and_hpga( + ds=ds, rho0=rho0, dx=resolution, p_mid=p_mid + ) + ds.layerThickness.attrs['long_name'] = 'pseudo-layer thickness' ds.layerThickness.attrs['units'] = 'm' @@ -253,6 +258,115 @@ def run(self): ds.attrs['dc'] = dc self.write_model_dataset(ds, 'initial_state.nc') + def _compute_montgomery_and_hpga( + self, + ds: xr.Dataset, + rho0: float, + dx: float, + p_mid: xr.DataArray, + ) -> None: + """Compute Montgomery potential and a 2-column HPGA. + + This mimics the way Omega will compute the horizontal pressure + gradient: simple finite differences between the two columns. + + The along-column HPGA is computed as: + + HPGA = dM/dx - p_edge * d(alpha)/dx + + where M is the Montgomery potential, alpha is the specific volume, + and p_edge is the pressure averaged between the two columns. + + Outputs are added to ``ds``: + - MontgomeryMid (Time, nCells, nVertLevels) + - MontgomeryInter (Time, nCells, nVertLevels, nbnds) + - HPGAMid (Time, nVertLevels) + - HPGAInter (Time, nVertLevels, nbnds) + """ + + if ds.sizes.get('nCells', 0) != 2: + raise ValueError( + 'The two-column HPGA computation requires exactly 2 cells.' + ) + if dx == 0.0: + raise ValueError('dx must be non-zero for finite differences.') + + g = constants['SHR_CONST_G'] + + # Midpoint quantities (alpha is layerwise constant) + alpha_mid = ds.SpecVol + + # Interface quantities: Omega treats alpha as constant within each + # layer, so interface values are represented as bounds for each layer + # (top and bottom), with discontinuities permitted between layers. + z_tilde_top = ds.zInterface.isel(nVertLevelsP1=slice(0, -1)).rename( + {'nVertLevelsP1': 'nVertLevels'} + ) + z_tilde_bot = ds.zInterface.isel(nVertLevelsP1=slice(1, None)).rename( + {'nVertLevelsP1': 'nVertLevels'} + ) + z_top = ds.GeomZInter.isel(nVertLevelsP1=slice(0, -1)).rename( + {'nVertLevelsP1': 'nVertLevels'} + ) + z_bot = ds.GeomZInter.isel(nVertLevelsP1=slice(1, None)).rename( + {'nVertLevelsP1': 'nVertLevels'} + ) + + z_tilde_bnds = xr.concat([z_tilde_top, z_tilde_bot], dim='nbnds') + z_bnds = xr.concat([z_top, z_bot], dim='nbnds') + # put nbnds last for readability/consistency + z_tilde_bnds = z_tilde_bnds.transpose( + 'Time', 'nCells', 'nVertLevels', 'nbnds' + ) + z_bnds = z_bnds.transpose('Time', 'nCells', 'nVertLevels', 'nbnds') + + alpha_bnds = alpha_mid.expand_dims(nbnds=[0, 1]).transpose( + 'Time', 'nCells', 'nVertLevels', 'nbnds' + ) + montgomery_inter = g * (rho0 * alpha_bnds * z_tilde_bnds + z_bnds) + montgomery_inter = montgomery_inter.transpose( + 'Time', 'nCells', 'nVertLevels', 'nbnds' + ) + + # Omega convention: Montgomery potential at midpoints is the mean of + # the two adjacent interface values. + montgomery_mid = 0.5 * ( + montgomery_inter.isel(nbnds=0) + montgomery_inter.isel(nbnds=1) + ) + + # 2-column finite differences across the pair + dM_dx_mid = ( + montgomery_mid.isel(nCells=1) - montgomery_mid.isel(nCells=0) + ) / dx + dalpha_dx_mid = ( + alpha_mid.isel(nCells=1) - alpha_mid.isel(nCells=0) + ) / dx + + # Pressure (positive downward), averaged to the edge between columns + p_edge_mid = 0.5 * (p_mid.isel(nCells=0) + p_mid.isel(nCells=1)) + + hpga_mid = dM_dx_mid - p_edge_mid * dalpha_dx_mid + + ds['MontgomeryMid'] = montgomery_mid + ds.MontgomeryMid.attrs['long_name'] = ( + 'Montgomery potential at layer midpoints' + ) + ds.MontgomeryMid.attrs['units'] = 'm2 s-2' + + ds['MontgomeryInter'] = montgomery_inter + ds.MontgomeryInter.attrs['long_name'] = ( + 'Montgomery potential at layer interfaces (bounds)' + ) + ds.MontgomeryInter.attrs['units'] = 'm2 s-2' + + ds['HPGA'] = hpga_mid + ds.HPGA.attrs = { + 'long_name': ( + 'along-layer pressure gradient acceleration at layer midpoints' + ), + 'units': 'm s-2', + } + def _get_geom_ssh_z_bot( self, x: np.ndarray ) -> tuple[xr.DataArray, xr.DataArray]: From 53fb49cee1b33a8fff8265757702aa809ea55efe Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 20 Jan 2026 15:32:38 +0100 Subject: [PATCH 15/53] Fix long names and units in init --- polaris/tasks/ocean/two_column/init.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 2f8d01067d..a08f5e0498 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -238,6 +238,9 @@ def run(self): ds.layerThickness.attrs['long_name'] = 'pseudo-layer thickness' ds.layerThickness.attrs['units'] = 'm' + ds.zMid.attrs['long_name'] = 'pseudo-height at layer midpoints' + ds.zMid.attrs['units'] = 'm' + nedges = ds_mesh.sizes['nEdges'] nvertlevels = ds.sizes['nVertLevels'] @@ -378,8 +381,22 @@ def _get_geom_ssh_z_bot( geom_ssh = get_array_from_mid_grad(config, 'geom_ssh', x) geom_z_bot = get_array_from_mid_grad(config, 'geom_z_bot', x) return ( - xr.DataArray(data=geom_ssh, dims=['nCells']), - xr.DataArray(data=geom_z_bot, dims=['nCells']), + xr.DataArray( + data=geom_ssh, + dims=['nCells'], + attrs={ + 'long_name': 'sea surface geometric height', + 'units': 'm', + }, + ), + xr.DataArray( + data=geom_z_bot, + dims=['nCells'], + attrs={ + 'long_name': 'seafloor geometric height', + 'units': 'm', + }, + ), ) def _init_z_tilde_vert_coord( @@ -393,8 +410,12 @@ def _init_z_tilde_vert_coord( ds = ds_mesh.copy() ds['bottomDepth'] = pseudo_bottom_depth + ds.bottomDepth.attrs['long_name'] = 'seafloor pseudo-height' + ds.bottomDepth.attrs['units'] = 'm' # the pseudo-ssh is always zero (like the surface pressure) ds['ssh'] = xr.zeros_like(pseudo_bottom_depth) + ds.ssh.attrs['long_name'] = 'sea surface pseudo-height' + ds.ssh.attrs['units'] = 'm' init_vertical_coord(config, ds) return ds From 3f90b35bccaee1b234e8a31b951c7ebb33f27cc9 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 20 Jan 2026 16:23:28 +0100 Subject: [PATCH 16/53] Fix reference output --- polaris/tasks/ocean/two_column/reference.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index d4fedcb7c2..2893984b9d 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -149,9 +149,10 @@ def run(self): ) hpga = dM_dx - p0 * dalpha_dx + cells = [1, 3] # indices for -0.5km and 0.5km ds = xr.Dataset() ds['temperature'] = xr.DataArray( - data=ct[np.newaxis, 1:2, 1::2], + data=ct[np.newaxis, cells, 1::2], dims=['Time', 'nCells', 'nVertLevels'], attrs={ 'long_name': 'conservative temperature', @@ -159,7 +160,7 @@ def run(self): }, ) ds['salinity'] = xr.DataArray( - data=sa[np.newaxis, 1:2, 1::2], + data=sa[np.newaxis, cells, 1::2], dims=['Time', 'nCells', 'nVertLevels'], attrs={ 'long_name': 'salinity', @@ -168,7 +169,7 @@ def run(self): ) ds['SpecVol'] = xr.DataArray( - data=spec_vol[np.newaxis, 1:2, 1::2], + data=spec_vol[np.newaxis, cells, 1::2], dims=['Time', 'nCells', 'nVertLevels'], attrs={ 'long_name': 'specific volume', @@ -180,21 +181,21 @@ def run(self): ds.Density.attrs['units'] = 'kg m-3' ds['ZTildeMid'] = xr.DataArray( - data=z_tilde[np.newaxis, 1:2, 1::2], + data=z_tilde[np.newaxis, cells, 1::2], dims=['Time', 'nCells', 'nVertLevels'], ) ds.ZTildeMid.attrs['long_name'] = 'pseudo-height at layer midpoints' ds.ZTildeMid.attrs['units'] = 'm' ds['ZTildeInter'] = xr.DataArray( - data=z_tilde[np.newaxis, 1:2, 0::2], + data=z_tilde[np.newaxis, cells, 0::2], dims=['Time', 'nCells', 'nVertLevelsP1'], ) ds.ZTildeInter.attrs['long_name'] = 'pseudo-height at layer interfaces' ds.ZTildeInter.attrs['units'] = 'm' ds['GeomZMid'] = xr.DataArray( - data=z[np.newaxis, 1:2, 1::2], + data=z[np.newaxis, cells, 1::2], dims=['Time', 'nCells', 'nVertLevels'], attrs={ 'long_name': 'geometric height at layer midpoints', @@ -203,7 +204,7 @@ def run(self): ) ds['GeomZInter'] = xr.DataArray( - data=z[np.newaxis, 1:2, 0::2], + data=z[np.newaxis, cells, 0::2], dims=['Time', 'nCells', 'nVertLevelsP1'], attrs={ 'long_name': 'geometric height at layer interfaces', @@ -212,7 +213,7 @@ def run(self): ) ds['MontgomeryMid'] = xr.DataArray( - data=montgomery[np.newaxis, 1:2, 1::2], + data=montgomery[np.newaxis, cells, 1::2], dims=['Time', 'nCells', 'nVertLevels'], attrs={ 'long_name': 'Montgomery potential at layer midpoints', @@ -221,7 +222,7 @@ def run(self): ) ds['MontgomeryInter'] = xr.DataArray( - data=montgomery[np.newaxis, 1:2, 0::2], + data=montgomery[np.newaxis, cells, 0::2], dims=['Time', 'nCells', 'nVertLevelsP1'], attrs={ 'long_name': 'Montgomery potential at layer interfaces', From f64471e8a142f0fc03a875c925a5abd851311b06 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 28 Jan 2026 16:13:38 +0100 Subject: [PATCH 17/53] Fix reference gradient, pressure and dx --- polaris/tasks/ocean/two_column/reference.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index 2893984b9d..a1087dd423 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -131,6 +131,8 @@ def run(self): geom_z_bot=geom_z_bot.isel(nCells=icol).item(), ) + dx = resolution * 1e3 # m + # compute montgomery potential M = alpha * p + g * z g = constants['SHR_CONST_G'] montgomery = g * (rho0 * spec_vol * z_tilde + z) @@ -138,15 +140,11 @@ def run(self): # the HPGF is grad(M) - p * grad(alpha) # Here we just compute the gradient at x=0 using a 4th-order # finite-difference stencil - p0 = rho0 * g * 0.5 * z_tilde[2, :] + p0 = -rho0 * g * z_tilde[2, :] # indices for -1.5dx, -0.5dx, 0.5dx, 1.5dx grad_indices = [0, 1, 3, 4] - dM_dx = _compute_4th_order_gradient( - montgomery[grad_indices, :], resolution - ) - dalpha_dx = _compute_4th_order_gradient( - spec_vol[grad_indices, :], resolution - ) + dM_dx = _compute_4th_order_gradient(montgomery[grad_indices, :], dx) + dalpha_dx = _compute_4th_order_gradient(spec_vol[grad_indices, :], dx) hpga = dM_dx - p0 * dalpha_dx cells = [1, 3] # indices for -0.5km and 0.5km @@ -735,7 +733,8 @@ def _compute_4th_order_gradient(f: np.ndarray, dx: float) -> np.ndarray: at x=0, assuming values at x = dx * [-1.5, -0.5, 0.5, 1.5]. The stencil is: - f'(0) ≈ [-f(1.5dx) + 9 f(0.5dx) - 9 f(-0.5dx) + f(-1.5dx)] / (8 dx) + f'(0) ≈ [f(-1.5dx) - 27 f(-0.5dx) + 27 f(0.5dx) - f(1.5dx)] + / (24 dx) Here we assume f[0,:], f[1,:], f[2,:], f[3,:] correspond to x = -1.5dx, -0.5dx, 0.5dx, 1.5dx respectively. @@ -746,6 +745,6 @@ def _compute_4th_order_gradient(f: np.ndarray, dx: float) -> np.ndarray: ) # gradient at x = 0 using the non-uniform 4-point stencil - df_dx = (-f[3, :] + 9.0 * f[2, :] - 9.0 * f[1, :] + f[0, :]) / (8.0 * dx) + df_dx = (f[0, :] - 27.0 * f[1, :] + 27.0 * f[2, :] - f[3, :]) / (24.0 * dx) return df_dx From abf5eead2221fc5351bb90485602528b0aa3fa8e Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 28 Jan 2026 16:15:10 +0100 Subject: [PATCH 18/53] Fix dx in init --- polaris/tasks/ocean/two_column/init.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index a08f5e0498..e07be6610c 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -77,6 +77,7 @@ def run(self): nx = 2 ny = 2 dc = 1e3 * resolution + dx = 1e3 * resolution ds_mesh = make_planar_hex_mesh( nx=nx, ny=ny, dc=dc, nonperiodic_x=True, nonperiodic_y=True ) @@ -231,9 +232,7 @@ def run(self): ) ds.GeomZInter.attrs['units'] = 'm' - self._compute_montgomery_and_hpga( - ds=ds, rho0=rho0, dx=resolution, p_mid=p_mid - ) + self._compute_montgomery_and_hpga(ds=ds, rho0=rho0, dx=dx, p_mid=p_mid) ds.layerThickness.attrs['long_name'] = 'pseudo-layer thickness' ds.layerThickness.attrs['units'] = 'm' From d901b22654c87e9eb7c31120dbf63181da5cbc97 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 28 Jan 2026 16:16:51 +0100 Subject: [PATCH 19/53] Add more outputs for debugging --- polaris/tasks/ocean/two_column/init.py | 28 +++++++ polaris/tasks/ocean/two_column/reference.py | 91 ++++++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index e07be6610c..dbad8020f8 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -344,6 +344,10 @@ def _compute_montgomery_and_hpga( alpha_mid.isel(nCells=1) - alpha_mid.isel(nCells=0) ) / dx + dsa_dx_mid = ( + ds.salinity.isel(nCells=1) - ds.salinity.isel(nCells=0) + ) / dx + # Pressure (positive downward), averaged to the edge between columns p_edge_mid = 0.5 * (p_mid.isel(nCells=0) + p_mid.isel(nCells=1)) @@ -369,6 +373,30 @@ def _compute_montgomery_and_hpga( 'units': 'm s-2', } + ds['dMdxMid'] = dM_dx_mid + ds.dMdxMid.attrs = { + 'long_name': 'Gradient of Montgomery potential at layer midpoints', + 'units': 'm s-2', + } + + ds['PEdgeMid'] = p_edge_mid + ds.PEdgeMid.attrs = { + 'long_name': 'Pressure at horizontal edge and layer midpoints', + 'units': 'Pa', + } + + ds['dalphadxMid'] = dalpha_dx_mid + ds.dalphadxMid.attrs = { + 'long_name': 'Gradient of specific volume at layer midpoints', + 'units': 'm2 kg-1', + } + + ds['dSAdxMid'] = dsa_dx_mid + ds.dSAdxMid.attrs = { + 'long_name': 'Gradient of absolute salinity at layer midpoints', + 'units': 'g kg-1 m-1', + } + def _get_geom_ssh_z_bot( self, x: np.ndarray ) -> tuple[xr.DataArray, xr.DataArray]: diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index a1087dd423..cb592f8f17 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -137,6 +137,27 @@ def run(self): g = constants['SHR_CONST_G'] montgomery = g * (rho0 * spec_vol * z_tilde + z) + dx = resolution * 1.0e3 # m + + check_gradient = False + + if check_gradient: + # sanity checks for 4th-order gradient stencil + x_m = dx * np.array([-1.5, -0.5, 0.5, 1.5], dtype=float) + + # exact for polynomials up to degree 3 + poly = 2.5 + 1.2 * x_m - 0.7 * x_m**2 + 0.9 * x_m**3 + _check_gradient( + self.logger, poly, expected=1.2, name='cubic polynomial', dx=dx + ) + + # smooth function check (should be highly accurate) + k = 2.0 * np.pi / (20.0 * dx) + sine = np.sin(k * x_m) + _check_gradient( + self.logger, sine, expected=k, name='sin(kx)', dx=dx + ) + # the HPGF is grad(M) - p * grad(alpha) # Here we just compute the gradient at x=0 using a 4th-order # finite-difference stencil @@ -147,7 +168,10 @@ def run(self): dalpha_dx = _compute_4th_order_gradient(spec_vol[grad_indices, :], dx) hpga = dM_dx - p0 * dalpha_dx - cells = [1, 3] # indices for -0.5km and 0.5km + dsa_dx = _compute_4th_order_gradient(sa[grad_indices, :], dx) + + # cells = [1, 3] # indices for -0.5km and 0.5km + cells = np.arange(len(x)) # use all columns ds = xr.Dataset() ds['temperature'] = xr.DataArray( data=ct[np.newaxis, cells, 1::2], @@ -248,6 +272,44 @@ def run(self): }, ) + ds['dMdxMid'] = xr.DataArray( + data=dM_dx[np.newaxis, 1::2], + dims=['Time', 'nVertLevels'], + attrs={ + 'long_name': 'Gradient of Montgomery potential at layer ' + 'midpoints', + 'units': 'm s-2', + }, + ) + + ds['PEdgeMid'] = xr.DataArray( + data=p0[np.newaxis, 1::2], + dims=['Time', 'nVertLevels'], + attrs={ + 'long_name': 'Pressure at horizontal edge and layer midpoints', + 'units': 'Pa', + }, + ) + + ds['dalphadxMid'] = xr.DataArray( + data=dalpha_dx[np.newaxis, 1::2], + dims=['Time', 'nVertLevels'], + attrs={ + 'long_name': 'Gradient of specific volume at layer midpoints', + 'units': 'm2 kg-1', + }, + ) + + ds['dSAdxMid'] = xr.DataArray( + data=dsa_dx[np.newaxis, 1::2], + dims=['Time', 'nVertLevels'], + attrs={ + 'long_name': 'Gradient of absolute salinity at layer ' + 'midpoints', + 'units': 'g kg-1 m-1', + }, + ) + self.write_model_dataset(ds, 'reference_solution.nc') def _get_geom_ssh_z_bot( @@ -727,6 +789,33 @@ def _adaptive_simpson_recursive( ) +def _check_gradient( + logger, + values: np.ndarray, + expected: float, + name: str, + dx: float | None = None, + rel_tol: float = 1.0e-12, + abs_tol: float = 5.0e-8, +) -> None: + """Check the 4th-order gradient stencil against an analytic value.""" + if dx is None: + raise ValueError('dx must be provided for gradient checks.') + calc = _compute_4th_order_gradient(values[:, np.newaxis], dx)[0] + err = calc - expected + logger.info( + f'4th-order gradient check {name}: ' + f'calc={calc:.6e}, expected={expected:.6e}, ' + f'err={err:.3e}' + ) + tol = max(abs_tol, rel_tol * max(1.0, abs(expected))) + if not np.isfinite(calc) or abs(err) > tol: + raise ValueError( + f'4th-order gradient check failed for {name}: ' + f'calc={calc}, expected={expected}, err={err}' + ) + + def _compute_4th_order_gradient(f: np.ndarray, dx: float) -> np.ndarray: """ Compute a 4th-order finite-difference gradient of f with respect to x From fc9c1423fe96152cebc0eea27e2df170b4f85801 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Fri, 30 Jan 2026 14:10:43 +0100 Subject: [PATCH 20/53] Support different vertical coords in each column --- polaris/tasks/ocean/two_column/init.py | 26 ++++++++++++++++--- polaris/tasks/ocean/two_column/reference.py | 22 +++++++++------- polaris/tasks/ocean/two_column/two_column.cfg | 11 +++++--- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index dbad8020f8..f18035db98 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -124,7 +124,7 @@ def run(self): ) for iter in range(water_col_adjust_iter_count): - ds = self._init_z_tilde_vert_coord(ds_mesh, pseudo_bottom_depth) + ds = self._init_z_tilde_vert_coord(ds_mesh, pseudo_bottom_depth, x) z_tilde_mid = ds.zMid h_tilde = ds.layerThickness @@ -427,13 +427,18 @@ def _get_geom_ssh_z_bot( ) def _init_z_tilde_vert_coord( - self, ds_mesh: xr.Dataset, pseudo_bottom_depth: xr.DataArray + self, + ds_mesh: xr.Dataset, + pseudo_bottom_depth: xr.DataArray, + x: np.ndarray, ) -> xr.Dataset: """ Initialize variables for a z-tilde vertical coordinate. """ config = self.config + z_tilde_bot = get_array_from_mid_grad(config, 'z_tilde_bot', x) + ds = ds_mesh.copy() ds['bottomDepth'] = pseudo_bottom_depth @@ -443,7 +448,22 @@ def _init_z_tilde_vert_coord( ds['ssh'] = xr.zeros_like(pseudo_bottom_depth) ds.ssh.attrs['long_name'] = 'sea surface pseudo-height' ds.ssh.attrs['units'] = 'm' - init_vertical_coord(config, ds) + ds_list = [] + for icell in range(ds.sizes['nCells']): + # initialize the vertical coordinate for each column separately + # to allow different pseudo-bottom depths + pseudo_bottom_depth = -z_tilde_bot[icell] + # keep `nCells` dimension + ds_cell = ds.isel(nCells=slice(icell, icell + 1)) + local_config = config.copy() + local_config.set( + 'vertical_grid', 'bottom_depth', str(pseudo_bottom_depth) + ) + + init_vertical_coord(local_config, ds_cell) + ds_list.append(ds_cell) + + ds = xr.concat(ds_list, dim='nCells') return ds def _get_z_tilde_t_s_nodes( diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index cb592f8f17..865f49fac2 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -95,7 +95,7 @@ def run(self): x = resolution * np.array([-1.5, -0.5, 0.0, 0.5, 1.5], dtype=float) - geom_ssh, geom_z_bot = self._get_geom_ssh_z_bot(x) + geom_ssh, geom_z_bot, z_tilde_bot = self._get_ssh_z_bot(x) vert_levels = config.getint('vertical_grid', 'vert_levels') if vert_levels is None: @@ -129,6 +129,7 @@ def run(self): salinity_node=salinity_node[icol, :], geom_ssh=geom_ssh.isel(nCells=icol).item(), geom_z_bot=geom_z_bot.isel(nCells=icol).item(), + z_tilde_bot=z_tilde_bot.isel(nCells=icol).item(), ) dx = resolution * 1e3 # m @@ -312,32 +313,33 @@ def run(self): self.write_model_dataset(ds, 'reference_solution.nc') - def _get_geom_ssh_z_bot( + def _get_ssh_z_bot( self, x: np.ndarray - ) -> tuple[xr.DataArray, xr.DataArray]: + ) -> tuple[xr.DataArray, xr.DataArray, xr.DataArray]: """ - Get the geometric sea surface height and sea floor height for each - column from the configuration. + Get the geometric sea surface height and sea floor height, as well as + sea floor pseudo-height for each column from the configuration. """ config = self.config geom_ssh = get_array_from_mid_grad(config, 'geom_ssh', x) geom_z_bot = get_array_from_mid_grad(config, 'geom_z_bot', x) + z_tilde_bot = get_array_from_mid_grad(config, 'z_tilde_bot', x) return ( xr.DataArray(data=geom_ssh, dims=['nCells']), xr.DataArray(data=geom_z_bot, dims=['nCells']), + xr.DataArray(data=z_tilde_bot, dims=['nCells']), ) def _init_z_tilde_interface( - self, pseudo_bottom_depth: float + self, pseudo_bottom_depth: float, z_tilde_bot: float ) -> tuple[np.ndarray, int]: """ Compute z-tilde vertical interfaces. """ section = self.config['vertical_grid'] vert_levels = section.getint('vert_levels') - bottom_depth = section.getfloat('bottom_depth') z_tilde_interface = np.linspace( - 0.0, -bottom_depth, vert_levels + 1, dtype=float + 0.0, z_tilde_bot, vert_levels + 1, dtype=float ) z_tilde_interface = np.maximum(z_tilde_interface, -pseudo_bottom_depth) dz = z_tilde_interface[0:-1] - z_tilde_interface[1:] @@ -385,6 +387,7 @@ def _compute_column( salinity_node: np.ndarray, geom_ssh: float, geom_z_bot: float, + z_tilde_bot: float, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: config = self.config logger = self.logger @@ -420,7 +423,8 @@ def _compute_column( for iter in range(water_col_adjust_iter_count): z_tilde_inter, max_layer = self._init_z_tilde_interface( - pseudo_bottom_depth=pseudo_bottom_depth + pseudo_bottom_depth=pseudo_bottom_depth, + z_tilde_bot=z_tilde_bot, ) vert_levels = len(z_tilde_inter) - 1 diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index e4f3934f75..d31d8b672b 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -7,10 +7,6 @@ grid_type = uniform # Number of vertical levels vert_levels = 60 -# Depth of the bottom of the ocean (with some buffer compared with geometric -# sea floor height) -bottom_depth = 600.0 - # The type of vertical coordinate (e.g. z-level, z-star, z-tilde, sigma) coord_type = z-tilde @@ -51,6 +47,13 @@ geom_z_bot_mid = -500.0 # m / km geom_z_bot_grad = 0.0 +# Pseudo-height of the bottom of the ocean (with some buffer compared with +# geometric sea floor height) at the midpoint between the two columns +# and its gradient +z_tilde_bot_mid = -600.0 +# m / km +z_tilde_bot_grad = 0.0 + # pseudo-height, temperatures and salinities for piecewise linear initial # conditions at the midpoint between the two columns and their gradients z_tilde_mid = [-5.0, -50.0, -150.0, -300.0, -495.0] From 8fa2f491346557f0a9a0defafcf97045915caff9 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 2 Feb 2026 10:29:25 +0100 Subject: [PATCH 21/53] Add two-column test with z-tilde gradient --- polaris/tasks/ocean/two_column/__init__.py | 2 + .../two_column/salinity_gradient/__init__.py | 13 +-- .../two_column/ztilde_gradient/__init__.py | 82 +++++++++++++++++++ .../ztilde_gradient/ztilde_gradient.cfg | 9 ++ 4 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py create mode 100644 polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg diff --git a/polaris/tasks/ocean/two_column/__init__.py b/polaris/tasks/ocean/two_column/__init__.py index 16c633abcb..e5452d9bcf 100644 --- a/polaris/tasks/ocean/two_column/__init__.py +++ b/polaris/tasks/ocean/two_column/__init__.py @@ -1,4 +1,5 @@ from polaris.tasks.ocean.two_column.salinity_gradient import SalinityGradient +from polaris.tasks.ocean.two_column.ztilde_gradient import ZTildeGradient def add_two_column_tasks(component): @@ -11,3 +12,4 @@ def add_two_column_tasks(component): the ocean component that the tasks will be added to """ component.add_task(SalinityGradient(component=component)) + component.add_task(ZTildeGradient(component=component)) diff --git a/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py b/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py index de34b1a060..ab57d21490 100644 --- a/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py +++ b/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py @@ -13,16 +13,17 @@ class SalinityGradient(Task): ocean columns, with no horizontal gradient in temperature or pseudo-height. The test includes a a quasi-analytic solution to horizontal - pressure-gradient force (HPGF) used for verification. It also includes a - set of Omega two-column initial conditions at various resolutions. + pressure-gradient acceleration (HPGA) used for verification. It also + includes a set of Omega two-column initial conditions at various + resolutions. TODO: Soon, the test will also include single-time-step forward model runs at - each resolution to output Omega's version of the HPGF, followed by an - analysis step to compute the error between Omega's HPGF and the - quasi-analytic solution. We will also compare Omega's HPGF with a python + each resolution to output Omega's version of the HPGA, followed by an + analysis step to compute the error between Omega's HPGA and the + quasi-analytic solution. We will also compare Omega's HPGA with a python computation as part of the initial condition that is expected to match - Omega's HPGF to high precision. + Omega's HPGA to high precision. """ def __init__(self, component): diff --git a/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py b/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py new file mode 100644 index 0000000000..efe00dbb15 --- /dev/null +++ b/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py @@ -0,0 +1,82 @@ +import os + +from polaris import Task +from polaris.tasks.ocean.two_column.init import Init +from polaris.tasks.ocean.two_column.reference import Reference + + +class ZTildeGradient(Task): + """ + The z-tilde gradient two-column test case tests convergence of the TEOS-10 + pressure-gradient computation in Omega at various horizontal resolutions. + The test prescribes a gradient in the bottom depth of the pseudo-height + vertical coordinate between two adjacent ocean columns, with no horizontal + gradient in temperature or salinity. + + The test includes a a quasi-analytic solution to horizontal + pressure-gradient acceleration (HPGA) used for verification. It also + includes a set of Omega two-column initial conditions at various + resolutions. + + TODO: + Soon, the test will also include single-time-step forward model runs at + each resolution to output Omega's version of the HPGA, followed by an + analysis step to compute the error between Omega's HPGA and the + quasi-analytic solution. We will also compare Omega's HPGA with a python + computation as part of the initial condition that is expected to match + Omega's HPGA to high precision. + """ + + def __init__(self, component): + """ + Create the test case + + Parameters + ---------- + component : polaris.tasks.ocean.Ocean + The ocean component that this task belongs to + """ + name = 'ztilde_gradient' + subdir = os.path.join('two_column', name) + super().__init__(component=component, name=name, subdir=subdir) + + self.config.add_from_package( + 'polaris.tasks.ocean.two_column', 'two_column.cfg' + ) + self.config.add_from_package( + 'polaris.tasks.ocean.two_column.ztilde_gradient', + 'ztilde_gradient.cfg', + ) + + self._setup_steps() + + def configure(self): + """ + Set config options for the test case + """ + super().configure() + + # set up the steps again in case a user has provided new resolutions + self._setup_steps() + + def _setup_steps(self): + """ + setup steps given resolutions + """ + section = self.config['two_column'] + resolutions = section.getexpression('resolutions') + + # start fresh with no steps + for step in list(self.steps.values()): + self.remove_step(step) + + self.add_step(Reference(component=self.component, indir=self.subdir)) + + for resolution in resolutions: + self.add_step( + Init( + component=self.component, + resolution=resolution, + indir=self.subdir, + ) + ) diff --git a/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg b/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg new file mode 100644 index 0000000000..1b268a80a0 --- /dev/null +++ b/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg @@ -0,0 +1,9 @@ +# config options for two column testcases +[two_column] + +# Pseudo-height of the bottom of the ocean (with some buffer compared with +# geometric sea floor height) at the midpoint between the two columns +# and its gradient +z_tilde_bot_mid = -600.0 +# m / km +z_tilde_bot_grad = 10.0 From aaab0e246ff1293f37906199296ccaf5f39d5325 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 2 Feb 2026 12:47:32 +0100 Subject: [PATCH 22/53] Switch to interpolating CT and SA with monotone PCHIP --- polaris/tasks/ocean/two_column/column.py | 81 +++++++++++++++++++ polaris/tasks/ocean/two_column/init.py | 27 +++++-- polaris/tasks/ocean/two_column/reference.py | 23 ++++-- polaris/tasks/ocean/two_column/two_column.cfg | 2 +- 4 files changed, 121 insertions(+), 12 deletions(-) diff --git a/polaris/tasks/ocean/two_column/column.py b/polaris/tasks/ocean/two_column/column.py index 8bc45754fc..057b41349e 100644 --- a/polaris/tasks/ocean/two_column/column.py +++ b/polaris/tasks/ocean/two_column/column.py @@ -1,4 +1,7 @@ +from typing import Callable + import numpy as np +from scipy.interpolate import PchipInterpolator from polaris.config import PolarisConfigParser @@ -55,3 +58,81 @@ def get_array_from_mid_grad( ) return array + + +def get_pchip_interpolator( + z_tilde_nodes: np.ndarray, + values_nodes: np.ndarray, + name: str, +) -> Callable[[np.ndarray], np.ndarray]: + """ + Create a monotone PCHIP interpolator for values defined at z-tilde nodes. + + Parameters + ---------- + z_tilde_nodes : np.ndarray + One-dimensional z-tilde node locations. Must be strictly monotonic + (increasing or decreasing). + values_nodes : np.ndarray + One-dimensional values at ``z_tilde_nodes``. + name : str + A descriptive name used in error messages. + + Returns + ------- + interpolator : callable + A function that maps target z-tilde values to interpolated values. + Targets must lie within the node range; extrapolation is not allowed. + """ + z_tilde_nodes = np.asarray(z_tilde_nodes, dtype=float) + values_nodes = np.asarray(values_nodes, dtype=float) + + if z_tilde_nodes.ndim != 1 or values_nodes.ndim != 1: + raise ValueError('z_tilde_nodes and values_nodes must be 1-D arrays.') + if len(z_tilde_nodes) != len(values_nodes): + raise ValueError( + f'Lengths of z_tilde_nodes and {name} nodes must match.' + ) + if len(z_tilde_nodes) < 2: + raise ValueError('At least two z_tilde nodes are required.') + + dz = np.diff(z_tilde_nodes) + is_increasing = np.all(dz > 0.0) + is_decreasing = np.all(dz < 0.0) + if not (is_increasing or is_decreasing): + raise ValueError( + 'z_tilde_nodes must be strictly monotonic (increasing or ' + 'decreasing).' + ) + + if is_decreasing: + x = -z_tilde_nodes + else: + x = z_tilde_nodes + + x_min = x.min() + x_max = x.max() + + interpolator = PchipInterpolator(x, values_nodes, extrapolate=False) + + def _interp(z_tilde_targets: np.ndarray) -> np.ndarray: + z_tilde_targets = np.asarray(z_tilde_targets, dtype=float) + if np.any(~np.isfinite(z_tilde_targets)): + raise ValueError('Target z_tilde values must be finite.') + if is_decreasing: + x_target = -z_tilde_targets + else: + x_target = z_tilde_targets + if np.any(x_target < x_min) or np.any(x_target > x_max): + raise ValueError( + f'Target z_tilde values for {name} must fall within the ' + 'node range; extrapolation is not supported.' + ) + values = interpolator(x_target) + if np.any(~np.isfinite(values)): + raise ValueError( + f'PCHIP interpolation produced non-finite values for {name}.' + ) + return values + + return _interp diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index f18035db98..186f644a15 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -13,7 +13,10 @@ pressure_from_z_tilde, ) from polaris.resolution import resolution_to_string -from polaris.tasks.ocean.two_column.column import get_array_from_mid_grad +from polaris.tasks.ocean.two_column.column import ( + get_array_from_mid_grad, + get_pchip_interpolator, +) class Init(OceanIOStep): @@ -529,7 +532,7 @@ def _interpolate_t_s( z_tilde = z_tilde_node[icell, :] temperatures = t_node[icell, :] salinities = s_node[icell, :] - z_mid = z_tilde_mid.isel(nCells=icell).values + z_tilde_mid_col = z_tilde_mid.isel(Time=0, nCells=icell).values if len(z_tilde) < 2: raise ValueError( @@ -537,10 +540,24 @@ def _interpolate_t_s( 'define piecewise linear initial conditions.' ) - temperature_np[0, icell, :] = np.interp( - -z_mid, -z_tilde, temperatures + t_interp = get_pchip_interpolator( + z_tilde_nodes=z_tilde, + values_nodes=temperatures, + name='temperature', + ) + s_interp = get_pchip_interpolator( + z_tilde_nodes=z_tilde, + values_nodes=salinities, + name='salinity', ) - salinity_np[0, icell, :] = np.interp(-z_mid, -z_tilde, salinities) + valid = np.isfinite(z_tilde_mid_col) + temperature_np[0, icell, :] = np.nan + salinity_np[0, icell, :] = np.nan + if np.any(valid): + temperature_np[0, icell, valid] = t_interp( + z_tilde_mid_col[valid] + ) + salinity_np[0, icell, valid] = s_interp(z_tilde_mid_col[valid]) temperature = xr.DataArray( data=temperature_np, diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index 865f49fac2..9b1cfb2a59 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -8,7 +8,10 @@ from mpas_tools.cime.constants import constants from polaris.ocean.model import OceanIOStep -from polaris.tasks.ocean.two_column.column import get_array_from_mid_grad +from polaris.tasks.ocean.two_column.column import ( + get_array_from_mid_grad, + get_pchip_interpolator, +) class Reference(OceanIOStep): @@ -589,14 +592,22 @@ def _integrate_geometric_height( g = constants['SHR_CONST_G'] + sa_interp = get_pchip_interpolator( + z_tilde_nodes=z_tilde_nodes, + values_nodes=sa_nodes, + name='salinity', + ) + ct_interp = get_pchip_interpolator( + z_tilde_nodes=z_tilde_nodes, + values_nodes=ct_nodes, + name='temperature', + ) + def spec_vol_ct_sa_at( z_tilde: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - # np.interp requires xp to be increasing; our - # z_tilde_nodes are decreasing, so flip the signs of - # z_tilde and z_tilde_nodes for the interpolation - sa = np.interp(-z_tilde, -z_tilde_nodes, sa_nodes) - ct = np.interp(-z_tilde, -z_tilde_nodes, ct_nodes) + sa = sa_interp(z_tilde) + ct = ct_interp(z_tilde) p_pa = -rho0 * g * z_tilde # gsw expects pressure in dbar spec_vol = gsw.specvol(sa, ct, p_pa * 1.0e-4) diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index d31d8b672b..ebfcb211d3 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -56,7 +56,7 @@ z_tilde_bot_grad = 0.0 # pseudo-height, temperatures and salinities for piecewise linear initial # conditions at the midpoint between the two columns and their gradients -z_tilde_mid = [-5.0, -50.0, -150.0, -300.0, -495.0] +z_tilde_mid = [0.0, -50.0, -150.0, -300.0, -600.0] # m / km z_tilde_grad = [0.0, 0.0, 0.0, 0.0, 0.0] From 921bd58a08b16dc3e558beb0f193baf8f5795d9d Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 2 Feb 2026 13:14:56 +0100 Subject: [PATCH 23/53] Fix sign of pressure term in Montgomery potential --- polaris/tasks/ocean/two_column/init.py | 3 ++- polaris/tasks/ocean/two_column/reference.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 186f644a15..3f51be40eb 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -328,7 +328,8 @@ def _compute_montgomery_and_hpga( alpha_bnds = alpha_mid.expand_dims(nbnds=[0, 1]).transpose( 'Time', 'nCells', 'nVertLevels', 'nbnds' ) - montgomery_inter = g * (rho0 * alpha_bnds * z_tilde_bnds + z_bnds) + # Montgomery: M = alpha * p + g * z, with p = -rho0 * g * z_tilde + montgomery_inter = g * (z_bnds - rho0 * alpha_bnds * z_tilde_bnds) montgomery_inter = montgomery_inter.transpose( 'Time', 'nCells', 'nVertLevels', 'nbnds' ) diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index 9b1cfb2a59..14f495bb00 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -137,9 +137,10 @@ def run(self): dx = resolution * 1e3 # m - # compute montgomery potential M = alpha * p + g * z + # compute Montgomery potential M = alpha * p + g * z + # with p = -rho0 * g * z_tilde (p positive downward) g = constants['SHR_CONST_G'] - montgomery = g * (rho0 * spec_vol * z_tilde + z) + montgomery = g * (z - rho0 * spec_vol * z_tilde) dx = resolution * 1.0e3 # m From 28ffdbfcef4dd416b9d64fb7c323e7c871fa0bb3 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 2 Feb 2026 14:41:06 +0100 Subject: [PATCH 24/53] Reduce gradient in z-tilde coordinate --- .../tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg b/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg index 1b268a80a0..1165bfa3e7 100644 --- a/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg +++ b/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg @@ -6,4 +6,4 @@ # and its gradient z_tilde_bot_mid = -600.0 # m / km -z_tilde_bot_grad = 10.0 +z_tilde_bot_grad = 1.0 From 49490ff42ddd4f8092a48dda7864b132d2f7bc1d Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 2 Feb 2026 17:02:10 +0100 Subject: [PATCH 25/53] Vary resolution both horizontally and vertically for convergence --- polaris/tasks/ocean/two_column/init.py | 37 ++++++--- polaris/tasks/ocean/two_column/reference.py | 81 +++++++++++++++---- .../two_column/salinity_gradient/__init__.py | 23 +++++- polaris/tasks/ocean/two_column/two_column.cfg | 13 +-- .../two_column/ztilde_gradient/__init__.py | 23 +++++- .../ztilde_gradient/ztilde_gradient.cfg | 2 +- 6 files changed, 143 insertions(+), 36 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 3f51be40eb..a9c3735391 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -26,11 +26,14 @@ class Init(OceanIOStep): Attributes ---------- - resolution : float + horiz_res : float The horizontal resolution in km + + vert_res : float + The vertical resolution in m """ - def __init__(self, component, resolution, indir): + def __init__(self, component, horiz_res, vert_res, indir): """ Create the step @@ -39,15 +42,19 @@ def __init__(self, component, resolution, indir): component : polaris.Component The component the step belongs to - resolution : float + horiz_res : float The horizontal resolution in km + vert_res : float + The vertical resolution in m + indir : str The subdirectory that the task belongs to, that this step will go into a subdirectory of """ - self.resolution = resolution - name = f'init_{resolution_to_string(resolution)}' + self.horiz_res = horiz_res + self.vert_res = vert_res + name = f'init_{resolution_to_string(horiz_res)}' super().__init__(component=component, name=name, indir=indir) for file in [ 'base_mesh.nc', @@ -70,17 +77,29 @@ def run(self): 'Omega ocean model.' ) - resolution = self.resolution + horiz_res = self.horiz_res + vert_res = self.vert_res rho0 = config.getfloat('vertical_grid', 'rho0') assert rho0 is not None, ( 'The "rho0" configuration option must be set in the ' '"vertical_grid" section.' ) + z_tilde_bot_mid = config.getfloat('two_column', 'z_tilde_bot_mid') + + assert z_tilde_bot_mid is not None, ( + 'The "z_tilde_bot_mid" configuration option must be set in the ' + '"two_column" section.' + ) + + vert_levels = int(-z_tilde_bot_mid / vert_res) + + config.set('vertical_grid', 'vert_levels', str(vert_levels)) + nx = 2 ny = 2 - dc = 1e3 * resolution - dx = 1e3 * resolution + dc = 1e3 * horiz_res + dx = 1e3 * horiz_res ds_mesh = make_planar_hex_mesh( nx=nx, ny=ny, dc=dc, nonperiodic_x=True, nonperiodic_y=True ) @@ -107,7 +126,7 @@ def run(self): f'{ncells} cells.' ) - x = resolution * np.array([-0.5, 0.5], dtype=float) + x = horiz_res * np.array([-0.5, 0.5], dtype=float) geom_ssh, geom_z_bot = self._get_geom_ssh_z_bot(x) goal_geom_water_column_thickness = geom_ssh - geom_z_bot diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index 14f495bb00..1ec522bc18 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -85,9 +85,9 @@ def run(self): 'Omega ocean model.' ) - resolution = config.getfloat('two_column', 'reference_resolution') + resolution = config.getfloat('two_column', 'reference_horiz_res') assert resolution is not None, ( - 'The "reference_resolution" configuration option must be set in ' + 'The "reference_horiz_res" configuration option must be set in ' 'the "two_column" section.' ) rho0 = config.getfloat('vertical_grid', 'rho0') @@ -100,12 +100,21 @@ def run(self): geom_ssh, geom_z_bot, z_tilde_bot = self._get_ssh_z_bot(x) - vert_levels = config.getint('vertical_grid', 'vert_levels') - if vert_levels is None: - raise ValueError( - 'The "vert_levels" configuration option must be set in the ' - '"vertical_grid" section.' - ) + vert_res = config.getfloat('two_column', 'reference_vert_res') + z_tilde_bot_mid = config.getfloat('two_column', 'z_tilde_bot_mid') + + assert vert_res is not None, ( + 'The "reference_vert_res" configuration option must be set in ' + 'the "two_column" section.' + ) + assert z_tilde_bot_mid is not None, ( + 'The "z_tilde_bot_mid" configuration option must be set in the ' + '"two_column" section.' + ) + + vert_levels = int(-z_tilde_bot_mid / vert_res) + + config.set('vertical_grid', 'vert_levels', str(vert_levels)) vert_levs_inters = 2 * vert_levels + 1 z_tilde = np.nan * np.ones((len(x), vert_levs_inters), dtype=float) @@ -113,6 +122,7 @@ def run(self): spec_vol = np.nan * np.ones((len(x), vert_levs_inters), dtype=float) ct = np.nan * np.ones((len(x), vert_levs_inters), dtype=float) sa = np.nan * np.ones((len(x), vert_levs_inters), dtype=float) + uniform_layer_mask = np.zeros((len(x), vert_levs_inters), dtype=bool) z_tilde_node, temperature_node, salinity_node = ( self._get_z_tilde_t_s_nodes(x) @@ -126,6 +136,7 @@ def run(self): spec_vol[icol, :], ct[icol, :], sa[icol, :], + uniform_layer_mask[icol, :], ) = self._compute_column( z_tilde_node=z_tilde_node[icol, :], temperature_node=temperature_node[icol, :], @@ -135,6 +146,8 @@ def run(self): z_tilde_bot=z_tilde_bot.isel(nCells=icol).item(), ) + valid_grad_mask = np.all(uniform_layer_mask[[1, 2, 3], :], axis=0) + dx = resolution * 1e3 # m # compute Montgomery potential M = alpha * p + g * z @@ -315,6 +328,25 @@ def run(self): }, ) + ds['ValidGradMidMask'] = xr.DataArray( + data=valid_grad_mask[np.newaxis, 1::2], + dims=['Time', 'nVertLevels'], + attrs={ + 'long_name': 'Mask indicating layers with valid gradients at ' + 'midpoints', + 'units': '1', + }, + ) + ds['ValidGradInterMask'] = xr.DataArray( + data=valid_grad_mask[np.newaxis, 0::2], + dims=['Time', 'nVertLevelsP1'], + attrs={ + 'long_name': 'Mask indicating layers with valid gradients at ' + 'interfaces', + 'units': '1', + }, + ) + self.write_model_dataset(ds, 'reference_solution.nc') def _get_ssh_z_bot( @@ -336,7 +368,7 @@ def _get_ssh_z_bot( def _init_z_tilde_interface( self, pseudo_bottom_depth: float, z_tilde_bot: float - ) -> tuple[np.ndarray, int]: + ) -> tuple[np.ndarray, int, np.ndarray]: """ Compute z-tilde vertical interfaces. """ @@ -345,6 +377,9 @@ def _init_z_tilde_interface( z_tilde_interface = np.linspace( 0.0, z_tilde_bot, vert_levels + 1, dtype=float ) + # layers where z_tilde is not adjusted for bathymetry + uniform_layer_mask = z_tilde_interface >= -pseudo_bottom_depth + z_tilde_interface = np.maximum(z_tilde_interface, -pseudo_bottom_depth) dz = z_tilde_interface[0:-1] - z_tilde_interface[1:] mask = dz == 0.0 @@ -352,7 +387,8 @@ def _init_z_tilde_interface( # max_layer is the index of the deepest non-nan layer interface max_layer = np.where(~mask)[0][-1] + 1 - return z_tilde_interface, max_layer + + return z_tilde_interface, max_layer, uniform_layer_mask def _get_z_tilde_t_s_nodes( self, x: np.ndarray @@ -392,7 +428,9 @@ def _compute_column( geom_ssh: float, geom_z_bot: float, z_tilde_bot: float, - ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + ) -> tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray + ]: config = self.config logger = self.logger section = config['two_column'] @@ -426,9 +464,11 @@ def _compute_column( ) for iter in range(water_col_adjust_iter_count): - z_tilde_inter, max_layer = self._init_z_tilde_interface( - pseudo_bottom_depth=pseudo_bottom_depth, - z_tilde_bot=z_tilde_bot, + z_tilde_inter, max_layer, uniform_layer_mask_inter = ( + self._init_z_tilde_interface( + pseudo_bottom_depth=pseudo_bottom_depth, + z_tilde_bot=z_tilde_bot, + ) ) vert_levels = len(z_tilde_inter) - 1 @@ -439,6 +479,13 @@ def _compute_column( z_tilde[0::2] = z_tilde_inter z_tilde[1::2] = z_tilde_mid + uniform_layer_mask_mid = ( + uniform_layer_mask_inter[0:-1] & uniform_layer_mask_inter[1:] + ) + uniform_layer_mask = np.zeros(2 * vert_levels + 1, dtype=bool) + uniform_layer_mask[0::2] = uniform_layer_mask_inter + uniform_layer_mask[1::2] = uniform_layer_mask_mid + valid = slice(0, 2 * max_layer + 1) z_tilde_valid = z_tilde[valid] @@ -480,7 +527,7 @@ def _compute_column( pseudo_bottom_depth *= scaling_factor logger.info('') - return z_tilde, z, spec_vol, ct, sa + return z_tilde, z, spec_vol, ct, sa, uniform_layer_mask def _integrate_geometric_height( @@ -852,4 +899,8 @@ def _compute_4th_order_gradient(f: np.ndarray, dx: float) -> np.ndarray: # gradient at x = 0 using the non-uniform 4-point stencil df_dx = (f[0, :] - 27.0 * f[1, :] + 27.0 * f[2, :] - f[3, :]) / (24.0 * dx) + # mask any locations where inputs are NaN + nan_mask = np.any(np.isnan(f), axis=0) + df_dx[nan_mask] = np.nan + return df_dx diff --git a/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py b/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py index ab57d21490..a73b296a5d 100644 --- a/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py +++ b/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py @@ -63,7 +63,21 @@ def _setup_steps(self): setup steps given resolutions """ section = self.config['two_column'] - resolutions = section.getexpression('resolutions') + horiz_resolutions = section.getexpression('horiz_resolutions') + vert_resolutions = section.getexpression('vert_resolutions') + + assert horiz_resolutions is not None, ( + 'The "horiz_resolutions" configuration option must be set in the ' + '"two_column" section.' + ) + assert vert_resolutions is not None, ( + 'The "vert_resolutions" configuration option must be set in the ' + '"two_column" section.' + ) + assert len(horiz_resolutions) == len(vert_resolutions), ( + 'The "horiz_resolutions" and "vert_resolutions" configuration ' + 'options must have the same length.' + ) # start fresh with no steps for step in list(self.steps.values()): @@ -71,11 +85,14 @@ def _setup_steps(self): self.add_step(Reference(component=self.component, indir=self.subdir)) - for resolution in resolutions: + for horiz_res, vert_res in zip( + horiz_resolutions, vert_resolutions, strict=True + ): self.add_step( Init( component=self.component, - resolution=resolution, + horiz_res=horiz_res, + vert_res=vert_res, indir=self.subdir, ) ) diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index ebfcb211d3..bc5501a261 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -4,9 +4,6 @@ # the type of vertical grid grid_type = uniform -# Number of vertical levels -vert_levels = 60 - # The type of vertical coordinate (e.g. z-level, z-star, z-tilde, sigma) coord_type = z-tilde @@ -33,7 +30,10 @@ eos_type = teos-10 [two_column] # resolutions in km (the distance between the two columns) -resolutions = [16.0, 8.0, 4.0, 2.0, 1.0] +horiz_resolutions = [16.0, 8.0, 4.0, 2.0, 1.0] + +# vertical resolution in m +vert_resolutions = [16.0, 8.0, 4.0, 2.0, 1.0] # geometric sea surface height at the midpoint between the two columns # and its gradient @@ -76,4 +76,7 @@ water_col_adjust_iter_count = 6 reference_quadrature_method = gauss4 # reference solution's horizontal resolution in km -reference_resolution = 1.0 +reference_horiz_res = 1.0 + +# reference solution's vertical resolution in m +reference_vert_res = 1.0 diff --git a/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py b/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py index efe00dbb15..811c4e56c1 100644 --- a/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py +++ b/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py @@ -64,7 +64,21 @@ def _setup_steps(self): setup steps given resolutions """ section = self.config['two_column'] - resolutions = section.getexpression('resolutions') + horiz_resolutions = section.getexpression('horiz_resolutions') + vert_resolutions = section.getexpression('vert_resolutions') + + assert horiz_resolutions is not None, ( + 'The "horiz_resolutions" configuration option must be set in the ' + '"two_column" section.' + ) + assert vert_resolutions is not None, ( + 'The "vert_resolutions" configuration option must be set in the ' + '"two_column" section.' + ) + assert len(horiz_resolutions) == len(vert_resolutions), ( + 'The "horiz_resolutions" and "vert_resolutions" configuration ' + 'options must have the same length.' + ) # start fresh with no steps for step in list(self.steps.values()): @@ -72,11 +86,14 @@ def _setup_steps(self): self.add_step(Reference(component=self.component, indir=self.subdir)) - for resolution in resolutions: + for horiz_res, vert_res in zip( + horiz_resolutions, vert_resolutions, strict=True + ): self.add_step( Init( component=self.component, - resolution=resolution, + horiz_res=horiz_res, + vert_res=vert_res, indir=self.subdir, ) ) diff --git a/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg b/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg index 1165bfa3e7..3419382619 100644 --- a/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg +++ b/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg @@ -6,4 +6,4 @@ # and its gradient z_tilde_bot_mid = -600.0 # m / km -z_tilde_bot_grad = 1.0 +z_tilde_bot_grad = 0.5 From 5d6bf7dbef1e4f77834eb7446ab7aa24a3181d10 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 3 Feb 2026 14:10:19 +0100 Subject: [PATCH 26/53] Compute ref vertical res as half finest test res This ensures that interfaces of the reference solution exactly hit layer midpoints of the test solutions except near the bathymetry. --- polaris/tasks/ocean/two_column/reference.py | 11 ++++++----- polaris/tasks/ocean/two_column/two_column.cfg | 3 --- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index 1ec522bc18..7e171470b0 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -100,13 +100,14 @@ def run(self): geom_ssh, geom_z_bot, z_tilde_bot = self._get_ssh_z_bot(x) - vert_res = config.getfloat('two_column', 'reference_vert_res') + test_vert_res = config.getexpression('two_column', 'vert_resolutions') + test_min_vert_res = np.min(test_vert_res) + + # Use half the minimum test vertical resolution for the reference + # so that reference interfaces lie exactly at test midpoints + vert_res = test_min_vert_res / 2.0 z_tilde_bot_mid = config.getfloat('two_column', 'z_tilde_bot_mid') - assert vert_res is not None, ( - 'The "reference_vert_res" configuration option must be set in ' - 'the "two_column" section.' - ) assert z_tilde_bot_mid is not None, ( 'The "z_tilde_bot_mid" configuration option must be set in the ' '"two_column" section.' diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index bc5501a261..130f052165 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -77,6 +77,3 @@ reference_quadrature_method = gauss4 # reference solution's horizontal resolution in km reference_horiz_res = 1.0 - -# reference solution's vertical resolution in m -reference_vert_res = 1.0 From af77b684e1cb925f87aaf319ecc95ab2b2a80212 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Thu, 5 Feb 2026 10:11:57 +0100 Subject: [PATCH 27/53] Add surface pressure to init --- polaris/tasks/ocean/two_column/init.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index a9c3735391..f347b3015a 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -236,6 +236,15 @@ def run(self): ds['salinity'] = sa ds['SpecVol'] = spec_vol + ds['SurfacePressure'] = xr.DataArray( + data=np.zeros((1, ncells), dtype=float), + dims=['Time', 'nCells'], + attrs={ + 'long_name': 'surface pressure', + 'units': 'Pa', + }, + ) + ds['Density'] = 1.0 / ds['SpecVol'] ds.Density.attrs['long_name'] = 'density' ds.Density.attrs['units'] = 'kg m-3' From b6ea4cb45f54488882e3603890171a16f72a796d Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Thu, 5 Feb 2026 17:20:33 +0100 Subject: [PATCH 28/53] Switch to a single task class to avoid redundancy This merge also removes some unneeded output from init --- polaris/tasks/ocean/two_column/__init__.py | 7 +- polaris/tasks/ocean/two_column/init.py | 2 - .../salinity_gradient.cfg | 0 .../__init__.py => task.py} | 23 +++-- .../{ztilde_gradient => }/ztilde_gradient.cfg | 0 .../two_column/ztilde_gradient/__init__.py | 99 ------------------- 6 files changed, 17 insertions(+), 114 deletions(-) rename polaris/tasks/ocean/two_column/{salinity_gradient => }/salinity_gradient.cfg (100%) rename polaris/tasks/ocean/two_column/{salinity_gradient/__init__.py => task.py} (80%) rename polaris/tasks/ocean/two_column/{ztilde_gradient => }/ztilde_gradient.cfg (100%) delete mode 100644 polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py diff --git a/polaris/tasks/ocean/two_column/__init__.py b/polaris/tasks/ocean/two_column/__init__.py index e5452d9bcf..bc6754d92a 100644 --- a/polaris/tasks/ocean/two_column/__init__.py +++ b/polaris/tasks/ocean/two_column/__init__.py @@ -1,5 +1,4 @@ -from polaris.tasks.ocean.two_column.salinity_gradient import SalinityGradient -from polaris.tasks.ocean.two_column.ztilde_gradient import ZTildeGradient +from polaris.tasks.ocean.two_column.task import TwoColumnTask def add_two_column_tasks(component): @@ -11,5 +10,5 @@ def add_two_column_tasks(component): component : polaris.tasks.ocean.Ocean the ocean component that the tasks will be added to """ - component.add_task(SalinityGradient(component=component)) - component.add_task(ZTildeGradient(component=component)) + for name in ['salinity_gradient', 'ztilde_gradient']: + component.add_task(TwoColumnTask(component=component, name=name)) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index f347b3015a..fea91c270c 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -57,9 +57,7 @@ def __init__(self, component, horiz_res, vert_res, indir): name = f'init_{resolution_to_string(horiz_res)}' super().__init__(component=component, name=name, indir=indir) for file in [ - 'base_mesh.nc', 'culled_mesh.nc', - 'culled_graph.info', 'initial_state.nc', ]: self.add_output_file(file) diff --git a/polaris/tasks/ocean/two_column/salinity_gradient/salinity_gradient.cfg b/polaris/tasks/ocean/two_column/salinity_gradient.cfg similarity index 100% rename from polaris/tasks/ocean/two_column/salinity_gradient/salinity_gradient.cfg rename to polaris/tasks/ocean/two_column/salinity_gradient.cfg diff --git a/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py b/polaris/tasks/ocean/two_column/task.py similarity index 80% rename from polaris/tasks/ocean/two_column/salinity_gradient/__init__.py rename to polaris/tasks/ocean/two_column/task.py index a73b296a5d..fbb0f02366 100644 --- a/polaris/tasks/ocean/two_column/salinity_gradient/__init__.py +++ b/polaris/tasks/ocean/two_column/task.py @@ -5,12 +5,13 @@ from polaris.tasks.ocean.two_column.reference import Reference -class SalinityGradient(Task): +class TwoColumnTask(Task): """ - The salinity gradient two-column test case tests convergence of the TEOS-10 - pressure-gradient computation in Omega at various horizontal resolutions. - The test uses a fixed horizontal gradient in salinity between two adjacent - ocean columns, with no horizontal gradient in temperature or pseudo-height. + The two-column test case tests convergence of the TEOS-10 pressure-gradient + computation in Omega at various horizontal and vertical resolutions. The + test uses fixed horizontal gradients in various proprties (e.g. salinity + and pseudo-height) between two adjacent ocean columns, as set by config + options. The test includes a a quasi-analytic solution to horizontal pressure-gradient acceleration (HPGA) used for verification. It also @@ -26,7 +27,7 @@ class SalinityGradient(Task): Omega's HPGA to high precision. """ - def __init__(self, component): + def __init__(self, component, name): """ Create the test case @@ -34,8 +35,12 @@ def __init__(self, component): ---------- component : polaris.tasks.ocean.Ocean The ocean component that this task belongs to + + name : str + The name of the test case, which must have a corresponding + .cfg config file in the two_column package that specifies + which properties vary betweeen the columns. """ - name = 'salinity_gradient' subdir = os.path.join('two_column', name) super().__init__(component=component, name=name, subdir=subdir) @@ -43,8 +48,8 @@ def __init__(self, component): 'polaris.tasks.ocean.two_column', 'two_column.cfg' ) self.config.add_from_package( - 'polaris.tasks.ocean.two_column.salinity_gradient', - 'salinity_gradient.cfg', + 'polaris.tasks.ocean.two_column', + f'{name}.cfg', ) self._setup_steps() diff --git a/polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg b/polaris/tasks/ocean/two_column/ztilde_gradient.cfg similarity index 100% rename from polaris/tasks/ocean/two_column/ztilde_gradient/ztilde_gradient.cfg rename to polaris/tasks/ocean/two_column/ztilde_gradient.cfg diff --git a/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py b/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py deleted file mode 100644 index 811c4e56c1..0000000000 --- a/polaris/tasks/ocean/two_column/ztilde_gradient/__init__.py +++ /dev/null @@ -1,99 +0,0 @@ -import os - -from polaris import Task -from polaris.tasks.ocean.two_column.init import Init -from polaris.tasks.ocean.two_column.reference import Reference - - -class ZTildeGradient(Task): - """ - The z-tilde gradient two-column test case tests convergence of the TEOS-10 - pressure-gradient computation in Omega at various horizontal resolutions. - The test prescribes a gradient in the bottom depth of the pseudo-height - vertical coordinate between two adjacent ocean columns, with no horizontal - gradient in temperature or salinity. - - The test includes a a quasi-analytic solution to horizontal - pressure-gradient acceleration (HPGA) used for verification. It also - includes a set of Omega two-column initial conditions at various - resolutions. - - TODO: - Soon, the test will also include single-time-step forward model runs at - each resolution to output Omega's version of the HPGA, followed by an - analysis step to compute the error between Omega's HPGA and the - quasi-analytic solution. We will also compare Omega's HPGA with a python - computation as part of the initial condition that is expected to match - Omega's HPGA to high precision. - """ - - def __init__(self, component): - """ - Create the test case - - Parameters - ---------- - component : polaris.tasks.ocean.Ocean - The ocean component that this task belongs to - """ - name = 'ztilde_gradient' - subdir = os.path.join('two_column', name) - super().__init__(component=component, name=name, subdir=subdir) - - self.config.add_from_package( - 'polaris.tasks.ocean.two_column', 'two_column.cfg' - ) - self.config.add_from_package( - 'polaris.tasks.ocean.two_column.ztilde_gradient', - 'ztilde_gradient.cfg', - ) - - self._setup_steps() - - def configure(self): - """ - Set config options for the test case - """ - super().configure() - - # set up the steps again in case a user has provided new resolutions - self._setup_steps() - - def _setup_steps(self): - """ - setup steps given resolutions - """ - section = self.config['two_column'] - horiz_resolutions = section.getexpression('horiz_resolutions') - vert_resolutions = section.getexpression('vert_resolutions') - - assert horiz_resolutions is not None, ( - 'The "horiz_resolutions" configuration option must be set in the ' - '"two_column" section.' - ) - assert vert_resolutions is not None, ( - 'The "vert_resolutions" configuration option must be set in the ' - '"two_column" section.' - ) - assert len(horiz_resolutions) == len(vert_resolutions), ( - 'The "horiz_resolutions" and "vert_resolutions" configuration ' - 'options must have the same length.' - ) - - # start fresh with no steps - for step in list(self.steps.values()): - self.remove_step(step) - - self.add_step(Reference(component=self.component, indir=self.subdir)) - - for horiz_res, vert_res in zip( - horiz_resolutions, vert_resolutions, strict=True - ): - self.add_step( - Init( - component=self.component, - horiz_res=horiz_res, - vert_res=vert_res, - indir=self.subdir, - ) - ) From c02319d28b1b62b14043ea63a1092b2321f9b788 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Thu, 5 Feb 2026 17:26:56 +0100 Subject: [PATCH 29/53] Add forward runs --- polaris/tasks/ocean/two_column/forward.py | 52 +++++++++++++++++++++ polaris/tasks/ocean/two_column/forward.yaml | 45 ++++++++++++++++++ polaris/tasks/ocean/two_column/task.py | 23 ++++++--- 3 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 polaris/tasks/ocean/two_column/forward.py create mode 100644 polaris/tasks/ocean/two_column/forward.yaml diff --git a/polaris/tasks/ocean/two_column/forward.py b/polaris/tasks/ocean/two_column/forward.py new file mode 100644 index 0000000000..7c4ab5f446 --- /dev/null +++ b/polaris/tasks/ocean/two_column/forward.py @@ -0,0 +1,52 @@ +from polaris.ocean.model import OceanModelStep +from polaris.resolution import resolution_to_string + + +class Forward(OceanModelStep): + """ + This step performs a forward run for a single time step, writing out + the normal-velocity tendency (which is just the pressure gradient + acceleration) along with other diagnostics. + """ + + def __init__(self, component, horiz_res, indir): + """ + Create the step + + Parameters + ---------- + component : polaris.Component + The component the step belongs to + + horiz_res : float + The horizontal resolution in km + + indir : str + The subdirectory that the task belongs to, that this step will + go into a subdirectory of + """ + self.horiz_res = horiz_res + name = f'forward_{resolution_to_string(horiz_res)}' + super().__init__( + component=component, + name=name, + indir=indir, + ntasks=1, + min_tasks=1, + openmp_threads=1, + ) + + self.add_yaml_file('polaris.tasks.ocean.two_column', 'forward.yaml') + + init_dir = f'init_{resolution_to_string(horiz_res)}' + self.add_input_file( + filename='initial_state.nc', + target=f'../{init_dir}/initial_state.nc', + ) + self.add_input_file( + filename='mesh.nc', + target=f'../{init_dir}/culled_mesh.nc', + ) + + validate_vars = ['NormalVelocityTend'] + self.add_output_file('output.nc', validate_vars=validate_vars) diff --git a/polaris/tasks/ocean/two_column/forward.yaml b/polaris/tasks/ocean/two_column/forward.yaml new file mode 100644 index 0000000000..fc8765b7b8 --- /dev/null +++ b/polaris/tasks/ocean/two_column/forward.yaml @@ -0,0 +1,45 @@ +Omega: + TimeIntegration: + TimeStep: 0000_00:00:01 + StartTime: 0001-01-01_00:00:00 + StopTime: 0001-01-01_00:00:01 + Tendencies: + ThicknessFluxTendencyEnable: false + PVTendencyEnable: false + KETendencyEnable: false + SSHTendencyEnable: false + VelDiffTendencyEnable: false + VelHyperDiffTendencyEnable: false + WindForcingTendencyEnable: false + BottomDragTendencyEnable: false + TracerHorzAdvTendencyEnable: false + TracerDiffTendencyEnable: false + TracerHyperDiffTendencyEnable: false + UseCustomTendency: false + ManufacturedSolutionTendency: false + PressureGradTendencyEnable: true + IOStreams: + InitialVertCoord: + Filename: initial_state.nc + InitialState: + UsePointerFile: false + Filename: initial_state.nc + History: + Filename: output.nc + Freq: 1 + FreqUnits: Seconds + IfExists: append + # effectively never + FileFreq: 9999 + FileFreqUnits: years + Contents: + - ZMid + - ZInterface + - PressureMid + - PressureInterface + - LayerThickness + - Temperature + - Salinity + - SpecVol + - GeopotentialMid + - NormalVelocityTend diff --git a/polaris/tasks/ocean/two_column/task.py b/polaris/tasks/ocean/two_column/task.py index fbb0f02366..e0c8604d39 100644 --- a/polaris/tasks/ocean/two_column/task.py +++ b/polaris/tasks/ocean/two_column/task.py @@ -1,6 +1,7 @@ import os from polaris import Task +from polaris.tasks.ocean.two_column.forward import Forward from polaris.tasks.ocean.two_column.init import Init from polaris.tasks.ocean.two_column.reference import Reference @@ -18,13 +19,14 @@ class TwoColumnTask(Task): includes a set of Omega two-column initial conditions at various resolutions. + The test also includes single-time-step forward model runs at + each resolution that outputs Omega's version of the HPGA, + TODO: - Soon, the test will also include single-time-step forward model runs at - each resolution to output Omega's version of the HPGA, followed by an - analysis step to compute the error between Omega's HPGA and the - quasi-analytic solution. We will also compare Omega's HPGA with a python - computation as part of the initial condition that is expected to match - Omega's HPGA to high precision. + Soon, this task will also include an analysis step to compute the error + between Omega's HPGA and the high-fidelity reference solution. We will + also compare Omega's HPGA with a python computation as part of the initial + condition that is expected to match Omega's HPGA to high precision. """ def __init__(self, component, name): @@ -101,3 +103,12 @@ def _setup_steps(self): indir=self.subdir, ) ) + + for horiz_res in horiz_resolutions: + self.add_step( + Forward( + component=self.component, + horiz_res=horiz_res, + indir=self.subdir, + ) + ) From 2471dab572c995ea4e3ec41a58afbcd6a6e39c6f Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 9 Feb 2026 07:07:09 -0600 Subject: [PATCH 30/53] Add OmegaMesh.nc link --- polaris/tasks/ocean/two_column/forward.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/polaris/tasks/ocean/two_column/forward.py b/polaris/tasks/ocean/two_column/forward.py index 7c4ab5f446..a8206526af 100644 --- a/polaris/tasks/ocean/two_column/forward.py +++ b/polaris/tasks/ocean/two_column/forward.py @@ -48,5 +48,8 @@ def __init__(self, component, horiz_res, indir): target=f'../{init_dir}/culled_mesh.nc', ) + # TODO: remove as soon as Omega no longer hard-codes this file + self.add_input_file(filename='OmegaMesh.nc', target='initial_state.nc') + validate_vars = ['NormalVelocityTend'] self.add_output_file('output.nc', validate_vars=validate_vars) From 863a5844c85c5f8c8e47190834e061f50aed617a Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 16 Feb 2026 06:07:54 -0600 Subject: [PATCH 31/53] Add vertical coordinate vars to variable map --- polaris/ocean/model/mpaso_to_omega.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/polaris/ocean/model/mpaso_to_omega.yaml b/polaris/ocean/model/mpaso_to_omega.yaml index 74e75e4125..af057e8e4d 100644 --- a/polaris/ocean/model/mpaso_to_omega.yaml +++ b/polaris/ocean/model/mpaso_to_omega.yaml @@ -22,6 +22,12 @@ variables: cellsOnVertex: CellsOnVertex edgesOnVertex: EdgesOnVertex + # vertical coordinate + minLevelCell: MinLayerCell + maxLevelCell: MaxLayerCell + # currently hard-coded in horizontal mesh + # bottomDepth: BottomDepth + # tracers temperature: Temperature salinity: Salinity From 9baf53a0144f2ee90640add110938af2727aaf31 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 16 Feb 2026 07:05:22 -0600 Subject: [PATCH 32/53] Get rho0 for Omega from Polaris config option --- polaris/tasks/ocean/two_column/forward.py | 20 ++++++++++++++++++-- polaris/tasks/ocean/two_column/forward.yaml | 5 +++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/polaris/tasks/ocean/two_column/forward.py b/polaris/tasks/ocean/two_column/forward.py index a8206526af..1af15345b3 100644 --- a/polaris/tasks/ocean/two_column/forward.py +++ b/polaris/tasks/ocean/two_column/forward.py @@ -36,8 +36,6 @@ def __init__(self, component, horiz_res, indir): openmp_threads=1, ) - self.add_yaml_file('polaris.tasks.ocean.two_column', 'forward.yaml') - init_dir = f'init_{resolution_to_string(horiz_res)}' self.add_input_file( filename='initial_state.nc', @@ -53,3 +51,21 @@ def __init__(self, component, horiz_res, indir): validate_vars = ['NormalVelocityTend'] self.add_output_file('output.nc', validate_vars=validate_vars) + + def setup(self): + """ + Fill in config options in the forward.yaml file based on config options + """ + super().setup() + + rho0 = self.config.get('vertical_grid', 'rho0') + if rho0 is None: + raise ValueError( + 'rho0 must be specified in the config file under vertical_grid' + ) + + self.add_yaml_file( + 'polaris.tasks.ocean.two_column', + 'forward.yaml', + template_replacements={'rho0': rho0}, + ) diff --git a/polaris/tasks/ocean/two_column/forward.yaml b/polaris/tasks/ocean/two_column/forward.yaml index fc8765b7b8..caa69fb5a3 100644 --- a/polaris/tasks/ocean/two_column/forward.yaml +++ b/polaris/tasks/ocean/two_column/forward.yaml @@ -3,6 +3,8 @@ Omega: TimeStep: 0000_00:00:01 StartTime: 0001-01-01_00:00:00 StopTime: 0001-01-01_00:00:01 + VertCoord: + Density0: {{ rho0 }} Tendencies: ThicknessFluxTendencyEnable: false PVTendencyEnable: false @@ -43,3 +45,6 @@ Omega: - SpecVol - GeopotentialMid - NormalVelocityTend + - BottomDepth + - MinLayerCell + - MaxLayerCell From 9ea7e2a75c46187d709138fcb98cd6c174c7372b Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 16 Feb 2026 07:15:29 -0600 Subject: [PATCH 33/53] Add geometric bottom depth and mid-layer pressure to init --- polaris/tasks/ocean/two_column/init.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index fea91c270c..df56e1acc9 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -243,6 +243,16 @@ def run(self): }, ) + # bottomDepth needs to be the geometric bottom depth of the bathymetry, + # not the pseudo-bottom depth used for the vertical coordinate + ds['bottomDepth'] = -geom_z_bot + ds.bottomDepth.attrs['long_name'] = 'seafloor geometric height' + ds.bottomDepth.attrs['units'] = 'm' + + ds['PressureMid'] = p_mid + ds.PressureMid.attrs['long_name'] = 'pressure at layer midpoints' + ds.PressureMid.attrs['units'] = 'Pa' + ds['Density'] = 1.0 / ds['SpecVol'] ds.Density.attrs['long_name'] = 'density' ds.Density.attrs['units'] = 'kg m-3' From b60dcbf3588dcdf05479be75901d301709f0c054 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 16 Feb 2026 10:32:10 -0600 Subject: [PATCH 34/53] Temporarily hard-code Omega's acceleration of gravity --- polaris/tasks/ocean/two_column/init.py | 9 +++++---- polaris/tasks/ocean/two_column/reference.py | 13 ++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index df56e1acc9..0348e6e962 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -1,6 +1,5 @@ import numpy as np import xarray as xr -from mpas_tools.cime.constants import constants from mpas_tools.io import write_netcdf from mpas_tools.mesh.conversion import convert, cull from mpas_tools.planar_hex import make_planar_hex_mesh @@ -9,6 +8,8 @@ from polaris.ocean.model import OceanIOStep from polaris.ocean.vertical import init_vertical_coord from polaris.ocean.vertical.ztilde import ( + # temporary until we can get this for GCD + Gravity, geom_height_from_pseudo_height, pressure_from_z_tilde, ) @@ -332,8 +333,6 @@ def _compute_montgomery_and_hpga( if dx == 0.0: raise ValueError('dx must be non-zero for finite differences.') - g = constants['SHR_CONST_G'] - # Midpoint quantities (alpha is layerwise constant) alpha_mid = ds.SpecVol @@ -365,7 +364,9 @@ def _compute_montgomery_and_hpga( 'Time', 'nCells', 'nVertLevels', 'nbnds' ) # Montgomery: M = alpha * p + g * z, with p = -rho0 * g * z_tilde - montgomery_inter = g * (z_bnds - rho0 * alpha_bnds * z_tilde_bnds) + montgomery_inter = Gravity * ( + z_bnds - rho0 * alpha_bnds * z_tilde_bnds + ) montgomery_inter = montgomery_inter.transpose( 'Time', 'nCells', 'nVertLevels', 'nbnds' ) diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index 7e171470b0..92f7311bad 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -5,9 +5,11 @@ import gsw import numpy as np import xarray as xr -from mpas_tools.cime.constants import constants from polaris.ocean.model import OceanIOStep + +# temporary until we can get this for GCD +from polaris.ocean.vertical.ztilde import Gravity from polaris.tasks.ocean.two_column.column import ( get_array_from_mid_grad, get_pchip_interpolator, @@ -153,8 +155,7 @@ def run(self): # compute Montgomery potential M = alpha * p + g * z # with p = -rho0 * g * z_tilde (p positive downward) - g = constants['SHR_CONST_G'] - montgomery = g * (z - rho0 * spec_vol * z_tilde) + montgomery = Gravity * (z - rho0 * spec_vol * z_tilde) dx = resolution * 1.0e3 # m @@ -180,7 +181,7 @@ def run(self): # the HPGF is grad(M) - p * grad(alpha) # Here we just compute the gradient at x=0 using a 4th-order # finite-difference stencil - p0 = -rho0 * g * z_tilde[2, :] + p0 = -rho0 * Gravity * z_tilde[2, :] # indices for -1.5dx, -0.5dx, 0.5dx, 1.5dx grad_indices = [0, 1, 3, 4] dM_dx = _compute_4th_order_gradient(montgomery[grad_indices, :], dx) @@ -639,8 +640,6 @@ def _integrate_geometric_height( if subdivisions < 1: raise ValueError('subdivisions must be >= 1.') - g = constants['SHR_CONST_G'] - sa_interp = get_pchip_interpolator( z_tilde_nodes=z_tilde_nodes, values_nodes=sa_nodes, @@ -657,7 +656,7 @@ def spec_vol_ct_sa_at( ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: sa = sa_interp(z_tilde) ct = ct_interp(z_tilde) - p_pa = -rho0 * g * z_tilde + p_pa = -rho0 * Gravity * z_tilde # gsw expects pressure in dbar spec_vol = gsw.specvol(sa, ct, p_pa * 1.0e-4) return spec_vol, ct, sa From f85c13283bd57d673232d7df6a9041c4bd776140 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Mon, 16 Feb 2026 15:57:27 -0600 Subject: [PATCH 35/53] Fix sign of HPGA --- polaris/tasks/ocean/two_column/init.py | 2 +- polaris/tasks/ocean/two_column/reference.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 0348e6e962..03cd274345 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -392,7 +392,7 @@ def _compute_montgomery_and_hpga( # Pressure (positive downward), averaged to the edge between columns p_edge_mid = 0.5 * (p_mid.isel(nCells=0) + p_mid.isel(nCells=1)) - hpga_mid = dM_dx_mid - p_edge_mid * dalpha_dx_mid + hpga_mid = -dM_dx_mid + p_edge_mid * dalpha_dx_mid ds['MontgomeryMid'] = montgomery_mid ds.MontgomeryMid.attrs['long_name'] = ( diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/two_column/reference.py index 92f7311bad..dd552eda7e 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/two_column/reference.py @@ -186,7 +186,7 @@ def run(self): grad_indices = [0, 1, 3, 4] dM_dx = _compute_4th_order_gradient(montgomery[grad_indices, :], dx) dalpha_dx = _compute_4th_order_gradient(spec_vol[grad_indices, :], dx) - hpga = dM_dx - p0 * dalpha_dx + hpga = -dM_dx + p0 * dalpha_dx dsa_dx = _compute_4th_order_gradient(sa[grad_indices, :], dx) From 6291b1cffa2665f6f6bc7bf23d6221a2f563dc13 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 17 Feb 2026 08:48:42 -0600 Subject: [PATCH 36/53] Add analysis step --- polaris/tasks/ocean/two_column/analysis.py | 436 +++++++++++++++++++++ polaris/tasks/ocean/two_column/task.py | 57 +-- 2 files changed, 471 insertions(+), 22 deletions(-) create mode 100644 polaris/tasks/ocean/two_column/analysis.py diff --git a/polaris/tasks/ocean/two_column/analysis.py b/polaris/tasks/ocean/two_column/analysis.py new file mode 100644 index 0000000000..18b4e12ebf --- /dev/null +++ b/polaris/tasks/ocean/two_column/analysis.py @@ -0,0 +1,436 @@ +import matplotlib.pyplot as plt +import numpy as np +import xarray as xr + +from polaris.ocean.model import OceanIOStep +from polaris.ocean.vertical.ztilde import Gravity +from polaris.viz import use_mplstyle + + +class Analysis(OceanIOStep): + """ + A step for analyzing two-column HPGA errors versus a reference solution + and versus the Python-computed initial-state solution. + + Attributes + ---------- + dependencies_dict : dict + A dictionary of dependent steps: + + reference : polaris.Step + The reference step that produces ``reference_solution.nc`` + + init : dict + Mapping from horizontal resolution (km) to ``Init`` step + + forward : dict + Mapping from horizontal resolution (km) to ``Forward`` step + """ + + def __init__(self, component, indir, dependencies): + """ + Create the analysis step + + Parameters + ---------- + component : polaris.Component + The component the step belongs to + + indir : str + The subdirectory that the task belongs to, that this step will + go into a subdirectory of + + dependencies : dict + A dictionary of dependent steps + """ + super().__init__(component=component, name='analysis', indir=indir) + + self.dependencies_dict = dependencies + + self.add_output_file('omega_vs_reference.png') + self.add_output_file('omega_vs_reference.nc') + self.add_output_file('omega_vs_python.png') + self.add_output_file('omega_vs_python.nc') + + def setup(self): + """ + Add inputs from reference, init and forward steps + """ + super().setup() + + section = self.config['two_column'] + horiz_resolutions = section.getexpression('horiz_resolutions') + assert horiz_resolutions is not None + + reference = self.dependencies_dict['reference'] + self.add_input_file( + filename='reference_solution.nc', + work_dir_target=f'{reference.path}/reference_solution.nc', + ) + + init_steps = self.dependencies_dict['init'] + forward_steps = self.dependencies_dict['forward'] + for resolution in horiz_resolutions: + init = init_steps[resolution] + forward = forward_steps[resolution] + self.add_input_file( + filename=f'init_r{resolution:02g}.nc', + work_dir_target=f'{init.path}/initial_state.nc', + ) + self.add_input_file( + filename=f'output_r{resolution:02g}.nc', + work_dir_target=f'{forward.path}/output.nc', + ) + + def run(self): + """ + Run this step of the test case + """ + plt.switch_backend('Agg') + logger = self.logger + config = self.config + + rho0 = config.getfloat('vertical_grid', 'rho0') + assert rho0 is not None, ( + 'The "rho0" configuration option must be set in the ' + '"vertical_grid" section.' + ) + + section = config['two_column'] + horiz_resolutions = section.getexpression('horiz_resolutions') + assert horiz_resolutions is not None, ( + 'The "horiz_resolutions" configuration option must be set in ' + 'the "two_column" section.' + ) + + ds_ref = self.open_model_dataset('reference_solution.nc') + if ds_ref.sizes.get('nCells', 0) <= 2: + raise ValueError( + 'The reference solution requires at least 3 columns so that ' + 'the central column is nCells=2.' + ) + + ref_z = ds_ref.ZTildeInter.isel(Time=0, nCells=2).values + ref_hpga = ds_ref.HPGAInter.isel(Time=0).values + + ref_errors = [] + py_errors = [] + + for resolution in horiz_resolutions: + ds_init = self.open_model_dataset(f'init_r{resolution:02g}.nc') + ds_out = self.open_model_dataset( + f'output_r{resolution:02g}.nc', decode_times=False + ) + + edge_index, cells_on_edge = _get_internal_edge(ds_init) + cell0, cell1 = cells_on_edge + + z_tilde_forward = _get_forward_z_tilde_edge_mid( + ds_out=ds_out, + rho0=rho0, + cell0=cell0, + cell1=cell1, + ) + hpga_forward = ds_out.NormalVelocityTend.isel( + Time=0, nEdges=edge_index + ).values + + sampled_ref_hpga = _sample_reference_without_interpolation( + ref_z=ref_z, + ref_values=ref_hpga, + target_z=z_tilde_forward, + ) + + ref_errors.append(_rms_error(hpga_forward - sampled_ref_hpga)) + + z_tilde_init = ( + 0.5 + * ( + ds_init.ZTildeMid.isel(Time=0, nCells=cell0) + + ds_init.ZTildeMid.isel(Time=0, nCells=cell1) + ).values + ) + _check_vertical_match( + z_ref=z_tilde_init, + z_test=z_tilde_forward, + msg=( + 'ZTilde mismatch between Python init and Omega forward ' + f'at resolution {resolution:g} km' + ), + ) + + hpga_init = ds_init.HPGA.isel(Time=0).values + py_errors.append(_rms_error(hpga_forward - hpga_init)) + + resolution_array = np.asarray(horiz_resolutions, dtype=float) + ref_error_array = np.asarray(ref_errors, dtype=float) + py_error_array = np.asarray(py_errors, dtype=float) + + ref_fit, ref_slope, ref_intercept = _power_law_fit( + x=resolution_array, + y=ref_error_array, + ) + py_fit, py_slope, py_intercept = _power_law_fit( + x=resolution_array, + y=py_error_array, + ) + + _write_dataset( + filename='omega_vs_reference.nc', + resolution_km=resolution_array, + rms_error=ref_error_array, + fit=ref_fit, + slope=ref_slope, + intercept=ref_intercept, + y_name='rms_error_vs_reference', + ) + _write_dataset( + filename='omega_vs_python.nc', + resolution_km=resolution_array, + rms_error=py_error_array, + fit=py_fit, + slope=py_slope, + intercept=py_intercept, + y_name='rms_error_vs_python', + ) + + _plot_errors( + resolution_km=resolution_array, + rms_error=ref_error_array, + fit=ref_fit, + slope=ref_slope, + y_label='RMS error in HPGA (m s-2)', + title='Omega HPGA Error vs Reference Solution', + output='omega_vs_reference.png', + ) + _plot_errors( + resolution_km=resolution_array, + rms_error=py_error_array, + fit=py_fit, + slope=py_slope, + y_label='RMS difference in HPGA (m s-2)', + title='Omega (C++) vs Polaris (Python) HPGA Difference', + output='omega_vs_python.png', + ) + + logger.info(f'Omega-vs-reference convergence slope: {ref_slope:1.3f}') + logger.info(f'Omega-vs-Python convergence slope: {py_slope:1.3f}') + + +def _get_internal_edge(ds_init: xr.Dataset) -> tuple[int, tuple[int, int]]: + """ + Determine the edge that connects the two valid cells in the two-column + mesh. + """ + if 'cellsOnEdge' not in ds_init: + raise ValueError('cellsOnEdge is required in initial_state.nc') + + cells_on_edge = ds_init.cellsOnEdge.values.astype(int) + if cells_on_edge.ndim != 2 or cells_on_edge.shape[1] != 2: + raise ValueError('cellsOnEdge must have shape (nEdges, 2).') + + valid = np.logical_and(cells_on_edge[:, 0] > 0, cells_on_edge[:, 1] > 0) + valid_edges = np.where(valid)[0] + if len(valid_edges) != 1: + raise ValueError( + 'Expected exactly one edge with two valid cells in the ' + f'two-column mesh, found {len(valid_edges)}.' + ) + + edge_index = int(valid_edges[0]) + # convert from 1-based MPAS indexing to 0-based indexing + cell0 = int(cells_on_edge[edge_index, 0] - 1) + cell1 = int(cells_on_edge[edge_index, 1] - 1) + return edge_index, (cell0, cell1) + + +def _get_forward_z_tilde_edge_mid( + ds_out: xr.Dataset, + rho0: float, + cell0: int, + cell1: int, +) -> np.ndarray: + """ + Compute edge-centered pseudo-height at layer midpoints from Omega output + pressure. + """ + pressure_mid = ds_out.PressureMid.isel(Time=0) + pressure_edge_mid = 0.5 * ( + pressure_mid.isel(nCells=cell0) + pressure_mid.isel(nCells=cell1) + ) + return (-pressure_edge_mid / (rho0 * Gravity)).values + + +def _sample_reference_without_interpolation( + ref_z: np.ndarray, + ref_values: np.ndarray, + target_z: np.ndarray, + abs_tol: float = 1.0e-6, + rel_tol: float = 1.0e-10, +) -> np.ndarray: + """ + Sample reference values at target z-levels by exact matching within a + strict tolerance, without interpolation. + """ + ref_z = np.asarray(ref_z, dtype=float) + ref_values = np.asarray(ref_values, dtype=float) + target_z = np.asarray(target_z, dtype=float) + + sampled = np.full_like(target_z, np.nan, dtype=float) + valid_target = np.isfinite(target_z) + valid_ref = np.logical_and(np.isfinite(ref_z), np.isfinite(ref_values)) + + ref_z_valid = ref_z[valid_ref] + ref_values_valid = ref_values[valid_ref] + + target_valid = target_z[valid_target] + if len(target_valid) == 0: + return sampled + + dz = np.abs(ref_z_valid[:, np.newaxis] - target_valid[np.newaxis, :]) + indices = np.argmin(dz, axis=0) + min_dz = dz[indices, np.arange(len(indices))] + + tol = np.maximum(abs_tol, rel_tol * np.maximum(1.0, np.abs(target_valid))) + if np.any(min_dz > tol): + max_mismatch = float(np.max(min_dz)) + raise ValueError( + 'Reference z-levels do not match Omega z-levels closely enough ' + f'for subsampling without interpolation. max |dz|={max_mismatch}' + ) + + sampled[valid_target] = ref_values_valid[indices] + return sampled + + +def _check_vertical_match( + z_ref: np.ndarray, + z_test: np.ndarray, + msg: str, + abs_tol: float = 1.0e-6, + rel_tol: float = 1.0e-10, +) -> None: + """ + Ensure two pseudo-height arrays match within strict tolerances. + """ + z_ref = np.asarray(z_ref, dtype=float) + z_test = np.asarray(z_test, dtype=float) + if z_ref.shape != z_test.shape: + raise ValueError( + f'{msg}: shape mismatch {z_ref.shape} != {z_test.shape}' + ) + + valid = np.logical_and(np.isfinite(z_ref), np.isfinite(z_test)) + if not np.any(valid): + raise ValueError(f'{msg}: no valid levels for comparison.') + + diff = np.abs(z_ref[valid] - z_test[valid]) + tol = np.maximum(abs_tol, rel_tol * np.maximum(1.0, np.abs(z_ref[valid]))) + if np.any(diff > tol): + raise ValueError( + f'{msg}: max |dz| = {float(np.max(diff))}, exceeds tolerance.' + ) + + +def _rms_error(values: np.ndarray) -> float: + """ + Compute RMS over finite values. + """ + values = np.asarray(values, dtype=float) + valid = np.isfinite(values) + if not np.any(valid): + raise ValueError('No finite values available for RMS error.') + return float(np.sqrt(np.mean(values[valid] ** 2))) + + +def _power_law_fit( + x: np.ndarray, + y: np.ndarray, +) -> tuple[np.ndarray, float, float]: + """ + Fit y = 10**b * x**m in log10 space. + """ + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + valid = np.logical_and.reduce( + (np.isfinite(x), np.isfinite(y), x > 0.0, y > 0.0) + ) + + if np.count_nonzero(valid) < 2: + raise ValueError( + 'At least two positive finite points are required for fit.' + ) + + poly = np.polyfit(np.log10(x[valid]), np.log10(y[valid]), 1) + slope = float(poly[0]) + intercept = float(poly[1]) + fit = x**slope * 10.0**intercept + return fit, slope, intercept + + +def _write_dataset( + filename: str, + resolution_km: np.ndarray, + rms_error: np.ndarray, + fit: np.ndarray, + slope: float, + intercept: float, + y_name: str, +) -> None: + """ + Write data used in a convergence plot to netCDF. + """ + nres = len(resolution_km) + ds = xr.Dataset() + ds['resolution_km'] = xr.DataArray( + data=resolution_km, + dims=['nResolutions'], + attrs={'long_name': 'horizontal resolution', 'units': 'km'}, + ) + ds[y_name] = xr.DataArray( + data=rms_error, + dims=['nResolutions'], + attrs={'long_name': y_name.replace('_', ' '), 'units': 'm s-2'}, + ) + ds['power_law_fit'] = xr.DataArray( + data=fit, + dims=['nResolutions'], + attrs={'long_name': 'power-law fit to rms error', 'units': 'm s-2'}, + ) + ds.attrs['fit_slope'] = slope + ds.attrs['fit_intercept_log10'] = intercept + ds.attrs['nResolutions'] = nres + ds.to_netcdf(filename) + + +def _plot_errors( + resolution_km: np.ndarray, + rms_error: np.ndarray, + fit: np.ndarray, + slope: float, + y_label: str, + title: str, + output: str, +) -> None: + """ + Plot RMS error vs. horizontal resolution with a power-law fit. + """ + use_mplstyle() + fig = plt.figure() + ax = fig.add_subplot(111) + + ax.loglog( + resolution_km, + fit, + 'k', + label=f'power-law fit (slope={slope:1.3f})', + ) + ax.loglog(resolution_km, rms_error, 'o', label='RMS error') + + ax.set_xlabel('Horizontal resolution (km)') + ax.set_ylabel(y_label) + ax.set_title(title) + ax.legend(loc='lower right') + ax.invert_xaxis() + fig.savefig(output, bbox_inches='tight', pad_inches=0.1) + plt.close() diff --git a/polaris/tasks/ocean/two_column/task.py b/polaris/tasks/ocean/two_column/task.py index e0c8604d39..34e32fd4a4 100644 --- a/polaris/tasks/ocean/two_column/task.py +++ b/polaris/tasks/ocean/two_column/task.py @@ -1,6 +1,7 @@ import os from polaris import Task +from polaris.tasks.ocean.two_column.analysis import Analysis from polaris.tasks.ocean.two_column.forward import Forward from polaris.tasks.ocean.two_column.init import Init from polaris.tasks.ocean.two_column.reference import Reference @@ -19,14 +20,10 @@ class TwoColumnTask(Task): includes a set of Omega two-column initial conditions at various resolutions. - The test also includes single-time-step forward model runs at - each resolution that outputs Omega's version of the HPGA, - - TODO: - Soon, this task will also include an analysis step to compute the error - between Omega's HPGA and the high-fidelity reference solution. We will - also compare Omega's HPGA with a python computation as part of the initial - condition that is expected to match Omega's HPGA to high precision. + The test also includes single-time-step forward model runs at each + resolution that output Omega's version of the HPGA, and an analysis step + that compares these runs with both the high-fidelity reference solution + and the Python-computed HPGA from the initial conditions. """ def __init__(self, component, name): @@ -90,25 +87,41 @@ def _setup_steps(self): for step in list(self.steps.values()): self.remove_step(step) - self.add_step(Reference(component=self.component, indir=self.subdir)) + reference_step = Reference(component=self.component, indir=self.subdir) + self.add_step(reference_step) + + init_steps = dict() + forward_steps = dict() for horiz_res, vert_res in zip( horiz_resolutions, vert_resolutions, strict=True ): - self.add_step( - Init( - component=self.component, - horiz_res=horiz_res, - vert_res=vert_res, - indir=self.subdir, - ) + init_step = Init( + component=self.component, + horiz_res=horiz_res, + vert_res=vert_res, + indir=self.subdir, ) + self.add_step(init_step) + init_steps[horiz_res] = init_step for horiz_res in horiz_resolutions: - self.add_step( - Forward( - component=self.component, - horiz_res=horiz_res, - indir=self.subdir, - ) + forward_step = Forward( + component=self.component, + horiz_res=horiz_res, + indir=self.subdir, ) + self.add_step(forward_step) + forward_steps[horiz_res] = forward_step + + self.add_step( + Analysis( + component=self.component, + indir=self.subdir, + dependencies={ + 'reference': reference_step, + 'init': init_steps, + 'forward': forward_steps, + }, + ) + ) From cc3732e042c56b844965deba5f84a961da4ff9bb Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 17 Feb 2026 09:13:18 -0600 Subject: [PATCH 37/53] Fix column concat in init step --- polaris/tasks/ocean/two_column/init.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 03cd274345..55423a1874 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -489,7 +489,7 @@ def _init_z_tilde_vert_coord( ds['ssh'] = xr.zeros_like(pseudo_bottom_depth) ds.ssh.attrs['long_name'] = 'sea surface pseudo-height' ds.ssh.attrs['units'] = 'm' - ds_list = [] + ds_list: list[xr.Dataset] = [] for icell in range(ds.sizes['nCells']): # initialize the vertical coordinate for each column separately # to allow different pseudo-bottom depths @@ -502,9 +502,19 @@ def _init_z_tilde_vert_coord( ) init_vertical_coord(local_config, ds_cell) - ds_list.append(ds_cell) - - ds = xr.concat(ds_list, dim='nCells') + cell_vars = [ + var + for var in ds_cell.data_vars + if 'nCells' in ds_cell[var].dims + ] + ds_list.append(ds_cell[cell_vars]) + + # copy back only the cell variables + ds_cell_vars = xr.concat(ds_list, dim='nCells') + for var in ds_cell_vars.data_vars: + attrs = ds_cell_vars[var].attrs + ds[var] = ds_cell_vars[var] + ds[var].attrs = attrs return ds def _get_z_tilde_t_s_nodes( From a671d020e5d4b1e2e4fe5fa27c927a2c10ef9189 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 17 Feb 2026 10:59:49 -0600 Subject: [PATCH 38/53] Make water column thickness an even multiple of 16 Fix some confusing references to z-levels --- polaris/tasks/ocean/two_column/analysis.py | 7 ++++--- polaris/tasks/ocean/two_column/init.py | 10 ++++++++++ polaris/tasks/ocean/two_column/two_column.cfg | 4 ++-- polaris/tasks/ocean/two_column/ztilde_gradient.cfg | 2 +- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/polaris/tasks/ocean/two_column/analysis.py b/polaris/tasks/ocean/two_column/analysis.py index 18b4e12ebf..d3515f2edc 100644 --- a/polaris/tasks/ocean/two_column/analysis.py +++ b/polaris/tasks/ocean/two_column/analysis.py @@ -269,7 +269,7 @@ def _sample_reference_without_interpolation( rel_tol: float = 1.0e-10, ) -> np.ndarray: """ - Sample reference values at target z-levels by exact matching within a + Sample reference values at target z-tilde values by exact matching within a strict tolerance, without interpolation. """ ref_z = np.asarray(ref_z, dtype=float) @@ -295,8 +295,9 @@ def _sample_reference_without_interpolation( if np.any(min_dz > tol): max_mismatch = float(np.max(min_dz)) raise ValueError( - 'Reference z-levels do not match Omega z-levels closely enough ' - f'for subsampling without interpolation. max |dz|={max_mismatch}' + f'Reference z-tilde values do not match Omega z-tilde values ' + f'closely enough for subsampling without interpolation. max ' + f'|dz|={max_mismatch}' ) sampled[valid_target] = ref_values_valid[indices] diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/two_column/init.py index 55423a1874..84b06aaa03 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/two_column/init.py @@ -91,6 +91,16 @@ def run(self): '"two_column" section.' ) + # it needs to be an error if the full water column can't be evenly + # divided by the resolution, because the later analysis will fail + if (-z_tilde_bot_mid / vert_res) % 1 != 0: + raise ValueError( + 'The "z_tilde_bot_mid" value must be an integer multiple of ' + 'the vertical resolution to ensure that the vertical grid can ' + 'be evenly divided into layers. Currently, z_tilde_bot_mid = ' + f'{z_tilde_bot_mid} and vert_res = {vert_res}, which results ' + f'in {-z_tilde_bot_mid / vert_res} layers.' + ) vert_levels = int(-z_tilde_bot_mid / vert_res) config.set('vertical_grid', 'vert_levels', str(vert_levels)) diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index 130f052165..dea8a50145 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -50,13 +50,13 @@ geom_z_bot_grad = 0.0 # Pseudo-height of the bottom of the ocean (with some buffer compared with # geometric sea floor height) at the midpoint between the two columns # and its gradient -z_tilde_bot_mid = -600.0 +z_tilde_bot_mid = -576.0 # m / km z_tilde_bot_grad = 0.0 # pseudo-height, temperatures and salinities for piecewise linear initial # conditions at the midpoint between the two columns and their gradients -z_tilde_mid = [0.0, -50.0, -150.0, -300.0, -600.0] +z_tilde_mid = [0.0, -48.0, -144.0, -288.0, -576.0] # m / km z_tilde_grad = [0.0, 0.0, 0.0, 0.0, 0.0] diff --git a/polaris/tasks/ocean/two_column/ztilde_gradient.cfg b/polaris/tasks/ocean/two_column/ztilde_gradient.cfg index 3419382619..32278c208d 100644 --- a/polaris/tasks/ocean/two_column/ztilde_gradient.cfg +++ b/polaris/tasks/ocean/two_column/ztilde_gradient.cfg @@ -4,6 +4,6 @@ # Pseudo-height of the bottom of the ocean (with some buffer compared with # geometric sea floor height) at the midpoint between the two columns # and its gradient -z_tilde_bot_mid = -600.0 +z_tilde_bot_mid = -576.0 # m / km z_tilde_bot_grad = 0.5 From 8700d9f27f19449639a8ce46dd7823e5e30f28ba Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 02:35:56 -0600 Subject: [PATCH 39/53] Mask comparisons in analysis step We can't compare the Omega and reference solutions too close to the bathymetry because the reference solution isn't good enough (4th-order gradient breaks down) and doesn't have data at the same locations as the Omega solution. --- polaris/tasks/ocean/two_column/analysis.py | 65 +++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/polaris/tasks/ocean/two_column/analysis.py b/polaris/tasks/ocean/two_column/analysis.py index d3515f2edc..f2f566be2b 100644 --- a/polaris/tasks/ocean/two_column/analysis.py +++ b/polaris/tasks/ocean/two_column/analysis.py @@ -112,6 +112,7 @@ def run(self): ref_z = ds_ref.ZTildeInter.isel(Time=0, nCells=2).values ref_hpga = ds_ref.HPGAInter.isel(Time=0).values + ref_valid_grad_mask = ds_ref.ValidGradInterMask.isel(Time=0).values ref_errors = [] py_errors = [] @@ -135,10 +136,28 @@ def run(self): Time=0, nEdges=edge_index ).values + # maxLevelCell is one-based (Fortran indexing), convert to + # zero-based and use the shallowest valid bottom among the two + # cells that bound the internal edge. + max_level_cells = ds_init.maxLevelCell.isel( + nCells=[cell0, cell1] + ).values.astype(int) + max_level_index = int(np.min(max_level_cells) - 1) + if max_level_index < 0: + raise ValueError( + f'Invalid maxLevelCell values {max_level_cells} at ' + f'resolution {resolution:g} km.' + ) + + forward_valid_mask = np.zeros_like(hpga_forward, dtype=bool) + forward_valid_mask[: max_level_index + 1] = True + sampled_ref_hpga = _sample_reference_without_interpolation( ref_z=ref_z, ref_values=ref_hpga, target_z=z_tilde_forward, + ref_valid_mask=ref_valid_grad_mask, + target_valid_mask=forward_valid_mask, ) ref_errors.append(_rms_error(hpga_forward - sampled_ref_hpga)) @@ -157,10 +176,12 @@ def run(self): 'ZTilde mismatch between Python init and Omega forward ' f'at resolution {resolution:g} km' ), + valid_mask=forward_valid_mask, ) hpga_init = ds_init.HPGA.isel(Time=0).values - py_errors.append(_rms_error(hpga_forward - hpga_init)) + hpga_diff = hpga_forward - hpga_init + py_errors.append(_rms_error(hpga_diff[forward_valid_mask])) resolution_array = np.asarray(horiz_resolutions, dtype=float) ref_error_array = np.asarray(ref_errors, dtype=float) @@ -209,7 +230,7 @@ def run(self): fit=py_fit, slope=py_slope, y_label='RMS difference in HPGA (m s-2)', - title='Omega (C++) vs Polaris (Python) HPGA Difference', + title='Omega vs Polaris HPGA Difference', output='omega_vs_python.png', ) @@ -265,6 +286,8 @@ def _sample_reference_without_interpolation( ref_z: np.ndarray, ref_values: np.ndarray, target_z: np.ndarray, + ref_valid_mask: np.ndarray | None = None, + target_valid_mask: np.ndarray | None = None, abs_tol: float = 1.0e-6, rel_tol: float = 1.0e-10, ) -> np.ndarray: @@ -275,10 +298,32 @@ def _sample_reference_without_interpolation( ref_z = np.asarray(ref_z, dtype=float) ref_values = np.asarray(ref_values, dtype=float) target_z = np.asarray(target_z, dtype=float) + if ref_valid_mask is not None: + ref_valid_mask = np.asarray(ref_valid_mask, dtype=bool) + if ref_valid_mask.shape != ref_z.shape: + raise ValueError( + 'ref_valid_mask must have the same shape as ref_z.' + ) + if target_valid_mask is not None: + target_valid_mask = np.asarray(target_valid_mask, dtype=bool) + if target_valid_mask.shape != target_z.shape: + raise ValueError( + 'target_valid_mask must have the same shape as target_z.' + ) sampled = np.full_like(target_z, np.nan, dtype=float) valid_target = np.isfinite(target_z) + if target_valid_mask is not None: + valid_target = np.logical_and(valid_target, target_valid_mask) valid_ref = np.logical_and(np.isfinite(ref_z), np.isfinite(ref_values)) + if ref_valid_mask is not None: + valid_ref = np.logical_and(valid_ref, ref_valid_mask) + + # The bottom valid forward layer hits bathymetry and should not be used + # in the reference comparison. + if np.any(valid_target): + deepest = int(np.where(valid_target)[0][-1]) + valid_target[deepest] = False ref_z_valid = ref_z[valid_ref] ref_values_valid = ref_values[valid_ref] @@ -286,6 +331,11 @@ def _sample_reference_without_interpolation( target_valid = target_z[valid_target] if len(target_valid) == 0: return sampled + if len(ref_z_valid) == 0: + raise ValueError( + 'No valid reference z-tilde values remain after applying ' + 'ValidGradInterMask.' + ) dz = np.abs(ref_z_valid[:, np.newaxis] - target_valid[np.newaxis, :]) indices = np.argmin(dz, axis=0) @@ -308,6 +358,7 @@ def _check_vertical_match( z_ref: np.ndarray, z_test: np.ndarray, msg: str, + valid_mask: np.ndarray | None = None, abs_tol: float = 1.0e-6, rel_tol: float = 1.0e-10, ) -> None: @@ -321,7 +372,17 @@ def _check_vertical_match( f'{msg}: shape mismatch {z_ref.shape} != {z_test.shape}' ) + if valid_mask is not None: + valid_mask = np.asarray(valid_mask, dtype=bool) + if valid_mask.shape != z_ref.shape: + raise ValueError( + f'{msg}: valid_mask shape mismatch ' + f'{valid_mask.shape} != {z_ref.shape}' + ) + valid = np.logical_and(np.isfinite(z_ref), np.isfinite(z_test)) + if valid_mask is not None: + valid = np.logical_and(valid, valid_mask) if not np.any(valid): raise ValueError(f'{msg}: no valid levels for comparison.') From bc13be925bb959234efe09b56562c58f057a02e2 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 02:56:05 -0600 Subject: [PATCH 40/53] Add thresholds to analysis step The analysis fails if errors exceed the thresholds or if the convergence rate is outside the expected range. --- polaris/tasks/ocean/two_column/analysis.py | 201 +++++++++++++++--- polaris/tasks/ocean/two_column/two_column.cfg | 13 ++ 2 files changed, 182 insertions(+), 32 deletions(-) diff --git a/polaris/tasks/ocean/two_column/analysis.py b/polaris/tasks/ocean/two_column/analysis.py index f2f566be2b..17c9607df9 100644 --- a/polaris/tasks/ocean/two_column/analysis.py +++ b/polaris/tasks/ocean/two_column/analysis.py @@ -102,6 +102,34 @@ def run(self): 'The "horiz_resolutions" configuration option must be set in ' 'the "two_column" section.' ) + omega_vs_polaris_norm_rmse_threshold = section.getfloat( + 'omega_vs_polaris_norm_rmse_threshold' + ) + assert omega_vs_polaris_norm_rmse_threshold is not None, ( + 'The "omega_vs_polaris_norm_rmse_threshold" configuration ' + 'option must be set in the "two_column" section.' + ) + omega_vs_reference_high_res_norm_rmse_threshold = section.getfloat( + 'omega_vs_reference_high_res_norm_rmse_threshold' + ) + assert omega_vs_reference_high_res_norm_rmse_threshold is not None, ( + 'The "omega_vs_reference_high_res_norm_rmse_threshold" ' + 'configuration option must be set in the "two_column" section.' + ) + omega_vs_reference_convergence_rate_min = section.getfloat( + 'omega_vs_reference_convergence_rate_min' + ) + assert omega_vs_reference_convergence_rate_min is not None, ( + 'The "omega_vs_reference_convergence_rate_min" configuration ' + 'option must be set in the "two_column" section.' + ) + omega_vs_reference_convergence_rate_max = section.getfloat( + 'omega_vs_reference_convergence_rate_max' + ) + assert omega_vs_reference_convergence_rate_max is not None, ( + 'The "omega_vs_reference_convergence_rate_max" configuration ' + 'option must be set in the "two_column" section.' + ) ds_ref = self.open_model_dataset('reference_solution.nc') if ds_ref.sizes.get('nCells', 0) <= 2: @@ -115,6 +143,7 @@ def run(self): ref_valid_grad_mask = ds_ref.ValidGradInterMask.isel(Time=0).values ref_errors = [] + ref_norm_errors = [] py_errors = [] for resolution in horiz_resolutions: @@ -160,7 +189,14 @@ def run(self): target_valid_mask=forward_valid_mask, ) - ref_errors.append(_rms_error(hpga_forward - sampled_ref_hpga)) + hpga_ref_diff = hpga_forward - sampled_ref_hpga + ref_errors.append(_rms_error(hpga_ref_diff)) + ref_norm_errors.append( + _normalized_rms_error( + values=hpga_ref_diff, + reference_values=sampled_ref_hpga, + ) + ) z_tilde_init = ( 0.5 @@ -181,20 +217,23 @@ def run(self): hpga_init = ds_init.HPGA.isel(Time=0).values hpga_diff = hpga_forward - hpga_init - py_errors.append(_rms_error(hpga_diff[forward_valid_mask])) + py_errors.append( + _normalized_rms_error( + values=hpga_diff, + reference_values=hpga_init, + valid_mask=forward_valid_mask, + ) + ) resolution_array = np.asarray(horiz_resolutions, dtype=float) ref_error_array = np.asarray(ref_errors, dtype=float) + ref_norm_error_array = np.asarray(ref_norm_errors, dtype=float) py_error_array = np.asarray(py_errors, dtype=float) ref_fit, ref_slope, ref_intercept = _power_law_fit( x=resolution_array, y=ref_error_array, ) - py_fit, py_slope, py_intercept = _power_law_fit( - x=resolution_array, - y=py_error_array, - ) _write_dataset( filename='omega_vs_reference.nc', @@ -204,15 +243,14 @@ def run(self): slope=ref_slope, intercept=ref_intercept, y_name='rms_error_vs_reference', + y_units='m s-2', ) _write_dataset( filename='omega_vs_python.nc', resolution_km=resolution_array, rms_error=py_error_array, - fit=py_fit, - slope=py_slope, - intercept=py_intercept, y_name='rms_error_vs_python', + y_units='1', ) _plot_errors( @@ -227,15 +265,62 @@ def run(self): _plot_errors( resolution_km=resolution_array, rms_error=py_error_array, - fit=py_fit, - slope=py_slope, - y_label='RMS difference in HPGA (m s-2)', + y_label='Normalized RMS difference in HPGA', title='Omega vs Polaris HPGA Difference', output='omega_vs_python.png', ) logger.info(f'Omega-vs-reference convergence slope: {ref_slope:1.3f}') - logger.info(f'Omega-vs-Python convergence slope: {py_slope:1.3f}') + logger.info( + 'Omega-vs-Polaris normalized RMS differences by resolution: ' + f'{dict(zip(resolution_array, py_error_array, strict=True))}' + ) + + failing_polaris = py_error_array > omega_vs_polaris_norm_rmse_threshold + if np.any(failing_polaris): + failing_text = ', '.join( + [ + f'{resolution_array[index]:g} km: ' + f'{py_error_array[index]:.3e}' + for index in np.where(failing_polaris)[0] + ] + ) + raise ValueError( + 'Omega-vs-Polaris normalized RMS difference exceeds ' + 'omega_vs_polaris_norm_rmse_threshold ' + f'({omega_vs_polaris_norm_rmse_threshold:.3e}) at: ' + f'{failing_text}' + ) + + highest_resolution_index = int(np.argmin(resolution_array)) + highest_resolution = float(resolution_array[highest_resolution_index]) + highest_resolution_norm_ref_error = float( + ref_norm_error_array[highest_resolution_index] + ) + if ( + highest_resolution_norm_ref_error + > omega_vs_reference_high_res_norm_rmse_threshold + ): + raise ValueError( + 'Normalized Omega-vs-reference RMS error at highest ' + f'resolution ({highest_resolution:g} km) is ' + f'{highest_resolution_norm_ref_error:.3e}, which exceeds ' + 'omega_vs_reference_high_res_norm_rmse_threshold ' + f'({omega_vs_reference_high_res_norm_rmse_threshold:.3e}).' + ) + + if not ( + omega_vs_reference_convergence_rate_min + <= ref_slope + <= omega_vs_reference_convergence_rate_max + ): + raise ValueError( + 'Omega-vs-reference convergence slope is outside the ' + 'allowed range: ' + f'{ref_slope:.3f} not in ' + f'[{omega_vs_reference_convergence_rate_min:.3f}, ' + f'{omega_vs_reference_convergence_rate_max:.3f}]' + ) def _get_internal_edge(ds_init: xr.Dataset) -> tuple[int, tuple[int, int]]: @@ -405,6 +490,48 @@ def _rms_error(values: np.ndarray) -> float: return float(np.sqrt(np.mean(values[valid] ** 2))) +def _normalized_rms_error( + values: np.ndarray, + reference_values: np.ndarray, + valid_mask: np.ndarray | None = None, +) -> float: + """ + Compute normalized RMS error using max(abs(reference_values)). + """ + values = np.asarray(values, dtype=float) + reference_values = np.asarray(reference_values, dtype=float) + if values.shape != reference_values.shape: + raise ValueError( + 'values and reference_values must have the same shape for ' + 'normalized RMS error.' + ) + + valid = np.logical_and(np.isfinite(values), np.isfinite(reference_values)) + if valid_mask is not None: + valid_mask = np.asarray(valid_mask, dtype=bool) + if valid_mask.shape != values.shape: + raise ValueError( + 'valid_mask must have the same shape as values for ' + 'normalized RMS error.' + ) + valid = np.logical_and(valid, valid_mask) + + if not np.any(valid): + raise ValueError( + 'No finite values available for normalized RMS error.' + ) + + max_abs_reference = float(np.max(np.abs(reference_values[valid]))) + if max_abs_reference <= 0.0: + raise ValueError( + 'Cannot normalize RMS error because max(abs(reference_values)) ' + 'is not positive.' + ) + + rms_error = _rms_error(values[valid]) + return rms_error / max_abs_reference + + def _power_law_fit( x: np.ndarray, y: np.ndarray, @@ -434,10 +561,11 @@ def _write_dataset( filename: str, resolution_km: np.ndarray, rms_error: np.ndarray, - fit: np.ndarray, - slope: float, - intercept: float, y_name: str, + y_units: str, + fit: np.ndarray | None = None, + slope: float | None = None, + intercept: float | None = None, ) -> None: """ Write data used in a convergence plot to netCDF. @@ -452,15 +580,21 @@ def _write_dataset( ds[y_name] = xr.DataArray( data=rms_error, dims=['nResolutions'], - attrs={'long_name': y_name.replace('_', ' '), 'units': 'm s-2'}, - ) - ds['power_law_fit'] = xr.DataArray( - data=fit, - dims=['nResolutions'], - attrs={'long_name': 'power-law fit to rms error', 'units': 'm s-2'}, + attrs={'long_name': y_name.replace('_', ' '), 'units': y_units}, ) - ds.attrs['fit_slope'] = slope - ds.attrs['fit_intercept_log10'] = intercept + if fit is not None: + ds['power_law_fit'] = xr.DataArray( + data=fit, + dims=['nResolutions'], + attrs={ + 'long_name': 'power-law fit to rms error', + 'units': y_units, + }, + ) + if slope is not None: + ds.attrs['fit_slope'] = slope + if intercept is not None: + ds.attrs['fit_intercept_log10'] = intercept ds.attrs['nResolutions'] = nres ds.to_netcdf(filename) @@ -468,11 +602,11 @@ def _write_dataset( def _plot_errors( resolution_km: np.ndarray, rms_error: np.ndarray, - fit: np.ndarray, - slope: float, y_label: str, title: str, output: str, + fit: np.ndarray | None = None, + slope: float | None = None, ) -> None: """ Plot RMS error vs. horizontal resolution with a power-law fit. @@ -481,12 +615,15 @@ def _plot_errors( fig = plt.figure() ax = fig.add_subplot(111) - ax.loglog( - resolution_km, - fit, - 'k', - label=f'power-law fit (slope={slope:1.3f})', - ) + if fit is not None: + if slope is None: + raise ValueError('slope must be provided when fit is provided.') + ax.loglog( + resolution_km, + fit, + 'k', + label=f'power-law fit (slope={slope:1.3f})', + ) ax.loglog(resolution_km, rms_error, 'o', label='RMS error') ax.set_xlabel('Horizontal resolution (km)') diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index dea8a50145..5f5d34134a 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -77,3 +77,16 @@ reference_quadrature_method = gauss4 # reference solution's horizontal resolution in km reference_horiz_res = 1.0 + +# Maximum normalized RMS difference allowed between Omega forward and +# Polaris/Python init HPGA at each resolution +omega_vs_polaris_norm_rmse_threshold = 1.0e-12 + +# Maximum normalized RMS error allowed between Omega and the reference +# solution at the highest resolution +omega_vs_reference_high_res_norm_rmse_threshold = 1.0e-7 + +# Allowed range for the Omega-vs-reference convergence slope from the +# power-law fit +omega_vs_reference_convergence_rate_min = 1.9 +omega_vs_reference_convergence_rate_max = 2.1 From e562338e55ce3cd82c661a18b6356c16d72a45e4 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 03:04:20 -0600 Subject: [PATCH 41/53] Add a temperature gradient task --- polaris/tasks/ocean/two_column/__init__.py | 6 +++++- polaris/tasks/ocean/two_column/temperature_gradient.cfg | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 polaris/tasks/ocean/two_column/temperature_gradient.cfg diff --git a/polaris/tasks/ocean/two_column/__init__.py b/polaris/tasks/ocean/two_column/__init__.py index bc6754d92a..c5f6aa730b 100644 --- a/polaris/tasks/ocean/two_column/__init__.py +++ b/polaris/tasks/ocean/two_column/__init__.py @@ -10,5 +10,9 @@ def add_two_column_tasks(component): component : polaris.tasks.ocean.Ocean the ocean component that the tasks will be added to """ - for name in ['salinity_gradient', 'ztilde_gradient']: + for name in [ + 'salinity_gradient', + 'temperature_gradient', + 'ztilde_gradient', + ]: component.add_task(TwoColumnTask(component=component, name=name)) diff --git a/polaris/tasks/ocean/two_column/temperature_gradient.cfg b/polaris/tasks/ocean/two_column/temperature_gradient.cfg new file mode 100644 index 0000000000..79acd7d359 --- /dev/null +++ b/polaris/tasks/ocean/two_column/temperature_gradient.cfg @@ -0,0 +1,8 @@ +# config options for two column testcases +[two_column] + +# temperatures for piecewise linear initial conditions at the midpoint between +# the two columns and their gradients +temperature_mid = [22.0, 20.0, 14.0, 8.0, 5.0] +# K / km +temperature_grad = [3.0, 1.0, 0.3, 0.2, 0.1] From a79209d806afbcfbd13b3812f2e182cde540ce8e Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 04:25:06 -0600 Subject: [PATCH 42/53] Add 6 km and 3 km resolutions Limit the convergence analysis to 6 km and higher. Add absolute error threshold for situations where the HPGA is too small for the normalize error to be useful. --- polaris/tasks/ocean/two_column/analysis.py | 233 +++++++++++++++--- polaris/tasks/ocean/two_column/two_column.cfg | 27 +- 2 files changed, 226 insertions(+), 34 deletions(-) diff --git a/polaris/tasks/ocean/two_column/analysis.py b/polaris/tasks/ocean/two_column/analysis.py index 17c9607df9..5be79e5784 100644 --- a/polaris/tasks/ocean/two_column/analysis.py +++ b/polaris/tasks/ocean/two_column/analysis.py @@ -130,6 +130,41 @@ def run(self): 'The "omega_vs_reference_convergence_rate_max" configuration ' 'option must be set in the "two_column" section.' ) + omega_vs_reference_convergence_fit_max_resolution = section.getfloat( + 'omega_vs_reference_convergence_fit_max_resolution' + ) + assert omega_vs_reference_convergence_fit_max_resolution is not None, ( + 'The "omega_vs_reference_convergence_fit_max_resolution" ' + 'configuration option must be set in the "two_column" section.' + ) + omega_vs_reference_reference_scale_floor = section.getfloat( + 'omega_vs_reference_reference_scale_floor' + ) + assert omega_vs_reference_reference_scale_floor is not None, ( + 'The "omega_vs_reference_reference_scale_floor" ' + 'configuration option must be set in the "two_column" section.' + ) + omega_vs_reference_high_res_rms_threshold = section.getfloat( + 'omega_vs_reference_high_res_rms_threshold' + ) + assert omega_vs_reference_high_res_rms_threshold is not None, ( + 'The "omega_vs_reference_high_res_rms_threshold" ' + 'configuration option must be set in the "two_column" section.' + ) + omega_vs_polaris_reference_scale_floor = section.getfloat( + 'omega_vs_polaris_reference_scale_floor' + ) + assert omega_vs_polaris_reference_scale_floor is not None, ( + 'The "omega_vs_polaris_reference_scale_floor" configuration ' + 'option must be set in the "two_column" section.' + ) + omega_vs_polaris_rms_threshold = section.getfloat( + 'omega_vs_polaris_rms_threshold' + ) + assert omega_vs_polaris_rms_threshold is not None, ( + 'The "omega_vs_polaris_rms_threshold" configuration option ' + 'must be set in the "two_column" section.' + ) ds_ref = self.open_model_dataset('reference_solution.nc') if ds_ref.sizes.get('nCells', 0) <= 2: @@ -144,7 +179,10 @@ def run(self): ref_errors = [] ref_norm_errors = [] + ref_reference_scales = [] py_errors = [] + py_abs_errors = [] + py_reference_scales = [] for resolution in horiz_resolutions: ds_init = self.open_model_dataset(f'init_r{resolution:02g}.nc') @@ -191,12 +229,17 @@ def run(self): hpga_ref_diff = hpga_forward - sampled_ref_hpga ref_errors.append(_rms_error(hpga_ref_diff)) - ref_norm_errors.append( - _normalized_rms_error( - values=hpga_ref_diff, - reference_values=sampled_ref_hpga, + ref_reference_scale = _max_abs(sampled_ref_hpga) + ref_reference_scales.append(ref_reference_scale) + if ref_reference_scale > omega_vs_reference_reference_scale_floor: + ref_norm_errors.append( + _normalized_rms_error( + values=hpga_ref_diff, + reference_values=sampled_ref_hpga, + ) ) - ) + else: + ref_norm_errors.append(np.nan) z_tilde_init = ( 0.5 @@ -217,22 +260,44 @@ def run(self): hpga_init = ds_init.HPGA.isel(Time=0).values hpga_diff = hpga_forward - hpga_init - py_errors.append( - _normalized_rms_error( - values=hpga_diff, - reference_values=hpga_init, - valid_mask=forward_valid_mask, - ) + py_abs_error = _rms_error(hpga_diff[forward_valid_mask]) + py_abs_errors.append(py_abs_error) + + py_reference_scale = _max_abs( + hpga_init, + valid_mask=forward_valid_mask, ) + py_reference_scales.append(py_reference_scale) + if py_reference_scale > omega_vs_polaris_reference_scale_floor: + py_errors.append( + _normalized_rms_error( + values=hpga_diff, + reference_values=hpga_init, + valid_mask=forward_valid_mask, + ) + ) + else: + py_errors.append(np.nan) resolution_array = np.asarray(horiz_resolutions, dtype=float) ref_error_array = np.asarray(ref_errors, dtype=float) ref_norm_error_array = np.asarray(ref_norm_errors, dtype=float) + ref_reference_scale_array = np.asarray( + ref_reference_scales, dtype=float + ) py_error_array = np.asarray(py_errors, dtype=float) + py_abs_error_array = np.asarray(py_abs_errors, dtype=float) + py_reference_scale_array = np.asarray(py_reference_scales, dtype=float) + + fit_mask = ( + resolution_array + <= omega_vs_reference_convergence_fit_max_resolution + ) ref_fit, ref_slope, ref_intercept = _power_law_fit( x=resolution_array, y=ref_error_array, + fit_mask=fit_mask, ) _write_dataset( @@ -271,25 +336,71 @@ def run(self): ) logger.info(f'Omega-vs-reference convergence slope: {ref_slope:1.3f}') + logger.info( + 'Omega-vs-reference fit uses resolutions (km): ' + f'{list(resolution_array[fit_mask])}' + ) logger.info( 'Omega-vs-Polaris normalized RMS differences by resolution: ' f'{dict(zip(resolution_array, py_error_array, strict=True))}' ) - failing_polaris = py_error_array > omega_vs_polaris_norm_rmse_threshold - if np.any(failing_polaris): - failing_text = ', '.join( - [ - f'{resolution_array[index]:g} km: ' - f'{py_error_array[index]:.3e}' - for index in np.where(failing_polaris)[0] - ] + low_scale_ref = np.logical_not(np.isfinite(ref_norm_error_array)) + if np.any(low_scale_ref): + logger.info( + 'Omega-vs-reference low-signal resolutions using ' + 'absolute-RMS fallback (km): ' + f'{list(resolution_array[low_scale_ref])}' + ) + + low_scale_polaris = np.logical_not(np.isfinite(py_error_array)) + if np.any(low_scale_polaris): + logger.info( + 'Omega-vs-Polaris low-signal resolutions using ' + 'absolute-RMS fallback (km): ' + f'{list(resolution_array[low_scale_polaris])}' ) + + failing_polaris_norm = np.logical_and( + np.isfinite(py_error_array), + py_error_array > omega_vs_polaris_norm_rmse_threshold, + ) + failing_polaris_abs = np.logical_and( + np.logical_not(np.isfinite(py_error_array)), + py_abs_error_array > omega_vs_polaris_rms_threshold, + ) + if np.any(failing_polaris_norm) or np.any(failing_polaris_abs): + failing_parts = [] + if np.any(failing_polaris_norm): + failing_norm_text = ', '.join( + [ + f'{resolution_array[index]:g} km: ' + f'norm={py_error_array[index]:.3e}' + for index in np.where(failing_polaris_norm)[0] + ] + ) + failing_parts.append(f'normalized: {failing_norm_text}') + + if np.any(failing_polaris_abs): + failing_abs_text = ', '.join( + [ + f'{resolution_array[index]:g} km: ' + f'abs={py_abs_error_array[index]:.3e}, ' + f'scale={py_reference_scale_array[index]:.3e}' + for index in np.where(failing_polaris_abs)[0] + ] + ) + failing_parts.append(f'absolute fallback: {failing_abs_text}') + raise ValueError( - 'Omega-vs-Polaris normalized RMS difference exceeds ' - 'omega_vs_polaris_norm_rmse_threshold ' - f'({omega_vs_polaris_norm_rmse_threshold:.3e}) at: ' - f'{failing_text}' + 'Omega-vs-Polaris error exceeds configured thresholds. ' + 'Normalized threshold ' + f'omega_vs_polaris_norm_rmse_threshold=' + f'{omega_vs_polaris_norm_rmse_threshold:.3e}; ' + 'absolute fallback threshold ' + f'omega_vs_polaris_rms_threshold=' + f'{omega_vs_polaris_rms_threshold:.3e}. Failing cases: ' + f'{"; ".join(failing_parts)}' ) highest_resolution_index = int(np.argmin(resolution_array)) @@ -297,17 +408,45 @@ def run(self): highest_resolution_norm_ref_error = float( ref_norm_error_array[highest_resolution_index] ) + highest_resolution_ref_scale = float( + ref_reference_scale_array[highest_resolution_index] + ) if ( - highest_resolution_norm_ref_error - > omega_vs_reference_high_res_norm_rmse_threshold + highest_resolution_ref_scale + > omega_vs_reference_reference_scale_floor ): - raise ValueError( - 'Normalized Omega-vs-reference RMS error at highest ' - f'resolution ({highest_resolution:g} km) is ' - f'{highest_resolution_norm_ref_error:.3e}, which exceeds ' - 'omega_vs_reference_high_res_norm_rmse_threshold ' - f'({omega_vs_reference_high_res_norm_rmse_threshold:.3e}).' + if ( + highest_resolution_norm_ref_error + > omega_vs_reference_high_res_norm_rmse_threshold + ): + raise ValueError( + 'Normalized Omega-vs-reference RMS error at highest ' + f'resolution ({highest_resolution:g} km) is ' + f'{highest_resolution_norm_ref_error:.3e}, which ' + 'exceeds ' + 'omega_vs_reference_high_res_norm_rmse_threshold ' + f'({omega_vs_reference_high_res_norm_rmse_threshold:.3e}).' + ) + else: + highest_resolution_ref_abs_error = float( + ref_error_array[highest_resolution_index] ) + if ( + highest_resolution_ref_abs_error + > omega_vs_reference_high_res_rms_threshold + ): + raise ValueError( + 'Reference amplitude at highest resolution ' + f'({highest_resolution:g} km) is too small for ' + 'normalized RMS validation ' + f'(scale={highest_resolution_ref_scale:.3e} <= ' + 'omega_vs_reference_reference_scale_floor=' + f'{omega_vs_reference_reference_scale_floor:.3e}), ' + 'and absolute Omega-vs-reference RMS error ' + f'{highest_resolution_ref_abs_error:.3e} exceeds ' + 'omega_vs_reference_high_res_rms_threshold ' + f'({omega_vs_reference_high_res_rms_threshold:.3e}).' + ) if not ( omega_vs_reference_convergence_rate_min @@ -490,6 +629,29 @@ def _rms_error(values: np.ndarray) -> float: return float(np.sqrt(np.mean(values[valid] ** 2))) +def _max_abs( + values: np.ndarray, + valid_mask: np.ndarray | None = None, +) -> float: + """ + Compute max(abs(values)) over finite values and an optional valid mask. + """ + values = np.asarray(values, dtype=float) + valid = np.isfinite(values) + if valid_mask is not None: + valid_mask = np.asarray(valid_mask, dtype=bool) + if valid_mask.shape != values.shape: + raise ValueError( + 'valid_mask must have the same shape as values for max abs.' + ) + valid = np.logical_and(valid, valid_mask) + + if not np.any(valid): + raise ValueError('No finite values available for max-abs computation.') + + return float(np.max(np.abs(values[valid]))) + + def _normalized_rms_error( values: np.ndarray, reference_values: np.ndarray, @@ -535,6 +697,7 @@ def _normalized_rms_error( def _power_law_fit( x: np.ndarray, y: np.ndarray, + fit_mask: np.ndarray | None = None, ) -> tuple[np.ndarray, float, float]: """ Fit y = 10**b * x**m in log10 space. @@ -544,6 +707,14 @@ def _power_law_fit( valid = np.logical_and.reduce( (np.isfinite(x), np.isfinite(y), x > 0.0, y > 0.0) ) + if fit_mask is not None: + fit_mask = np.asarray(fit_mask, dtype=bool) + if fit_mask.shape != x.shape: + raise ValueError( + 'fit_mask must have the same shape as x and y for ' + 'power-law fitting.' + ) + valid = np.logical_and(valid, fit_mask) if np.count_nonzero(valid) < 2: raise ValueError( diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/two_column/two_column.cfg index 5f5d34134a..06404dfc41 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/two_column/two_column.cfg @@ -30,10 +30,10 @@ eos_type = teos-10 [two_column] # resolutions in km (the distance between the two columns) -horiz_resolutions = [16.0, 8.0, 4.0, 2.0, 1.0] +horiz_resolutions = [16.0, 8.0, 6.0, 4.0, 3.0, 2.0, 1.0] # vertical resolution in m -vert_resolutions = [16.0, 8.0, 4.0, 2.0, 1.0] +vert_resolutions = [16.0, 8.0, 6.0, 4.0, 3.0, 2.0, 1.0] # geometric sea surface height at the midpoint between the two columns # and its gradient @@ -80,13 +80,34 @@ reference_horiz_res = 1.0 # Maximum normalized RMS difference allowed between Omega forward and # Polaris/Python init HPGA at each resolution -omega_vs_polaris_norm_rmse_threshold = 1.0e-12 +omega_vs_polaris_norm_rmse_threshold = 1.0e-10 + +# If max(abs(Polaris/Python HPGA)) is below this floor, use absolute RMS +# threshold instead of normalized RMS for Omega-vs-Polaris validation +omega_vs_polaris_reference_scale_floor = 1.0e-12 + +# Maximum absolute RMS difference allowed between Omega forward and +# Polaris/Python init HPGA when using low-signal fallback +omega_vs_polaris_rms_threshold = 1.0e-12 # Maximum normalized RMS error allowed between Omega and the reference # solution at the highest resolution omega_vs_reference_high_res_norm_rmse_threshold = 1.0e-7 +# If max(abs(reference HPGA)) is below this floor, use absolute RMS threshold +# instead of normalized RMS for highest-resolution Omega-vs-reference +# validation +omega_vs_reference_reference_scale_floor = 1.0e-12 + +# Maximum absolute RMS error allowed between Omega and the reference at the +# highest resolution when using low-signal fallback +omega_vs_reference_high_res_rms_threshold = 1.0e-12 + # Allowed range for the Omega-vs-reference convergence slope from the # power-law fit omega_vs_reference_convergence_rate_min = 1.9 omega_vs_reference_convergence_rate_max = 2.1 + +# Maximum horizontal resolution (km) included in the power-law fit. All +# resolutions are still shown in output plots. +omega_vs_reference_convergence_fit_max_resolution = 6.0 From 3d496b13b4a4b8403de1e6287355698d90cc732f Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 04:39:14 -0600 Subject: [PATCH 43/53] Rename two_column --> horiz_press_grad --- polaris/tasks/ocean/add_tasks.py | 6 ++-- .../tasks/ocean/horiz_press_grad/__init__.py | 19 +++++++++++++ .../analysis.py | 28 +++++++++++-------- .../column.py | 8 +++--- .../forward.py | 2 +- .../forward.yaml | 0 .../horiz_press_grad.cfg} | 2 +- .../{two_column => horiz_press_grad}/init.py | 14 ++++++---- .../reference.py | 24 +++++++++------- .../salinity_gradient.cfg | 2 +- .../{two_column => horiz_press_grad}/task.py | 26 ++++++++--------- .../temperature_gradient.cfg | 2 +- .../ztilde_gradient.cfg | 2 +- polaris/tasks/ocean/two_column/__init__.py | 18 ------------ 14 files changed, 81 insertions(+), 72 deletions(-) create mode 100644 polaris/tasks/ocean/horiz_press_grad/__init__.py rename polaris/tasks/ocean/{two_column => horiz_press_grad}/analysis.py (97%) rename polaris/tasks/ocean/{two_column => horiz_press_grad}/column.py (96%) rename polaris/tasks/ocean/{two_column => horiz_press_grad}/forward.py (97%) rename polaris/tasks/ocean/{two_column => horiz_press_grad}/forward.yaml (100%) rename polaris/tasks/ocean/{two_column/two_column.cfg => horiz_press_grad/horiz_press_grad.cfg} (99%) rename polaris/tasks/ocean/{two_column => horiz_press_grad}/init.py (98%) rename polaris/tasks/ocean/{two_column => horiz_press_grad}/reference.py (97%) rename polaris/tasks/ocean/{two_column => horiz_press_grad}/salinity_gradient.cfg (93%) rename polaris/tasks/ocean/{two_column => horiz_press_grad}/task.py (83%) rename polaris/tasks/ocean/{two_column => horiz_press_grad}/temperature_gradient.cfg (93%) rename polaris/tasks/ocean/{two_column => horiz_press_grad}/ztilde_gradient.cfg (93%) delete mode 100644 polaris/tasks/ocean/two_column/__init__.py diff --git a/polaris/tasks/ocean/add_tasks.py b/polaris/tasks/ocean/add_tasks.py index dfe1290c4e..0593839752 100644 --- a/polaris/tasks/ocean/add_tasks.py +++ b/polaris/tasks/ocean/add_tasks.py @@ -7,6 +7,7 @@ add_external_gravity_wave_tasks as add_external_gravity_wave_tasks, ) from polaris.tasks.ocean.geostrophic import add_geostrophic_tasks +from polaris.tasks.ocean.horiz_press_grad import add_horiz_press_grad_tasks from polaris.tasks.ocean.ice_shelf_2d import add_ice_shelf_2d_tasks from polaris.tasks.ocean.inertial_gravity_wave import ( add_inertial_gravity_wave_tasks as add_inertial_gravity_wave_tasks, @@ -21,7 +22,6 @@ from polaris.tasks.ocean.seamount import add_seamount_tasks from polaris.tasks.ocean.single_column import add_single_column_tasks from polaris.tasks.ocean.sphere_transport import add_sphere_transport_tasks -from polaris.tasks.ocean.two_column import add_two_column_tasks def add_ocean_tasks(component): @@ -37,6 +37,7 @@ def add_ocean_tasks(component): add_baroclinic_channel_tasks(component=component) add_barotropic_channel_tasks(component=component) add_barotropic_gyre_tasks(component=component) + add_horiz_press_grad_tasks(component=component) add_ice_shelf_2d_tasks(component=component) add_inertial_gravity_wave_tasks(component=component) add_internal_wave_tasks(component=component) @@ -49,9 +50,6 @@ def add_ocean_tasks(component): # single column tasks add_single_column_tasks(component=component) - # two column tasks - add_two_column_tasks(component=component) - # spherical tasks add_customizable_viz_tasks(component=component) add_cosine_bell_tasks(component=component) diff --git a/polaris/tasks/ocean/horiz_press_grad/__init__.py b/polaris/tasks/ocean/horiz_press_grad/__init__.py new file mode 100644 index 0000000000..6e670e22e9 --- /dev/null +++ b/polaris/tasks/ocean/horiz_press_grad/__init__.py @@ -0,0 +1,19 @@ +from polaris.tasks.ocean.horiz_press_grad.task import HorizPressGradTask + + +def add_horiz_press_grad_tasks(component): + """ + Add tasks for various tests involving the horizonal pressure-gradient + acceleration between two adjacent ocean columns + + Parameters + ---------- + component : polaris.tasks.ocean.Ocean + the ocean component that the tasks will be added to + """ + for name in [ + 'salinity_gradient', + 'temperature_gradient', + 'ztilde_gradient', + ]: + component.add_task(HorizPressGradTask(component=component, name=name)) diff --git a/polaris/tasks/ocean/two_column/analysis.py b/polaris/tasks/ocean/horiz_press_grad/analysis.py similarity index 97% rename from polaris/tasks/ocean/two_column/analysis.py rename to polaris/tasks/ocean/horiz_press_grad/analysis.py index 5be79e5784..559eb7566e 100644 --- a/polaris/tasks/ocean/two_column/analysis.py +++ b/polaris/tasks/ocean/horiz_press_grad/analysis.py @@ -58,7 +58,7 @@ def setup(self): """ super().setup() - section = self.config['two_column'] + section = self.config['horiz_press_grad'] horiz_resolutions = section.getexpression('horiz_resolutions') assert horiz_resolutions is not None @@ -96,74 +96,78 @@ def run(self): '"vertical_grid" section.' ) - section = config['two_column'] + section = config['horiz_press_grad'] horiz_resolutions = section.getexpression('horiz_resolutions') assert horiz_resolutions is not None, ( 'The "horiz_resolutions" configuration option must be set in ' - 'the "two_column" section.' + 'the "horiz_press_grad" section.' ) omega_vs_polaris_norm_rmse_threshold = section.getfloat( 'omega_vs_polaris_norm_rmse_threshold' ) assert omega_vs_polaris_norm_rmse_threshold is not None, ( 'The "omega_vs_polaris_norm_rmse_threshold" configuration ' - 'option must be set in the "two_column" section.' + 'option must be set in the "horiz_press_grad" section.' ) omega_vs_reference_high_res_norm_rmse_threshold = section.getfloat( 'omega_vs_reference_high_res_norm_rmse_threshold' ) assert omega_vs_reference_high_res_norm_rmse_threshold is not None, ( 'The "omega_vs_reference_high_res_norm_rmse_threshold" ' - 'configuration option must be set in the "two_column" section.' + 'configuration option must be set in the "horiz_press_grad" ' + 'section.' ) omega_vs_reference_convergence_rate_min = section.getfloat( 'omega_vs_reference_convergence_rate_min' ) assert omega_vs_reference_convergence_rate_min is not None, ( 'The "omega_vs_reference_convergence_rate_min" configuration ' - 'option must be set in the "two_column" section.' + 'option must be set in the "horiz_press_grad" section.' ) omega_vs_reference_convergence_rate_max = section.getfloat( 'omega_vs_reference_convergence_rate_max' ) assert omega_vs_reference_convergence_rate_max is not None, ( 'The "omega_vs_reference_convergence_rate_max" configuration ' - 'option must be set in the "two_column" section.' + 'option must be set in the "horiz_press_grad" section.' ) omega_vs_reference_convergence_fit_max_resolution = section.getfloat( 'omega_vs_reference_convergence_fit_max_resolution' ) assert omega_vs_reference_convergence_fit_max_resolution is not None, ( 'The "omega_vs_reference_convergence_fit_max_resolution" ' - 'configuration option must be set in the "two_column" section.' + 'configuration option must be set in the "horiz_press_grad" ' + 'section.' ) omega_vs_reference_reference_scale_floor = section.getfloat( 'omega_vs_reference_reference_scale_floor' ) assert omega_vs_reference_reference_scale_floor is not None, ( 'The "omega_vs_reference_reference_scale_floor" ' - 'configuration option must be set in the "two_column" section.' + 'configuration option must be set in the "horiz_press_grad" ' + 'section.' ) omega_vs_reference_high_res_rms_threshold = section.getfloat( 'omega_vs_reference_high_res_rms_threshold' ) assert omega_vs_reference_high_res_rms_threshold is not None, ( 'The "omega_vs_reference_high_res_rms_threshold" ' - 'configuration option must be set in the "two_column" section.' + 'configuration option must be set in the "horiz_press_grad" ' + 'section.' ) omega_vs_polaris_reference_scale_floor = section.getfloat( 'omega_vs_polaris_reference_scale_floor' ) assert omega_vs_polaris_reference_scale_floor is not None, ( 'The "omega_vs_polaris_reference_scale_floor" configuration ' - 'option must be set in the "two_column" section.' + 'option must be set in the "horiz_press_grad" section.' ) omega_vs_polaris_rms_threshold = section.getfloat( 'omega_vs_polaris_rms_threshold' ) assert omega_vs_polaris_rms_threshold is not None, ( 'The "omega_vs_polaris_rms_threshold" configuration option ' - 'must be set in the "two_column" section.' + 'must be set in the "horiz_press_grad" section.' ) ds_ref = self.open_model_dataset('reference_solution.nc') diff --git a/polaris/tasks/ocean/two_column/column.py b/polaris/tasks/ocean/horiz_press_grad/column.py similarity index 96% rename from polaris/tasks/ocean/two_column/column.py rename to polaris/tasks/ocean/horiz_press_grad/column.py index 057b41349e..4c7995027d 100644 --- a/polaris/tasks/ocean/two_column/column.py +++ b/polaris/tasks/ocean/horiz_press_grad/column.py @@ -17,7 +17,7 @@ def get_array_from_mid_grad( ---------- config : PolarisConfigParser The configuration parser containing the options "{name}_mid" and - "{name}_grad" in the "two_column" section. + "{name}_grad" in the "horiz_press_grad" section. x : np.ndarray The x-coordinates at which to evaluate the array name : str @@ -28,17 +28,17 @@ def get_array_from_mid_grad( array : np.ndarray The array evaluated at the given x-coordinates """ - section = config['two_column'] + section = config['horiz_press_grad'] mid = section.getnumpy(f'{name}_mid') grad = section.getnumpy(f'{name}_grad') assert mid is not None, ( f'The "{name}_mid" configuration option must be set in the ' - '"two_column" section.' + '"horiz_press_grad" section.' ) assert grad is not None, ( f'The "{name}_grad" configuration option must be set in the ' - '"two_column" section.' + '"horiz_press_grad" section.' ) if isinstance(mid, (list, tuple, np.ndarray)): diff --git a/polaris/tasks/ocean/two_column/forward.py b/polaris/tasks/ocean/horiz_press_grad/forward.py similarity index 97% rename from polaris/tasks/ocean/two_column/forward.py rename to polaris/tasks/ocean/horiz_press_grad/forward.py index 1af15345b3..bd82a0b820 100644 --- a/polaris/tasks/ocean/two_column/forward.py +++ b/polaris/tasks/ocean/horiz_press_grad/forward.py @@ -65,7 +65,7 @@ def setup(self): ) self.add_yaml_file( - 'polaris.tasks.ocean.two_column', + 'polaris.tasks.ocean.horiz_press_grad', 'forward.yaml', template_replacements={'rho0': rho0}, ) diff --git a/polaris/tasks/ocean/two_column/forward.yaml b/polaris/tasks/ocean/horiz_press_grad/forward.yaml similarity index 100% rename from polaris/tasks/ocean/two_column/forward.yaml rename to polaris/tasks/ocean/horiz_press_grad/forward.yaml diff --git a/polaris/tasks/ocean/two_column/two_column.cfg b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg similarity index 99% rename from polaris/tasks/ocean/two_column/two_column.cfg rename to polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg index 06404dfc41..306a3872c5 100644 --- a/polaris/tasks/ocean/two_column/two_column.cfg +++ b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg @@ -27,7 +27,7 @@ eos_type = teos-10 # config options for two column testcases -[two_column] +[horiz_press_grad] # resolutions in km (the distance between the two columns) horiz_resolutions = [16.0, 8.0, 6.0, 4.0, 3.0, 2.0, 1.0] diff --git a/polaris/tasks/ocean/two_column/init.py b/polaris/tasks/ocean/horiz_press_grad/init.py similarity index 98% rename from polaris/tasks/ocean/two_column/init.py rename to polaris/tasks/ocean/horiz_press_grad/init.py index 84b06aaa03..010e46d507 100644 --- a/polaris/tasks/ocean/two_column/init.py +++ b/polaris/tasks/ocean/horiz_press_grad/init.py @@ -14,7 +14,7 @@ pressure_from_z_tilde, ) from polaris.resolution import resolution_to_string -from polaris.tasks.ocean.two_column.column import ( +from polaris.tasks.ocean.horiz_press_grad.column import ( get_array_from_mid_grad, get_pchip_interpolator, ) @@ -72,7 +72,7 @@ def run(self): config = self.config if config.get('ocean', 'model') != 'omega': raise ValueError( - 'The two_column test case is only supported for the ' + 'The horiz_press_grad test case is only supported for the ' 'Omega ocean model.' ) @@ -84,11 +84,13 @@ def run(self): '"vertical_grid" section.' ) - z_tilde_bot_mid = config.getfloat('two_column', 'z_tilde_bot_mid') + z_tilde_bot_mid = config.getfloat( + 'horiz_press_grad', 'z_tilde_bot_mid' + ) assert z_tilde_bot_mid is not None, ( 'The "z_tilde_bot_mid" configuration option must be set in the ' - '"two_column" section.' + '"horiz_press_grad" section.' ) # it needs to be an error if the full water column can't be evenly @@ -145,13 +147,13 @@ def run(self): pseudo_bottom_depth = goal_geom_water_column_thickness water_col_adjust_iter_count = config.getint( - 'two_column', 'water_col_adjust_iter_count' + 'horiz_press_grad', 'water_col_adjust_iter_count' ) if water_col_adjust_iter_count is None: raise ValueError( 'The "water_col_adjust_iter_count" configuration option ' - 'must be set in the "two_column" section.' + 'must be set in the "horiz_press_grad" section.' ) for iter in range(water_col_adjust_iter_count): diff --git a/polaris/tasks/ocean/two_column/reference.py b/polaris/tasks/ocean/horiz_press_grad/reference.py similarity index 97% rename from polaris/tasks/ocean/two_column/reference.py rename to polaris/tasks/ocean/horiz_press_grad/reference.py index dd552eda7e..f40ad316a1 100644 --- a/polaris/tasks/ocean/two_column/reference.py +++ b/polaris/tasks/ocean/horiz_press_grad/reference.py @@ -10,7 +10,7 @@ # temporary until we can get this for GCD from polaris.ocean.vertical.ztilde import Gravity -from polaris.tasks.ocean.two_column.column import ( +from polaris.tasks.ocean.horiz_press_grad.column import ( get_array_from_mid_grad, get_pchip_interpolator, ) @@ -83,14 +83,14 @@ def run(self): config = self.config if config.get('ocean', 'model') != 'omega': raise ValueError( - 'The two_column test case is only supported for the ' + 'The horiz_press_grad test case is only supported for the ' 'Omega ocean model.' ) - resolution = config.getfloat('two_column', 'reference_horiz_res') + resolution = config.getfloat('horiz_press_grad', 'reference_horiz_res') assert resolution is not None, ( 'The "reference_horiz_res" configuration option must be set in ' - 'the "two_column" section.' + 'the "horiz_press_grad" section.' ) rho0 = config.getfloat('vertical_grid', 'rho0') assert rho0 is not None, ( @@ -102,17 +102,21 @@ def run(self): geom_ssh, geom_z_bot, z_tilde_bot = self._get_ssh_z_bot(x) - test_vert_res = config.getexpression('two_column', 'vert_resolutions') + test_vert_res = config.getexpression( + 'horiz_press_grad', 'vert_resolutions' + ) test_min_vert_res = np.min(test_vert_res) # Use half the minimum test vertical resolution for the reference # so that reference interfaces lie exactly at test midpoints vert_res = test_min_vert_res / 2.0 - z_tilde_bot_mid = config.getfloat('two_column', 'z_tilde_bot_mid') + z_tilde_bot_mid = config.getfloat( + 'horiz_press_grad', 'z_tilde_bot_mid' + ) assert z_tilde_bot_mid is not None, ( 'The "z_tilde_bot_mid" configuration option must be set in the ' - '"two_column" section.' + '"horiz_press_grad" section.' ) vert_levels = int(-z_tilde_bot_mid / vert_res) @@ -435,11 +439,11 @@ def _compute_column( ]: config = self.config logger = self.logger - section = config['two_column'] + section = config['horiz_press_grad'] method = section.get('reference_quadrature_method') assert method is not None, ( 'The "reference_quadrature_method" configuration option must be ' - 'set in the "two_column" section.' + 'set in the "horiz_press_grad" section.' ) rho0 = config.getfloat('vertical_grid', 'rho0') assert rho0 is not None, ( @@ -451,7 +455,7 @@ def _compute_column( ) assert water_col_adjust_iter_count is not None, ( 'The "water_col_adjust_iter_count" configuration option must be ' - 'set in the "two_column" section.' + 'set in the "horiz_press_grad" section.' ) goal_geom_water_column_thickness = geom_ssh - geom_z_bot diff --git a/polaris/tasks/ocean/two_column/salinity_gradient.cfg b/polaris/tasks/ocean/horiz_press_grad/salinity_gradient.cfg similarity index 93% rename from polaris/tasks/ocean/two_column/salinity_gradient.cfg rename to polaris/tasks/ocean/horiz_press_grad/salinity_gradient.cfg index e28b4bdeb8..a8e9ebd003 100644 --- a/polaris/tasks/ocean/two_column/salinity_gradient.cfg +++ b/polaris/tasks/ocean/horiz_press_grad/salinity_gradient.cfg @@ -1,5 +1,5 @@ # config options for two column testcases -[two_column] +[horiz_press_grad] # salinities for piecewise linear initial conditions at the midpoint between # the two columns and their gradients diff --git a/polaris/tasks/ocean/two_column/task.py b/polaris/tasks/ocean/horiz_press_grad/task.py similarity index 83% rename from polaris/tasks/ocean/two_column/task.py rename to polaris/tasks/ocean/horiz_press_grad/task.py index 34e32fd4a4..407daa8b9d 100644 --- a/polaris/tasks/ocean/two_column/task.py +++ b/polaris/tasks/ocean/horiz_press_grad/task.py @@ -1,13 +1,13 @@ import os from polaris import Task -from polaris.tasks.ocean.two_column.analysis import Analysis -from polaris.tasks.ocean.two_column.forward import Forward -from polaris.tasks.ocean.two_column.init import Init -from polaris.tasks.ocean.two_column.reference import Reference +from polaris.tasks.ocean.horiz_press_grad.analysis import Analysis +from polaris.tasks.ocean.horiz_press_grad.forward import Forward +from polaris.tasks.ocean.horiz_press_grad.init import Init +from polaris.tasks.ocean.horiz_press_grad.reference import Reference -class TwoColumnTask(Task): +class HorizPressGradTask(Task): """ The two-column test case tests convergence of the TEOS-10 pressure-gradient computation in Omega at various horizontal and vertical resolutions. The @@ -37,17 +37,17 @@ def __init__(self, component, name): name : str The name of the test case, which must have a corresponding - .cfg config file in the two_column package that specifies - which properties vary betweeen the columns. + .cfg config file in the horiz_press_grad package that + specifies which properties vary betweeen the columns. """ - subdir = os.path.join('two_column', name) + subdir = os.path.join('horiz_press_grad', name) super().__init__(component=component, name=name, subdir=subdir) self.config.add_from_package( - 'polaris.tasks.ocean.two_column', 'two_column.cfg' + 'polaris.tasks.ocean.horiz_press_grad', 'horiz_press_grad.cfg' ) self.config.add_from_package( - 'polaris.tasks.ocean.two_column', + 'polaris.tasks.ocean.horiz_press_grad', f'{name}.cfg', ) @@ -66,17 +66,17 @@ def _setup_steps(self): """ setup steps given resolutions """ - section = self.config['two_column'] + section = self.config['horiz_press_grad'] horiz_resolutions = section.getexpression('horiz_resolutions') vert_resolutions = section.getexpression('vert_resolutions') assert horiz_resolutions is not None, ( 'The "horiz_resolutions" configuration option must be set in the ' - '"two_column" section.' + '"horiz_press_grad" section.' ) assert vert_resolutions is not None, ( 'The "vert_resolutions" configuration option must be set in the ' - '"two_column" section.' + '"horiz_press_grad" section.' ) assert len(horiz_resolutions) == len(vert_resolutions), ( 'The "horiz_resolutions" and "vert_resolutions" configuration ' diff --git a/polaris/tasks/ocean/two_column/temperature_gradient.cfg b/polaris/tasks/ocean/horiz_press_grad/temperature_gradient.cfg similarity index 93% rename from polaris/tasks/ocean/two_column/temperature_gradient.cfg rename to polaris/tasks/ocean/horiz_press_grad/temperature_gradient.cfg index 79acd7d359..ec64c02a6b 100644 --- a/polaris/tasks/ocean/two_column/temperature_gradient.cfg +++ b/polaris/tasks/ocean/horiz_press_grad/temperature_gradient.cfg @@ -1,5 +1,5 @@ # config options for two column testcases -[two_column] +[horiz_press_grad] # temperatures for piecewise linear initial conditions at the midpoint between # the two columns and their gradients diff --git a/polaris/tasks/ocean/two_column/ztilde_gradient.cfg b/polaris/tasks/ocean/horiz_press_grad/ztilde_gradient.cfg similarity index 93% rename from polaris/tasks/ocean/two_column/ztilde_gradient.cfg rename to polaris/tasks/ocean/horiz_press_grad/ztilde_gradient.cfg index 32278c208d..29845b4276 100644 --- a/polaris/tasks/ocean/two_column/ztilde_gradient.cfg +++ b/polaris/tasks/ocean/horiz_press_grad/ztilde_gradient.cfg @@ -1,5 +1,5 @@ # config options for two column testcases -[two_column] +[horiz_press_grad] # Pseudo-height of the bottom of the ocean (with some buffer compared with # geometric sea floor height) at the midpoint between the two columns diff --git a/polaris/tasks/ocean/two_column/__init__.py b/polaris/tasks/ocean/two_column/__init__.py deleted file mode 100644 index c5f6aa730b..0000000000 --- a/polaris/tasks/ocean/two_column/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from polaris.tasks.ocean.two_column.task import TwoColumnTask - - -def add_two_column_tasks(component): - """ - Add tasks for various tests involving two adjacent ocean columns - - Parameters - ---------- - component : polaris.tasks.ocean.Ocean - the ocean component that the tasks will be added to - """ - for name in [ - 'salinity_gradient', - 'temperature_gradient', - 'ztilde_gradient', - ]: - component.add_task(TwoColumnTask(component=component, name=name)) From e6049e89362a8594d122c58b7289596e62c4046c Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 05:22:25 -0600 Subject: [PATCH 44/53] Switch back to absolute RMS error or difference in analysis Add 0.5 km resolution and lower convergence fit to <= 4 km. Switch reference solution to higher res (0.25 km). Adjust convergence thresholds. --- .../tasks/ocean/horiz_press_grad/analysis.py | 271 +++--------------- .../horiz_press_grad/horiz_press_grad.cfg | 41 +-- 2 files changed, 58 insertions(+), 254 deletions(-) diff --git a/polaris/tasks/ocean/horiz_press_grad/analysis.py b/polaris/tasks/ocean/horiz_press_grad/analysis.py index 559eb7566e..b6815280f8 100644 --- a/polaris/tasks/ocean/horiz_press_grad/analysis.py +++ b/polaris/tasks/ocean/horiz_press_grad/analysis.py @@ -102,21 +102,6 @@ def run(self): 'The "horiz_resolutions" configuration option must be set in ' 'the "horiz_press_grad" section.' ) - omega_vs_polaris_norm_rmse_threshold = section.getfloat( - 'omega_vs_polaris_norm_rmse_threshold' - ) - assert omega_vs_polaris_norm_rmse_threshold is not None, ( - 'The "omega_vs_polaris_norm_rmse_threshold" configuration ' - 'option must be set in the "horiz_press_grad" section.' - ) - omega_vs_reference_high_res_norm_rmse_threshold = section.getfloat( - 'omega_vs_reference_high_res_norm_rmse_threshold' - ) - assert omega_vs_reference_high_res_norm_rmse_threshold is not None, ( - 'The "omega_vs_reference_high_res_norm_rmse_threshold" ' - 'configuration option must be set in the "horiz_press_grad" ' - 'section.' - ) omega_vs_reference_convergence_rate_min = section.getfloat( 'omega_vs_reference_convergence_rate_min' ) @@ -139,14 +124,6 @@ def run(self): 'configuration option must be set in the "horiz_press_grad" ' 'section.' ) - omega_vs_reference_reference_scale_floor = section.getfloat( - 'omega_vs_reference_reference_scale_floor' - ) - assert omega_vs_reference_reference_scale_floor is not None, ( - 'The "omega_vs_reference_reference_scale_floor" ' - 'configuration option must be set in the "horiz_press_grad" ' - 'section.' - ) omega_vs_reference_high_res_rms_threshold = section.getfloat( 'omega_vs_reference_high_res_rms_threshold' ) @@ -155,13 +132,6 @@ def run(self): 'configuration option must be set in the "horiz_press_grad" ' 'section.' ) - omega_vs_polaris_reference_scale_floor = section.getfloat( - 'omega_vs_polaris_reference_scale_floor' - ) - assert omega_vs_polaris_reference_scale_floor is not None, ( - 'The "omega_vs_polaris_reference_scale_floor" configuration ' - 'option must be set in the "horiz_press_grad" section.' - ) omega_vs_polaris_rms_threshold = section.getfloat( 'omega_vs_polaris_rms_threshold' ) @@ -182,11 +152,7 @@ def run(self): ref_valid_grad_mask = ds_ref.ValidGradInterMask.isel(Time=0).values ref_errors = [] - ref_norm_errors = [] - ref_reference_scales = [] py_errors = [] - py_abs_errors = [] - py_reference_scales = [] for resolution in horiz_resolutions: ds_init = self.open_model_dataset(f'init_r{resolution:02g}.nc') @@ -233,17 +199,6 @@ def run(self): hpga_ref_diff = hpga_forward - sampled_ref_hpga ref_errors.append(_rms_error(hpga_ref_diff)) - ref_reference_scale = _max_abs(sampled_ref_hpga) - ref_reference_scales.append(ref_reference_scale) - if ref_reference_scale > omega_vs_reference_reference_scale_floor: - ref_norm_errors.append( - _normalized_rms_error( - values=hpga_ref_diff, - reference_values=sampled_ref_hpga, - ) - ) - else: - ref_norm_errors.append(np.nan) z_tilde_init = ( 0.5 @@ -264,34 +219,11 @@ def run(self): hpga_init = ds_init.HPGA.isel(Time=0).values hpga_diff = hpga_forward - hpga_init - py_abs_error = _rms_error(hpga_diff[forward_valid_mask]) - py_abs_errors.append(py_abs_error) - - py_reference_scale = _max_abs( - hpga_init, - valid_mask=forward_valid_mask, - ) - py_reference_scales.append(py_reference_scale) - if py_reference_scale > omega_vs_polaris_reference_scale_floor: - py_errors.append( - _normalized_rms_error( - values=hpga_diff, - reference_values=hpga_init, - valid_mask=forward_valid_mask, - ) - ) - else: - py_errors.append(np.nan) + py_errors.append(_rms_error(hpga_diff[forward_valid_mask])) resolution_array = np.asarray(horiz_resolutions, dtype=float) ref_error_array = np.asarray(ref_errors, dtype=float) - ref_norm_error_array = np.asarray(ref_norm_errors, dtype=float) - ref_reference_scale_array = np.asarray( - ref_reference_scales, dtype=float - ) py_error_array = np.asarray(py_errors, dtype=float) - py_abs_error_array = np.asarray(py_abs_errors, dtype=float) - py_reference_scale_array = np.asarray(py_reference_scales, dtype=float) fit_mask = ( resolution_array @@ -319,7 +251,7 @@ def run(self): resolution_km=resolution_array, rms_error=py_error_array, y_name='rms_error_vs_python', - y_units='1', + y_units='m s-2', ) _plot_errors( @@ -334,123 +266,55 @@ def run(self): _plot_errors( resolution_km=resolution_array, rms_error=py_error_array, - y_label='Normalized RMS difference in HPGA', - title='Omega vs Polaris HPGA Difference', + y_label='RMS difference in HPGA (m s-2)', + title='Omega vs Polaris HPGA RMS Difference', output='omega_vs_python.png', ) logger.info(f'Omega-vs-reference convergence slope: {ref_slope:1.3f}') logger.info( 'Omega-vs-reference fit uses resolutions (km): ' - f'{list(resolution_array[fit_mask])}' + f'{_format_resolution_list(resolution_array[fit_mask])}' ) logger.info( - 'Omega-vs-Polaris normalized RMS differences by resolution: ' - f'{dict(zip(resolution_array, py_error_array, strict=True))}' - ) - - low_scale_ref = np.logical_not(np.isfinite(ref_norm_error_array)) - if np.any(low_scale_ref): - logger.info( - 'Omega-vs-reference low-signal resolutions using ' - 'absolute-RMS fallback (km): ' - f'{list(resolution_array[low_scale_ref])}' - ) - - low_scale_polaris = np.logical_not(np.isfinite(py_error_array)) - if np.any(low_scale_polaris): - logger.info( - 'Omega-vs-Polaris low-signal resolutions using ' - 'absolute-RMS fallback (km): ' - f'{list(resolution_array[low_scale_polaris])}' - ) - - failing_polaris_norm = np.logical_and( - np.isfinite(py_error_array), - py_error_array > omega_vs_polaris_norm_rmse_threshold, - ) - failing_polaris_abs = np.logical_and( - np.logical_not(np.isfinite(py_error_array)), - py_abs_error_array > omega_vs_polaris_rms_threshold, - ) - if np.any(failing_polaris_norm) or np.any(failing_polaris_abs): - failing_parts = [] - if np.any(failing_polaris_norm): - failing_norm_text = ', '.join( - [ - f'{resolution_array[index]:g} km: ' - f'norm={py_error_array[index]:.3e}' - for index in np.where(failing_polaris_norm)[0] - ] + 'Omega-vs-Polaris RMS differences by resolution: ' + f'{ + _format_resolution_error_pairs( + resolution_array, py_error_array ) - failing_parts.append(f'normalized: {failing_norm_text}') - - if np.any(failing_polaris_abs): - failing_abs_text = ', '.join( - [ - f'{resolution_array[index]:g} km: ' - f'abs={py_abs_error_array[index]:.3e}, ' - f'scale={py_reference_scale_array[index]:.3e}' - for index in np.where(failing_polaris_abs)[0] - ] - ) - failing_parts.append(f'absolute fallback: {failing_abs_text}') - + }' + ) + failing_polaris = py_error_array > omega_vs_polaris_rms_threshold + if np.any(failing_polaris): + failing_text = ', '.join( + [ + f'{resolution_array[index]:g} km: ' + f'{py_error_array[index]:.3e}' + for index in np.where(failing_polaris)[0] + ] + ) raise ValueError( - 'Omega-vs-Polaris error exceeds configured thresholds. ' - 'Normalized threshold ' - f'omega_vs_polaris_norm_rmse_threshold=' - f'{omega_vs_polaris_norm_rmse_threshold:.3e}; ' - 'absolute fallback threshold ' + 'Omega-vs-Polaris RMS difference exceeds ' f'omega_vs_polaris_rms_threshold=' - f'{omega_vs_polaris_rms_threshold:.3e}. Failing cases: ' - f'{"; ".join(failing_parts)}' + f'{omega_vs_polaris_rms_threshold:.3e} at: {failing_text}' ) highest_resolution_index = int(np.argmin(resolution_array)) highest_resolution = float(resolution_array[highest_resolution_index]) - highest_resolution_norm_ref_error = float( - ref_norm_error_array[highest_resolution_index] - ) - highest_resolution_ref_scale = float( - ref_reference_scale_array[highest_resolution_index] + highest_resolution_ref_error = float( + ref_error_array[highest_resolution_index] ) if ( - highest_resolution_ref_scale - > omega_vs_reference_reference_scale_floor + highest_resolution_ref_error + > omega_vs_reference_high_res_rms_threshold ): - if ( - highest_resolution_norm_ref_error - > omega_vs_reference_high_res_norm_rmse_threshold - ): - raise ValueError( - 'Normalized Omega-vs-reference RMS error at highest ' - f'resolution ({highest_resolution:g} km) is ' - f'{highest_resolution_norm_ref_error:.3e}, which ' - 'exceeds ' - 'omega_vs_reference_high_res_norm_rmse_threshold ' - f'({omega_vs_reference_high_res_norm_rmse_threshold:.3e}).' - ) - else: - highest_resolution_ref_abs_error = float( - ref_error_array[highest_resolution_index] + raise ValueError( + 'Omega-vs-reference RMS error at highest resolution ' + f'({highest_resolution:g} km) is ' + f'{highest_resolution_ref_error:.3e}, which exceeds ' + 'omega_vs_reference_high_res_rms_threshold ' + f'({omega_vs_reference_high_res_rms_threshold:.3e}).' ) - if ( - highest_resolution_ref_abs_error - > omega_vs_reference_high_res_rms_threshold - ): - raise ValueError( - 'Reference amplitude at highest resolution ' - f'({highest_resolution:g} km) is too small for ' - 'normalized RMS validation ' - f'(scale={highest_resolution_ref_scale:.3e} <= ' - 'omega_vs_reference_reference_scale_floor=' - f'{omega_vs_reference_reference_scale_floor:.3e}), ' - 'and absolute Omega-vs-reference RMS error ' - f'{highest_resolution_ref_abs_error:.3e} exceeds ' - 'omega_vs_reference_high_res_rms_threshold ' - f'({omega_vs_reference_high_res_rms_threshold:.3e}).' - ) if not ( omega_vs_reference_convergence_rate_min @@ -633,69 +497,26 @@ def _rms_error(values: np.ndarray) -> float: return float(np.sqrt(np.mean(values[valid] ** 2))) -def _max_abs( - values: np.ndarray, - valid_mask: np.ndarray | None = None, -) -> float: +def _format_resolution_list(resolution_km: np.ndarray) -> str: """ - Compute max(abs(values)) over finite values and an optional valid mask. + Format a resolution array as a compact list of floats. """ - values = np.asarray(values, dtype=float) - valid = np.isfinite(values) - if valid_mask is not None: - valid_mask = np.asarray(valid_mask, dtype=bool) - if valid_mask.shape != values.shape: - raise ValueError( - 'valid_mask must have the same shape as values for max abs.' - ) - valid = np.logical_and(valid, valid_mask) - - if not np.any(valid): - raise ValueError('No finite values available for max-abs computation.') + values = [f'{float(resolution):g}' for resolution in resolution_km] + return f'[{", ".join(values)}]' - return float(np.max(np.abs(values[valid]))) - -def _normalized_rms_error( - values: np.ndarray, - reference_values: np.ndarray, - valid_mask: np.ndarray | None = None, -) -> float: +def _format_resolution_error_pairs( + resolution_km: np.ndarray, + rms_error: np.ndarray, +) -> str: """ - Compute normalized RMS error using max(abs(reference_values)). + Format resolution/error pairs as readable key-value text. """ - values = np.asarray(values, dtype=float) - reference_values = np.asarray(reference_values, dtype=float) - if values.shape != reference_values.shape: - raise ValueError( - 'values and reference_values must have the same shape for ' - 'normalized RMS error.' - ) - - valid = np.logical_and(np.isfinite(values), np.isfinite(reference_values)) - if valid_mask is not None: - valid_mask = np.asarray(valid_mask, dtype=bool) - if valid_mask.shape != values.shape: - raise ValueError( - 'valid_mask must have the same shape as values for ' - 'normalized RMS error.' - ) - valid = np.logical_and(valid, valid_mask) - - if not np.any(valid): - raise ValueError( - 'No finite values available for normalized RMS error.' - ) - - max_abs_reference = float(np.max(np.abs(reference_values[valid]))) - if max_abs_reference <= 0.0: - raise ValueError( - 'Cannot normalize RMS error because max(abs(reference_values)) ' - 'is not positive.' - ) - - rms_error = _rms_error(values[valid]) - return rms_error / max_abs_reference + pairs = [ + f'{float(resolution):g} km: {float(error):.3e}' + for resolution, error in zip(resolution_km, rms_error, strict=True) + ] + return '; '.join(pairs) def _power_law_fit( diff --git a/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg index 306a3872c5..55fe871dae 100644 --- a/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg +++ b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg @@ -26,14 +26,14 @@ model = mpas-ocean eos_type = teos-10 -# config options for two column testcases +# config options for horizontal pressure gradient testcases [horiz_press_grad] # resolutions in km (the distance between the two columns) -horiz_resolutions = [16.0, 8.0, 6.0, 4.0, 3.0, 2.0, 1.0] +horiz_resolutions = [16.0, 8.0, 6.0, 4.0, 3.0, 2.0, 1.0, 0.5] # vertical resolution in m -vert_resolutions = [16.0, 8.0, 6.0, 4.0, 3.0, 2.0, 1.0] +vert_resolutions = [16.0, 8.0, 6.0, 4.0, 3.0, 2.0, 1.0, 0.5] # geometric sea surface height at the midpoint between the two columns # and its gradient @@ -76,38 +76,21 @@ water_col_adjust_iter_count = 6 reference_quadrature_method = gauss4 # reference solution's horizontal resolution in km -reference_horiz_res = 1.0 +reference_horiz_res = 0.25 -# Maximum normalized RMS difference allowed between Omega forward and -# Polaris/Python init HPGA at each resolution -omega_vs_polaris_norm_rmse_threshold = 1.0e-10 +# Maximum RMS difference allowed between Omega forward and Polaris/Python +# init HPGA at each resolution +omega_vs_polaris_rms_threshold = 1.0e-10 -# If max(abs(Polaris/Python HPGA)) is below this floor, use absolute RMS -# threshold instead of normalized RMS for Omega-vs-Polaris validation -omega_vs_polaris_reference_scale_floor = 1.0e-12 - -# Maximum absolute RMS difference allowed between Omega forward and -# Polaris/Python init HPGA when using low-signal fallback -omega_vs_polaris_rms_threshold = 1.0e-12 - -# Maximum normalized RMS error allowed between Omega and the reference -# solution at the highest resolution -omega_vs_reference_high_res_norm_rmse_threshold = 1.0e-7 - -# If max(abs(reference HPGA)) is below this floor, use absolute RMS threshold -# instead of normalized RMS for highest-resolution Omega-vs-reference -# validation -omega_vs_reference_reference_scale_floor = 1.0e-12 - -# Maximum absolute RMS error allowed between Omega and the reference at the -# highest resolution when using low-signal fallback -omega_vs_reference_high_res_rms_threshold = 1.0e-12 +# Maximum RMS error allowed between Omega and the reference solution at the +# highest resolution +omega_vs_reference_high_res_rms_threshold = 1.0e-6 # Allowed range for the Omega-vs-reference convergence slope from the # power-law fit -omega_vs_reference_convergence_rate_min = 1.9 +omega_vs_reference_convergence_rate_min = 1.6 omega_vs_reference_convergence_rate_max = 2.1 # Maximum horizontal resolution (km) included in the power-law fit. All # resolutions are still shown in output plots. -omega_vs_reference_convergence_fit_max_resolution = 6.0 +omega_vs_reference_convergence_fit_max_resolution = 4.0 From 33ebdeada9ea2c92ee67d3f66b07e70c81bb1928 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 05:24:46 -0600 Subject: [PATCH 45/53] Vary the salinity gradient with depth This makes the problem more numerically challenging. --- polaris/tasks/ocean/horiz_press_grad/salinity_gradient.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polaris/tasks/ocean/horiz_press_grad/salinity_gradient.cfg b/polaris/tasks/ocean/horiz_press_grad/salinity_gradient.cfg index a8e9ebd003..541d1ab80d 100644 --- a/polaris/tasks/ocean/horiz_press_grad/salinity_gradient.cfg +++ b/polaris/tasks/ocean/horiz_press_grad/salinity_gradient.cfg @@ -5,4 +5,4 @@ # the two columns and their gradients salinity_mid = [35.6, 35.4, 35.0, 34.8, 34.75] # g/kg / km -salinity_grad = [0.2, 0.2, 0.2, 0.2, 0.2] +salinity_grad = [0.8, 0.5, 0.3, 0.2, 0.1] From a2d23b2f68262f716da55c17ede3ba74296b08ea Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 06:14:45 -0600 Subject: [PATCH 46/53] Stop iterating in init if converged --- .../horiz_press_grad/horiz_press_grad.cfg | 4 ++ polaris/tasks/ocean/horiz_press_grad/init.py | 48 +++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg index 55fe871dae..b6cd6fc090 100644 --- a/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg +++ b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg @@ -71,6 +71,10 @@ salinity_grad = [0.0, 0.0, 0.0, 0.0, 0.0] # number of iterations over which to allow water column adjustments water_col_adjust_iter_count = 6 +# Early stopping threshold for fractional change in geometric water-column +# thickness between adjustment iterations +water_col_adjust_frac_change_threshold = 1.0e-12 + # reference solution quadrature method reference_quadrature_method = gauss4 diff --git a/polaris/tasks/ocean/horiz_press_grad/init.py b/polaris/tasks/ocean/horiz_press_grad/init.py index 010e46d507..593f68c461 100644 --- a/polaris/tasks/ocean/horiz_press_grad/init.py +++ b/polaris/tasks/ocean/horiz_press_grad/init.py @@ -70,6 +70,7 @@ def run(self): logger = self.logger # logger.setLevel(logging.INFO) config = self.config + hpg_section = config['horiz_press_grad'] if config.get('ocean', 'model') != 'omega': raise ValueError( 'The horiz_press_grad test case is only supported for the ' @@ -84,9 +85,7 @@ def run(self): '"vertical_grid" section.' ) - z_tilde_bot_mid = config.getfloat( - 'horiz_press_grad', 'z_tilde_bot_mid' - ) + z_tilde_bot_mid = hpg_section.getfloat('z_tilde_bot_mid') assert z_tilde_bot_mid is not None, ( 'The "z_tilde_bot_mid" configuration option must be set in the ' @@ -156,6 +155,22 @@ def run(self): 'must be set in the "horiz_press_grad" section.' ) + water_col_adjust_frac_change_threshold = hpg_section.getfloat( + 'water_col_adjust_frac_change_threshold' + ) + if water_col_adjust_frac_change_threshold is None: + raise ValueError( + 'The "water_col_adjust_frac_change_threshold" configuration ' + 'option must be set in the "horiz_press_grad" section.' + ) + if water_col_adjust_frac_change_threshold < 0.0: + raise ValueError( + 'The "water_col_adjust_frac_change_threshold" configuration ' + 'option must be nonnegative.' + ) + + prev_geom_water_column_thickness: xr.DataArray | None = None + for iter in range(water_col_adjust_iter_count): ds = self._init_z_tilde_vert_coord(ds_mesh, pseudo_bottom_depth, x) @@ -222,6 +237,31 @@ def run(self): f'geom_water_column_thickness = {geom_water_column_thickness}' ) + if prev_geom_water_column_thickness is not None: + frac_change = ( + np.abs( + geom_water_column_thickness + - prev_geom_water_column_thickness + ) + / prev_geom_water_column_thickness + ) + max_frac_change = frac_change.max().item() + + logger.info( + f'Iteration {iter}: max fractional change in ' + f'geometric water-column thickness = ' + f'{max_frac_change:.6e}' + ) + + if max_frac_change < water_col_adjust_frac_change_threshold: + logger.info( + f'Early stopping water-column adjustment after ' + f'iteration {iter} because max fractional change ' + f'({max_frac_change:.6e}) is below threshold ' + f'({water_col_adjust_frac_change_threshold:.6e}).' + ) + break + # scale the pseudo bottom depth proportional to how far off we are # in the geometric water column thickness from the goal scaling_factor = ( @@ -243,6 +283,8 @@ def run(self): f'{pseudo_bottom_depth.values}' ) + prev_geom_water_column_thickness = geom_water_column_thickness + ds['temperature'] = ct ds['salinity'] = sa ds['SpecVol'] = spec_vol From 47eb59b050589544de1ca73d98c6b551e3326505 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 07:19:34 -0600 Subject: [PATCH 47/53] Change resolutions Drop coarsest resolutions (16, 8, 6 km) and add more fine resolutions (1.5 and 0.75 km). --- polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg index b6cd6fc090..9e00f8a41f 100644 --- a/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg +++ b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg @@ -30,10 +30,10 @@ eos_type = teos-10 [horiz_press_grad] # resolutions in km (the distance between the two columns) -horiz_resolutions = [16.0, 8.0, 6.0, 4.0, 3.0, 2.0, 1.0, 0.5] +horiz_resolutions = [4.0, 3.0, 2.0, 1.5, 1.0, 0.75, 0.5] # vertical resolution in m -vert_resolutions = [16.0, 8.0, 6.0, 4.0, 3.0, 2.0, 1.0, 0.5] +vert_resolutions = [4.0, 3.0, 2.0, 1.5, 1.0, 0.75, 0.5] # geometric sea surface height at the midpoint between the two columns # and its gradient From 7267b68e95d2ecb5edb5000a2e151b532bd3c78e Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 07:38:00 -0600 Subject: [PATCH 48/53] Fix reference vertical res --- .../tasks/ocean/horiz_press_grad/reference.py | 66 +++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/polaris/tasks/ocean/horiz_press_grad/reference.py b/polaris/tasks/ocean/horiz_press_grad/reference.py index f40ad316a1..709f047ebe 100644 --- a/polaris/tasks/ocean/horiz_press_grad/reference.py +++ b/polaris/tasks/ocean/horiz_press_grad/reference.py @@ -1,5 +1,7 @@ from __future__ import annotations +from fractions import Fraction +from math import gcd, lcm from typing import Callable, Literal, Sequence import gsw @@ -105,11 +107,13 @@ def run(self): test_vert_res = config.getexpression( 'horiz_press_grad', 'vert_resolutions' ) - test_min_vert_res = np.min(test_vert_res) - - # Use half the minimum test vertical resolution for the reference - # so that reference interfaces lie exactly at test midpoints - vert_res = test_min_vert_res / 2.0 + # Choose a reference vertical resolution based on the greatest common + # spacing among all test vertical resolutions so every test + # resolution is an integer multiple of 2 * vert_res. + vert_res = _get_reference_vert_res(test_vert_res) + logger.info( + f'Reference vertical resolution: vert_res = {vert_res:.12g} m' + ) z_tilde_bot_mid = config.getfloat( 'horiz_press_grad', 'z_tilde_bot_mid' ) @@ -696,6 +700,58 @@ def integrand(z_tilde: np.ndarray) -> np.ndarray: return z, spec_vol, ct, sa +def _get_reference_vert_res( + test_vert_res: Sequence[float] | np.ndarray, +) -> float: + """ + Compute the reference vertical resolution. + + The returned value ``vert_res`` is chosen so that each test vertical + resolution is an integer multiple of ``2 * vert_res``. + """ + test_vert_res = np.asarray(test_vert_res, dtype=float) + if test_vert_res.size == 0: + raise ValueError('At least one test vertical resolution is required.') + + flat = test_vert_res.ravel() + if not np.all(np.isfinite(flat)): + raise ValueError('All test vertical resolutions must be finite.') + if np.any(flat <= 0.0): + raise ValueError('All test vertical resolutions must be positive.') + + fractions = [Fraction(str(value)) for value in flat] + + common_spacing = fractions[0] + for value in fractions[1:]: + common_spacing = _fraction_gcd(common_spacing, value) + + if common_spacing <= 0: + raise ValueError( + 'Could not determine a positive common spacing from ' + 'test vertical resolutions.' + ) + + vert_res = float(common_spacing) / 2.0 + + # Defensive check against accidental precision/pathological inputs. + multipliers = flat / (2.0 * vert_res) + if not np.allclose( + multipliers, np.round(multipliers), rtol=0.0, atol=1.0e-12 + ): + raise ValueError( + 'Test vertical resolutions are not integer multiples of ' + '2 * reference vertical resolution.' + ) + + return vert_res + + +def _fraction_gcd(a: Fraction, b: Fraction) -> Fraction: + return Fraction( + gcd(a.numerator, b.numerator), lcm(a.denominator, b.denominator) + ) + + def _fixed_quadrature( integrand: Callable[[np.ndarray], np.ndarray], a: float, From 6f4a2e53a237774f84c24ad7439acf35d9e4074a Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 08:48:31 -0600 Subject: [PATCH 49/53] Add horiz_press_grad tests to Omega suites --- polaris/suites/ocean/omega_nightly.txt | 3 +++ polaris/suites/ocean/omega_pr.txt | 1 + 2 files changed, 4 insertions(+) diff --git a/polaris/suites/ocean/omega_nightly.txt b/polaris/suites/ocean/omega_nightly.txt index 11eb3c4940..0b46e45f71 100644 --- a/polaris/suites/ocean/omega_nightly.txt +++ b/polaris/suites/ocean/omega_nightly.txt @@ -1,4 +1,7 @@ ocean/planar/barotropic_gyre/munk/free-slip +ocean/horiz_press_grad/salinity_gradient +ocean/horiz_press_grad/temperature_gradient +ocean/horiz_press_grad/ztilde_gradient ocean/planar/manufactured_solution/convergence_both/default ocean/planar/manufactured_solution/convergence_both/del2 ocean/planar/manufactured_solution/convergence_both/del4 diff --git a/polaris/suites/ocean/omega_pr.txt b/polaris/suites/ocean/omega_pr.txt index 57c518c8fb..770ca4efd8 100644 --- a/polaris/suites/ocean/omega_pr.txt +++ b/polaris/suites/ocean/omega_pr.txt @@ -1,6 +1,7 @@ #ocean/planar/barotropic_channel/default # Supported but fails ocean/planar/merry_go_round/default ocean/planar/barotropic_gyre/munk/free-slip +ocean/horiz_press_grad/salinity_gradient ocean/planar/manufactured_solution/convergence_both/default ocean/planar/manufactured_solution/convergence_both/del2 ocean/planar/manufactured_solution/convergence_both/del4 From 73e5b542f9a6ab84d83e92edc94da218c276b802 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 18 Feb 2026 09:08:29 -0600 Subject: [PATCH 50/53] Add horiz_press_grad to the docs --- docs/developers_guide/ocean/api.md | 27 +++++ .../ocean/tasks/horiz_press_grad.md | 72 ++++++++++++ docs/developers_guide/ocean/tasks/index.md | 1 + .../ocean/tasks/horiz_press_grad.md | 108 ++++++++++++++++++ .../images/horiz_press_grad_salin_grad.png | Bin 0 -> 69755 bytes docs/users_guide/ocean/tasks/index.md | 1 + 6 files changed, 209 insertions(+) create mode 100644 docs/developers_guide/ocean/tasks/horiz_press_grad.md create mode 100644 docs/users_guide/ocean/tasks/horiz_press_grad.md create mode 100644 docs/users_guide/ocean/tasks/images/horiz_press_grad_salin_grad.png diff --git a/docs/developers_guide/ocean/api.md b/docs/developers_guide/ocean/api.md index 4c1fe455d0..adbed63d37 100644 --- a/docs/developers_guide/ocean/api.md +++ b/docs/developers_guide/ocean/api.md @@ -198,6 +198,33 @@ viz.Viz.run ``` +### horiz_press_grad + +```{eval-rst} +.. currentmodule:: polaris.tasks.ocean.horiz_press_grad + +.. autosummary:: + :toctree: generated/ + + add_horiz_press_grad_tasks + + task.HorizPressGradTask + task.HorizPressGradTask.configure + + reference.Reference + reference.Reference.run + + init.Init + init.Init.run + + forward.Forward + forward.Forward.setup + + analysis.Analysis + analysis.Analysis.setup + analysis.Analysis.run +``` + ### ice_shelf_2d ```{eval-rst} diff --git a/docs/developers_guide/ocean/tasks/horiz_press_grad.md b/docs/developers_guide/ocean/tasks/horiz_press_grad.md new file mode 100644 index 0000000000..22ee5925dc --- /dev/null +++ b/docs/developers_guide/ocean/tasks/horiz_press_grad.md @@ -0,0 +1,72 @@ +(dev-ocean-horiz-press-grad)= + +# horiz_press_grad + +The {py:class}`polaris.tasks.ocean.horiz_press_grad.task.HorizPressGradTask` +provides two-column Omega tests for pressure-gradient-acceleration (`HPGA`) +accuracy and convergence across horizontal and vertical resolutions. + +The task family includes three variants: + +- `salinity_gradient` +- `temperature_gradient` +- `ztilde_gradient` + +## framework + +The config options for these tests are described in +{ref}`ocean-horiz-press-grad` in the User's Guide. + +The task dynamically rebuilds `init` and `forward` steps in `configure()` so +user-supplied `horiz_resolutions` and `vert_resolutions` in config files are +reflected in the work directory setup. + +### reference + +The class {py:class}`polaris.tasks.ocean.horiz_press_grad.reference.Reference` +defines a step that builds a high-fidelity reference HPGA solution in +`reference_solution.nc`. + +It computes pseudo-height and geometric-height profiles on a refined reference +grid, evaluates TEOS-10 specific volume, and computes HPGA at the center +column using a 4th-order finite-difference stencil. + +### init + +The class {py:class}`polaris.tasks.ocean.horiz_press_grad.init.Init` +defines one step per `(horiz_res, vert_res)` pair. + +Each `init` step: + +- builds and culls a planar two-cell mesh, +- sets up z-tilde vertical coordinates and profile fields, +- iteratively adjusts pseudo-bottom depth to match target geometric + water-column thickness, and +- writes `culled_mesh.nc` and `initial_state.nc`. + +### forward + +The class {py:class}`polaris.tasks.ocean.horiz_press_grad.forward.Forward` +defines one model step per horizontal resolution. + +It runs Omega from the corresponding `init` output and writes `output.nc` +(with `NormalVelocityTend` validation), using options from `forward.yaml`. + +### analysis + +The class {py:class}`polaris.tasks.ocean.horiz_press_grad.analysis.Analysis` +compares each `forward` result with: + +- the high-fidelity reference solution, and +- the Python-computed HPGA from `initial_state.nc`. + +The step writes: + +- `omega_vs_reference.nc` and `omega_vs_reference.png` +- `omega_vs_python.nc` and `omega_vs_python.png` + +and enforces regression criteria from `[horiz_press_grad]`, including: + +- allowed convergence-slope range for Omega-vs-reference, +- high-resolution RMS threshold for Omega-vs-reference, and +- RMS threshold for Omega-vs-Python consistency. diff --git a/docs/developers_guide/ocean/tasks/index.md b/docs/developers_guide/ocean/tasks/index.md index fc689f1c67..81fbe96807 100644 --- a/docs/developers_guide/ocean/tasks/index.md +++ b/docs/developers_guide/ocean/tasks/index.md @@ -13,6 +13,7 @@ cosine_bell customizable_viz external_gravity_wave geostrophic +horiz_press_grad divergent_2d ice_shelf_2d inertial_gravity_wave diff --git a/docs/users_guide/ocean/tasks/horiz_press_grad.md b/docs/users_guide/ocean/tasks/horiz_press_grad.md new file mode 100644 index 0000000000..e915cb390f --- /dev/null +++ b/docs/users_guide/ocean/tasks/horiz_press_grad.md @@ -0,0 +1,108 @@ +(ocean-horiz-press-grad)= + +# horizontal pressure gradient + +## description + +The `horiz_press_grad` tasks in `polaris.tasks.ocean.horiz_press_grad` +exercise Omega's hydrostatic pressure-gradient acceleration (`HPGA`) +for a two-column configuration with prescribed horizontal gradients. + +Each task includes: + +- a high-fidelity `reference` solution for HPGA, +- an `init` step at each horizontal/vertical resolution pair, +- a single-time-step `forward` run at each horizontal resolution, and +- an `analysis` step comparing Omega output with both the reference and + Python-initialized HPGA. + +The tasks currently provided are: + +``` +ocean/horiz_press_grad/salinity_gradient +ocean/horiz_press_grad/temperature_gradient +ocean/horiz_press_grad/ztilde_gradient +``` + +```{image} images/horiz_press_grad_salin_grad.png +:align: center +:width: 600 px +``` +## supported models + +These tasks currently support Omega only. + +## mesh + +The mesh is planar with two adjacent ocean cells. For each resolution in +`horiz_resolutions`, the spacing between the two columns is set by that value +(in km). + +## vertical grid + +The vertical coordinate is `z-tilde` with a uniform pseudo-height spacing for +each test in `vert_resolutions`. + +The `reference` step uses a finer spacing `vert_res` chosen so that every test +spacing is an integer multiple of `2 * vert_res`. This allows reference +interfaces to align with test midpoints for exact subsampling in analysis. + +(ocean-horiz-press-grad-config)= +## config options + +Shared options are in section `[horiz_press_grad]`: + +```cfg +# resolutions in km (distance between the two columns) +horiz_resolutions = [4.0, 3.0, 2.0, 1.5, 1.0, 0.75, 0.5] + +# vertical resolution in m for each two-column setup +vert_resolutions = [4.0, 3.0, 2.0, 1.5, 1.0, 0.75, 0.5] + +# geometric sea-surface and sea-floor midpoint values and x-gradients +geom_ssh_mid = 0.0 +geom_ssh_grad = 0.0 +geom_z_bot_mid = -500.0 +geom_z_bot_grad = 0.0 + +# pseudo-height bottom midpoint and x-gradient +z_tilde_bot_mid = -576.0 +z_tilde_bot_grad = 0.0 + +# midpoint and gradient node values for piecewise profiles +z_tilde_mid = [0.0, -48.0, -144.0, -288.0, -576.0] +z_tilde_grad = [0.0, 0.0, 0.0, 0.0, 0.0] + +temperature_mid = [22.0, 20.0, 14.0, 8.0, 5.0] +temperature_grad = [0.0, 0.0, 0.0, 0.0, 0.0] + +salinity_mid = [35.6, 35.4, 35.0, 34.8, 34.75] +salinity_grad = [0.0, 0.0, 0.0, 0.0, 0.0] + +# reference settings +reference_quadrature_method = gauss4 +reference_horiz_res = 0.25 +``` + +The three task variants specialize one horizontal gradient field: + +- `salinity_gradient`: nonzero `salinity_grad` +- `temperature_gradient`: nonzero `temperature_grad` +- `ztilde_gradient`: nonzero `z_tilde_bot_grad` + +## time step and run duration + +The `forward` step performs one model time step and outputs pressure-gradient +diagnostics used in the analysis. + +## analysis + +The `analysis` step computes and plots: + +- Omega RMS error versus reference (`omega_vs_reference.png`), including a + power-law fit and convergence slope, and +- Omega RMS difference versus Python initialization (`omega_vs_python.png`). + +The corresponding tabulated data are written to +`omega_vs_reference.nc` and `omega_vs_python.nc`. + diff --git a/docs/users_guide/ocean/tasks/images/horiz_press_grad_salin_grad.png b/docs/users_guide/ocean/tasks/images/horiz_press_grad_salin_grad.png new file mode 100644 index 0000000000000000000000000000000000000000..a359f89032843e7f1d1739c0bc4a2fd38d3379d6 GIT binary patch literal 69755 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�VqJ@myVv22ahVS`>m5_*a&=TnmvZbzT zB8plT581#2wmxx~!(tV^fFSJq6;l8`ph_rGbum5xQ|RdG#yOPl2E*q;9Gvf<=5u{C z4_dA&p7J$zTaZxKxzvI+I)^7d8^lY@!)h&oIutaZU8{}+7I-;5J(dZDbp${ZCiM;c z*mXoL4Kh6--M97&@Jo60-1+;|YpAn3VX&uPKA)+wrceJfXU|$8dSKScRcaq{#w2qq zq>sbPzR4D4O??WRBiY-}FjS`0RJ*+=DrI(_!{+h1e0+SYjFqHApHlb@U ziHJI%t_GLKrRUF^qP6$*s{Qua;Tx3)a~j4wjg7YoNf>$P?XVSqHv`1IWhGH48)BV% z4eLq>Uh_oy$`y5e3ftDK7nQV|%o?a3`E~e1I6~7LPLx(TF1lxWHua;b$&YzX)4G@& zSrK)^%}Dx#VZF5={4(Ae_sr1zK!hyZt^lQOJ@(TZr7jl#ELMI$(Uv-VWaOaF7S%6C>ZbRx&~qPzSvkB{r%YhX+=FZ zPw1ULK3Dfb-GlWRK*)h40}}Z=tEut4e&0`=*=EF+%-dOU&I3R7%#T|N?k6)aneGH9 z8UK8(eWQOVwZEH5=_*6>(2Muz-8(zyjVN@1{t=3F3{}7JA7ZYnJ@_O;-+5;eu?j+C)_tD*VbWFUjWIUWSF1#Wl4IP(8edy}0* z-mCmiPVe70_rK-z{`<@RUoP<9w**7@|NB|WgDRD=Z9CW7Wbf*%@Sh Date: Wed, 18 Feb 2026 09:22:16 -0600 Subject: [PATCH 51/53] Fix docstring for Reference step --- .../tasks/ocean/horiz_press_grad/reference.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/polaris/tasks/ocean/horiz_press_grad/reference.py b/polaris/tasks/ocean/horiz_press_grad/reference.py index 709f047ebe..31b538d89b 100644 --- a/polaris/tasks/ocean/horiz_press_grad/reference.py +++ b/polaris/tasks/ocean/horiz_press_grad/reference.py @@ -19,34 +19,34 @@ class Reference(OceanIOStep): - """ + r""" A step for creating a high-fidelity reference solution for two column - test cases + test cases. The reference solution is computed by first converting from the Omega pseudo-height coordinate :math:`\tilde z` (``z_tilde``) to true geometric height ``z`` by numerically integrating the hydrostatic - relation + relation: .. math:: - \frac{\\partial z}{\\partial \tilde z} = - \rho_0\\,\nu( S_A, \\Theta, p ) + \frac{\partial z}{\partial \tilde z} = + \rho_0\,\nu\left(S_A, \Theta, p\right) where :math:`\nu` is the specific volume (``spec_vol``) computed from the TEOS-10 equation of state, :math:`S_A` is Absolute Salinity, - :math:`\\Theta` is Conservative Temperature, :math:`p` is sea pressure + :math:`\Theta` is Conservative Temperature, :math:`p` is sea pressure (positive downward), and :math:`\rho_0` is a reference density used in the - definition of ``z_tilde = - p / (\rho_0 g)`. The conversion therefore - requires an integral of the form + definition :math:`\tilde z = -p/(\rho_0 g)`. The conversion therefore + requires an integral of the form: .. math:: - z(\tilde z) = z_b + \\int_{\tilde z_b}^{\tilde z} - \rho_0\\,\nu\big(S_A(\tilde z'),\\Theta(\tilde z'),p(\tilde z')\big)\\; + z(\tilde z) = z_b + \int_{\tilde z_b}^{\tilde z} + \rho_0\,\nu\bigl(S_A(\tilde z'),\Theta(\tilde z'),p(\tilde z')\bigr)\; d\tilde z' , - with :math:`z_b = -\text{bottom\\_depth}` at the pseudo-height + with :math:`z_b = -\mathrm{bottom\_depth}` at the pseudo-height ``z_tilde_b`` at the seafloor, typically the minimum (most negative) value of the pseudo-height domain for a given water column. From 57a5f49b1c1feb93d21d45ebd8688856afd18ce0 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Thu, 19 Feb 2026 01:52:15 -0600 Subject: [PATCH 52/53] Fix f-string for python <3.12 --- polaris/tasks/ocean/horiz_press_grad/analysis.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/polaris/tasks/ocean/horiz_press_grad/analysis.py b/polaris/tasks/ocean/horiz_press_grad/analysis.py index b6815280f8..1a94e13688 100644 --- a/polaris/tasks/ocean/horiz_press_grad/analysis.py +++ b/polaris/tasks/ocean/horiz_press_grad/analysis.py @@ -276,13 +276,12 @@ def run(self): 'Omega-vs-reference fit uses resolutions (km): ' f'{_format_resolution_list(resolution_array[fit_mask])}' ) + res_error_pairs = _format_resolution_error_pairs( + resolution_array, ref_error_array + ) logger.info( 'Omega-vs-Polaris RMS differences by resolution: ' - f'{ - _format_resolution_error_pairs( - resolution_array, py_error_array - ) - }' + f'{res_error_pairs}' ) failing_polaris = py_error_array > omega_vs_polaris_rms_threshold if np.any(failing_polaris): From a385f6b646f1ec52e467fb699ed48e18f37137b3 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 11 Mar 2026 12:00:09 +0100 Subject: [PATCH 53/53] Remove rho0 config options from horiz press grad --- .../tasks/ocean/horiz_press_grad/analysis.py | 12 ++-------- .../tasks/ocean/horiz_press_grad/forward.py | 7 ------ .../tasks/ocean/horiz_press_grad/forward.yaml | 2 -- .../horiz_press_grad/horiz_press_grad.cfg | 3 --- polaris/tasks/ocean/horiz_press_grad/init.py | 15 +++--------- .../tasks/ocean/horiz_press_grad/reference.py | 24 ++++--------------- 6 files changed, 10 insertions(+), 53 deletions(-) diff --git a/polaris/tasks/ocean/horiz_press_grad/analysis.py b/polaris/tasks/ocean/horiz_press_grad/analysis.py index 1a94e13688..c7dd120b08 100644 --- a/polaris/tasks/ocean/horiz_press_grad/analysis.py +++ b/polaris/tasks/ocean/horiz_press_grad/analysis.py @@ -3,7 +3,7 @@ import xarray as xr from polaris.ocean.model import OceanIOStep -from polaris.ocean.vertical.ztilde import Gravity +from polaris.ocean.vertical.ztilde import Gravity, RhoSw from polaris.viz import use_mplstyle @@ -90,12 +90,6 @@ def run(self): logger = self.logger config = self.config - rho0 = config.getfloat('vertical_grid', 'rho0') - assert rho0 is not None, ( - 'The "rho0" configuration option must be set in the ' - '"vertical_grid" section.' - ) - section = config['horiz_press_grad'] horiz_resolutions = section.getexpression('horiz_resolutions') assert horiz_resolutions is not None, ( @@ -165,7 +159,6 @@ def run(self): z_tilde_forward = _get_forward_z_tilde_edge_mid( ds_out=ds_out, - rho0=rho0, cell0=cell0, cell1=cell1, ) @@ -358,7 +351,6 @@ def _get_internal_edge(ds_init: xr.Dataset) -> tuple[int, tuple[int, int]]: def _get_forward_z_tilde_edge_mid( ds_out: xr.Dataset, - rho0: float, cell0: int, cell1: int, ) -> np.ndarray: @@ -370,7 +362,7 @@ def _get_forward_z_tilde_edge_mid( pressure_edge_mid = 0.5 * ( pressure_mid.isel(nCells=cell0) + pressure_mid.isel(nCells=cell1) ) - return (-pressure_edge_mid / (rho0 * Gravity)).values + return (-pressure_edge_mid / (RhoSw * Gravity)).values def _sample_reference_without_interpolation( diff --git a/polaris/tasks/ocean/horiz_press_grad/forward.py b/polaris/tasks/ocean/horiz_press_grad/forward.py index bd82a0b820..c1080b7c56 100644 --- a/polaris/tasks/ocean/horiz_press_grad/forward.py +++ b/polaris/tasks/ocean/horiz_press_grad/forward.py @@ -58,14 +58,7 @@ def setup(self): """ super().setup() - rho0 = self.config.get('vertical_grid', 'rho0') - if rho0 is None: - raise ValueError( - 'rho0 must be specified in the config file under vertical_grid' - ) - self.add_yaml_file( 'polaris.tasks.ocean.horiz_press_grad', 'forward.yaml', - template_replacements={'rho0': rho0}, ) diff --git a/polaris/tasks/ocean/horiz_press_grad/forward.yaml b/polaris/tasks/ocean/horiz_press_grad/forward.yaml index caa69fb5a3..00219ae168 100644 --- a/polaris/tasks/ocean/horiz_press_grad/forward.yaml +++ b/polaris/tasks/ocean/horiz_press_grad/forward.yaml @@ -3,8 +3,6 @@ Omega: TimeStep: 0000_00:00:01 StartTime: 0001-01-01_00:00:00 StopTime: 0001-01-01_00:00:01 - VertCoord: - Density0: {{ rho0 }} Tendencies: ThicknessFluxTendencyEnable: false PVTendencyEnable: false diff --git a/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg index 9e00f8a41f..f51bd8a912 100644 --- a/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg +++ b/polaris/tasks/ocean/horiz_press_grad/horiz_press_grad.cfg @@ -13,9 +13,6 @@ partial_cell_type = None # The minimum fraction of a layer for partial cells min_pc_fraction = 0.1 -# reference density defining the pseudo-height -rho0 = 1035.0 - # Options related the ocean component [ocean] diff --git a/polaris/tasks/ocean/horiz_press_grad/init.py b/polaris/tasks/ocean/horiz_press_grad/init.py index 593f68c461..82d0599e6f 100644 --- a/polaris/tasks/ocean/horiz_press_grad/init.py +++ b/polaris/tasks/ocean/horiz_press_grad/init.py @@ -10,6 +10,7 @@ from polaris.ocean.vertical.ztilde import ( # temporary until we can get this for GCD Gravity, + RhoSw, geom_height_from_pseudo_height, pressure_from_z_tilde, ) @@ -79,11 +80,6 @@ def run(self): horiz_res = self.horiz_res vert_res = self.vert_res - rho0 = config.getfloat('vertical_grid', 'rho0') - assert rho0 is not None, ( - 'The "rho0" configuration option must be set in the ' - '"vertical_grid" section.' - ) z_tilde_bot_mid = hpg_section.getfloat('z_tilde_bot_mid') @@ -184,11 +180,9 @@ def run(self): ds=ds, z_tilde_mid=z_tilde_mid, x=x, - rho0=rho0, ) p_mid = pressure_from_z_tilde( z_tilde=z_tilde_mid, - rho0=rho0, ) logger.debug(f'ct = {ct}') @@ -216,7 +210,6 @@ def run(self): spec_vol=spec_vol, min_level_cell=min_level_cell, max_level_cell=max_level_cell, - rho0=rho0, ) logger.debug(f'geom_z_inter = {geom_z_inter}') @@ -326,7 +319,7 @@ def run(self): ) ds.GeomZInter.attrs['units'] = 'm' - self._compute_montgomery_and_hpga(ds=ds, rho0=rho0, dx=dx, p_mid=p_mid) + self._compute_montgomery_and_hpga(ds=ds, dx=dx, p_mid=p_mid) ds.layerThickness.attrs['long_name'] = 'pseudo-layer thickness' ds.layerThickness.attrs['units'] = 'm' @@ -357,7 +350,6 @@ def run(self): def _compute_montgomery_and_hpga( self, ds: xr.Dataset, - rho0: float, dx: float, p_mid: xr.DataArray, ) -> None: @@ -419,7 +411,7 @@ def _compute_montgomery_and_hpga( ) # Montgomery: M = alpha * p + g * z, with p = -rho0 * g * z_tilde montgomery_inter = Gravity * ( - z_bnds - rho0 * alpha_bnds * z_tilde_bnds + z_bnds - RhoSw * alpha_bnds * z_tilde_bnds ) montgomery_inter = montgomery_inter.transpose( 'Time', 'nCells', 'nVertLevels', 'nbnds' @@ -599,7 +591,6 @@ def _interpolate_t_s( ds: xr.Dataset, z_tilde_mid: xr.DataArray, x: np.ndarray, - rho0: float, ) -> tuple[xr.DataArray, xr.DataArray]: """ Compute temperature, salinity, pressure and specific volume given diff --git a/polaris/tasks/ocean/horiz_press_grad/reference.py b/polaris/tasks/ocean/horiz_press_grad/reference.py index 31b538d89b..6d72547c8e 100644 --- a/polaris/tasks/ocean/horiz_press_grad/reference.py +++ b/polaris/tasks/ocean/horiz_press_grad/reference.py @@ -11,7 +11,7 @@ from polaris.ocean.model import OceanIOStep # temporary until we can get this for GCD -from polaris.ocean.vertical.ztilde import Gravity +from polaris.ocean.vertical.ztilde import Gravity, RhoSw from polaris.tasks.ocean.horiz_press_grad.column import ( get_array_from_mid_grad, get_pchip_interpolator, @@ -94,11 +94,6 @@ def run(self): 'The "reference_horiz_res" configuration option must be set in ' 'the "horiz_press_grad" section.' ) - rho0 = config.getfloat('vertical_grid', 'rho0') - assert rho0 is not None, ( - 'The "rho0" configuration option must be set in the ' - '"vertical_grid" section.' - ) x = resolution * np.array([-1.5, -0.5, 0.0, 0.5, 1.5], dtype=float) @@ -163,7 +158,7 @@ def run(self): # compute Montgomery potential M = alpha * p + g * z # with p = -rho0 * g * z_tilde (p positive downward) - montgomery = Gravity * (z - rho0 * spec_vol * z_tilde) + montgomery = Gravity * (z - RhoSw * spec_vol * z_tilde) dx = resolution * 1.0e3 # m @@ -189,7 +184,7 @@ def run(self): # the HPGF is grad(M) - p * grad(alpha) # Here we just compute the gradient at x=0 using a 4th-order # finite-difference stencil - p0 = -rho0 * Gravity * z_tilde[2, :] + p0 = -RhoSw * Gravity * z_tilde[2, :] # indices for -1.5dx, -0.5dx, 0.5dx, 1.5dx grad_indices = [0, 1, 3, 4] dM_dx = _compute_4th_order_gradient(montgomery[grad_indices, :], dx) @@ -449,11 +444,6 @@ def _compute_column( 'The "reference_quadrature_method" configuration option must be ' 'set in the "horiz_press_grad" section.' ) - rho0 = config.getfloat('vertical_grid', 'rho0') - assert rho0 is not None, ( - 'The "rho0" configuration option must be set in the ' - '"vertical_grid" section.' - ) water_col_adjust_iter_count = section.getint( 'water_col_adjust_iter_count' ) @@ -518,7 +508,6 @@ def _compute_column( sa_nodes=salinity_node, ct_nodes=temperature_node, bottom_depth=-geom_z_bot, - rho0=rho0, method=method, ) @@ -546,7 +535,6 @@ def _integrate_geometric_height( sa_nodes: Sequence[float] | np.ndarray, ct_nodes: Sequence[float] | np.ndarray, bottom_depth: float, - rho0: float, method: Literal[ 'midpoint', 'trapezoid', 'simpson', 'gauss2', 'gauss4', 'adaptive' ] = 'gauss4', @@ -598,8 +586,6 @@ def _integrate_geometric_height( ``z_tilde_nodes``. bottom_depth : float Positive depth (m); geometric height at seafloor is ``-bottom_depth``. - rho0 : float - Reference density used in the pseudo-height definition. method : str, optional Quadrature method ('midpoint','trapezoid','simpson','gauss2', 'gauss4','adaptive'). Default 'gauss4'. @@ -664,14 +650,14 @@ def spec_vol_ct_sa_at( ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: sa = sa_interp(z_tilde) ct = ct_interp(z_tilde) - p_pa = -rho0 * Gravity * z_tilde + p_pa = -RhoSw * Gravity * z_tilde # gsw expects pressure in dbar spec_vol = gsw.specvol(sa, ct, p_pa * 1.0e-4) return spec_vol, ct, sa def integrand(z_tilde: np.ndarray) -> np.ndarray: spec_vol, _, _ = spec_vol_ct_sa_at(z_tilde) - return rho0 * spec_vol + return RhoSw * spec_vol # fill interface heights: anchor bottom, integrate upward (reverse) n_interfaces = len(z_tilde_interfaces)