horiz_press_grad
The 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 four such variants:
salinity_gradienttemperature_gradientztilde_gradientsurface_pressure_gradient
polaris.tasks.ocean.horiz_press_grad.resting_state_task.HorizPressGradRestingStateTask
provides two further variants that are exact resting states, in which the true
HPGA is identically zero and no reference solution is used:
hydrostatic_consistencybathymetry_step
framework
The config options for these tests are described in horizontal pressure gradient in the User’s Guide.
The User’s Guide is also place to go for the mathematical formulation, the reference-solution definition, and the algorithmic interpretation of the two task baselines. This page focuses on how that workflow is implemented in the code.
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.
metrics
polaris.tasks.ocean.horiz_press_grad.metrics is a dependency-light
leaf module (numpy and xarray only) holding the pieces both analysis steps
need: get_internal_edge(), rms(), power_law_fit(),
write_metric_dataset(), format_value_list() and
format_value_error_pairs(). It exists so the two step modules can share them
without importing private names from one another.
the finite-volume pressure gradient
Four leaf modules implement the FiniteVolume scheme of the
PGradHighOrder.md design, as the Python counterpart of Omega’s
PressureGradFiniteVolume. They are written from that design rather than from
the C++, so that the Omega-vs-Polaris comparison in analysis compares two
independent implementations.
polaris.tasks.ocean.horiz_press_grad.edge holds the two-column edge
operator, edge_delta() and edge_mean(). It has no dependencies inside the
package so the modules below can share it without importing one another.
polaris.tasks.ocean.horiz_press_grad.eos_expansion gives the four
Taylor coefficients of the equation of state from one gsw evaluation per cell
per layer, averages them and the reference state to the edge, and evaluates the
resulting shared profile. Note that gsw.specvol_first_derivatives() takes
pressure in dbar but returns the pressure derivative per Pa; the tests assert
this rather than trusting it.
polaris.tasks.ocean.horiz_press_grad.reconstruction builds the
mean-preserving linear reconstruction of temperature and salinity in pressure.
The slope is a centred difference of the layer means against mid-layer
pressure, which is what makes it exact on a non-uniform grid.
polaris.tasks.ocean.horiz_press_grad.finite_volume assembles the
scheme. The essential piece is delta_specvol_at_pressure(), which
differences the integrand at matched pressure rather than at matched layer
index: for each quadrature point, layer_containing_pressure() finds the layer
of each column that contains that pressure. Under tilt those are not the
same layer – at 50 m/km and 64 m layers the two columns’ layer k do not
overlap in pressure at all – and getting this wrong silently turns the scheme
into a different one. column_scan() then accumulates the fixed-pressure
height difference along the column from anchor_difference(), and
finite_volume_hpga() forms the layer mean and the tendency.
The scan anchors at the sea floor, per design §3.7.4, at the deepest
interface valid in both columns. This is not a conditioning preference:
VertCoord builds geometric height upward from a prescribed bathymetry by
accumulating over each column’s own layers, so two columns partitioned
differently by a tilt derive sea-surface heights that differ at
\(O(\tilde h^2)\), and a surface anchor would feed that straight into the answer.
Note that §3.5.1 of the design still describes a surface anchor; §3.7.4 is
later, gives the argument, and is what Omega implements.
One consequence is worth knowing before reading the tests: on these states the
sea-floor anchor is not zero, even on hydrostatic_consistency_linear.
The cause is the midpoint rule both codes use to turn pressure into geometric
height, geom_thickness = spec_vol * h_tilde * RhoSw. At 256 m layers it
truncates each column’s 3500 m integral by \(1.096\times10^{-3}\) m. Init
holds bottomDepth at the prescribed bathymetry and solves for
BottomPressure, which is the right assignment of known and unknown, and each
column converges exactly – to the bottom pressure that makes its own
midpoint sum 3500 m. Under tilt the two columns’ interfaces fall in different
places, so their truncation errors differ, by \(7.138\times10^{-8}\) m; that
difference is the anchor, to four significant figures. At a common pressure
the two columns’ heights genuinely differ by that much, and the scheme is right
to report it.
Three things follow that are worth stating because each closes off a plausible fix:
more iterations do not help. The iteration is at its exact fixed point; over 6 to 40 iterations the inter-column pressure difference is unchanged to seven digits;
anchoring
pstar_initat the sea floor would be a no-op. It already builds the column sea-floor-anchored and shifts it afterwards, and that shift is exactly zero wherever the iteration converges – which is everywhere in this task, since these variants setpartial_cell_type = None;Polaris must not integrate more accurately. The increment is byte-identical to
VertCoord::computeGeomZHeighton purpose; that is what closes the round trip and givesomega_vs_polarisits meaning. A better quadrature here would break it by ~1 mm to remove \(7\times10^{-8}\) m.
What the tests assert on the exact set is therefore that the scan stays flat
– every increment zero to round-off – and that the assembled tendency is the
anchor and nothing else, which holds to a part in \(10^7\) across the sweep. The
residual is \(1.75\times10^{-10}\) m s\(^{-2}\), against \(9.1\times10^{-8}\) for
Centered on the same state, so it is a ceiling on how exact the test can be
rather than a limit on the scheme. The anchor_at_surface guard switches ends
so the gap can be measured rather than assumed.
The module also retains centered_shift() and centered_shift_accumulated().
These are no longer on the computational path; they are kept as diagnostics,
because hpga_from_shift(centered_shift(ds), dx) reproduces the centered
scheme’s HPGA exactly and is the cheapest available regression test of it.
finite_volume_hpga() accepts a guards argument holding verification-only
switches, each deliberately breaking one rule of the design. They exist to
confirm the test suite can detect a broken implementation; they are not
supported settings.
The Gauss-Legendre rule these integrals use comes from the quadrature_points
config option, which is the same option that fills in Omega’s
PressureGrad:QuadraturePoints. That is deliberate and is not a convenience.
omega_vs_polaris_rms_threshold is the only check in this task family that
tests Omega’s arithmetic against an independent implementation, and it is a
check on the implementations only if both integrate with the same rule; two
sides quadrating differently are two algorithms, and their disagreement would
not be distinguishable from a bug in either. Exactness itself does not depend
on the rule – on the exact set the integrand is zero at every point, so any
rule integrates it to zero – which is exactly why a mismatch here would show
up only off the exact set, where it is hardest to attribute.
reference
The class
polaris.tasks.ocean.horiz_press_grad.reference.ReferenceColumn
is not a Step but a lightweight callable evaluator. It is instantiated
inside Analysis.run() (once per task, using the config and the mesh-derived
x_sign), and computes the HPGA reference analytically without writing any
intermediate file.
ReferenceColumn.__init__() reads the quadrature settings
(reference_quadrature_method, reference_quadrature_subdivisions,
reference_horiz_eps_km) and the geometry / profile parameters from config.
It builds _ClampedInterp PCHIP interpolants for Absolute Salinity and
Conservative Temperature at \(x = 0\) and \(x = \pm\varepsilon\) (six interpolants
in all), which are used later for centred finite-differencing.
The public methods are specvol(z_tilde) (specific volume at \(x = 0\)) and
dalpha_dx(z_tilde) (the fixed-\(\tilde z\) x-gradient of specific volume), plus
the two used by Analysis:
hpga(z_tilde)— evaluates \(a(\tilde z)\) pointwise at the edge \(x = 0\) via the chain-rule / Leibniz integral, anchored at the surface (the boundary the model honours). It accumulates the cumulative integral \(I(\tilde z) = \int_{\tilde z_s}^{\tilde z} \bigl(\alpha_{S_A} \partial_x S_A + \alpha_{\Theta} \partial_x \Theta\bigr)\,d\tilde z'\) using_fixed_quadratureon a sorted unique node set, then interpolates back onto the requested \(\tilde z\) values. The surface boundary term (\(\eta'\) and \(\tilde z_s'\)) is kept general so nonzero sea-surface height and surface pressure are supported.layer_mean_hpga(z_tilde_interfaces)— layer-averageshpga()over the model’s actual pseudo-height layer bounds using 4-point Gauss–Legendre quadrature withreference_quadrature_subdivisionssub-panels per layer. This is whatAnalysiscalls to form the reference target per layer.
The private class _ClampedInterp wraps
get_pchip_interpolator()
with constant extrapolation at the node bounds.
The quadrature primitives (_fixed_quadrature, _gauss_composite) support
midpoint, trapezoid, Simpson, gauss2, and gauss4 methods and are shared
between the cumulative integral and the layer averaging.
init
The class polaris.tasks.ocean.horiz_press_grad.init.Init
defines one step per (horiz_res, vert_res) pair. It inherits from both
polaris.ocean.vertical.pstar_init.PStarInitStep and
polaris.ocean.model.OceanIOStep.
Each init step:
builds and culls a planar two-cell mesh,
delegates the p-star iterative initialization to
run_pstar_init(), which adjustsBottomPressureuntil the recovered geometric water-column thickness matches the prescribed sea-surface and seafloor geometry, andwrites
culled_mesh.nc,vert_coord.nc, andinit.nc.
The class implements the two extension points required by PStarInitStep:
init_tracers()reconstructs conservative temperature and absolute salinity at p-star layer midpoints by calling the private helper_interpolate_t_s(), which applies a PCHIP interpolator to the piecewise pseudo-height profiles defined in the configuration._build_pstar_coord_ds()overrides the base-class default to callinit_pstar_vertical_coord()per column, allowing each column to have a different reference pseudo-depth set byz_tilde_botin the configuration.
After the iteration converges, Init.run() appends the Python-side HPGA
diagnostic via the private helper _compute_montgomery_and_hpga().
init.nc stores both the fields needed by Omega and the offline diagnostics
later used in analysis, including pressure, SpecVol, Density,
GeomZMid, GeomZInterface, MontgomeryMid, MontgomeryInter, HPGA,
dMdxMid, dalphadxMid, PEdgeMid, and dSAdxMid. vert_coord.nc
holds the p-star coordinate variables written for Omega.
forward
The class polaris.tasks.ocean.horiz_press_grad.forward.Forward
defines one model step per horizontal resolution and per pressure-gradient
scheme, named forward_<res>_<scheme>.
It runs Omega from the corresponding init output and writes output.nc
(with NormalVelocityTend validation), using options from forward.yaml.
forward.yaml is a Jinja2 template whose PressureGrad block is filled in per
step from the scheme argument and the quadrature_points config option.
The schemes are listed by the pressure_grad_types config option and named in
the module-level SCHEMES dict, which maps the config spelling to Omega’s
PressureGradType. SCHEME_HPGA maps each to the field in init.nc that is
its Polaris counterpart, which is what the analysis steps compare Omega
against.
Two things about this axis are worth stating, because both were decided against plausible alternatives:
Both schemes share one
Initstep.Initis scheme-independent – it writesHPGAandHPGAFiniteVolumefrom the same state – so duplicating it per scheme would cost run time and, worse, would leave the comparison open to the objection that the two schemes ran on different initial conditions. The cost is that the scheme label is in every forward step’s directory, so baselines recorded before the axis existed do not carry over.There is no “centered limit” of the finite-volume scheme. The two are separate implementations and no setting reduces one to the other, so this is a choice between schemes rather than a parameter of one. Omega treats an unrecognized
PressureGradTypeas fatal rather than falling back toCentered, so a typo aborts the run instead of producing centered answers that read as a pass.
analysis
The class polaris.tasks.ocean.horiz_press_grad.analysis.Analysis
compares each forward result with:
the analytic reference solution (built from
ReferenceColumn), which is a property of the state and so is shared by both schemes, andthe Python-computed HPGA from
init.ncfor the matching scheme:HPGAforcentered,HPGAFiniteVolumeforfinite_volume. Comparing Omega’s finite-volume output against the centeredHPGAwould measure the difference between two schemes and report it as an implementation disagreement.
The step writes:
omega_vs_reference.ncandomega_vs_reference.pngomega_vs_python.ncandomega_vs_python.png
Both files carry one series per scheme, as variables suffixed with the scheme name, and both plots draw the schemes on one panel – the comparison between them at the same resolution is the measurement, so separating them would hide it.
The step enforces regression criteria from [horiz_press_grad]:
allowed convergence-slope range for Omega-vs-reference, per scheme (
omega_vs_reference_convergence_rate_{min,max}_{scheme});high-resolution RMS threshold for Omega-vs-reference, per scheme;
RMS threshold for Omega-vs-Python consistency, per scheme; and
omega_vs_reference_accuracy_ratio_min, the ratio of the centered to the finite-volume RMS error at the coarsest resolution.
Three things about the last two are worth knowing, because each was set from measurement against a plausible alternative:
Bands are per scheme because the schemes genuinely differ in order on one variant. Measured offline over 4, 2, 1 and 0.5 km, centered against finite volume: 1.68/1.68 (
temperature_gradient), 1.77/1.77 (salinity_gradient), 0.93/1.98 (ztilde_gradient), 2.00/1.99 (surface_pressure_gradient). Onlyztilde_gradientneeds its own pair, and it is the one that shows what the scheme is for: the centered scheme is first order there and the finite-volume scheme restores second.The accuracy gate is a ratio, not a comparison of orders. Gating on the new scheme’s slope beating the old one would fail on three variants out of four, where the two converge at the same rate and nothing is wrong.
Its default is 0.98, which asserts only “not worse”. On
temperature_gradientandsalinity_gradientthe two schemes agree to five digits – the layers are level there, so matched-pressure and matched-index coincide, and the error is the two-column representation of the tracer gradient, which both schemes share. Demanding an improvement would fail on a configuration with nothing to improve.ztilde_gradientraises it to 1.5 (measured 1.80 at the coarse end) andsurface_pressure_gradientto 1.8 (measured 2.19).
ztilde_gradient is also where the design’s own guidance needs qualifying: the
advantage there widens under refinement, 1.80x at 4 km against 14.8x at
0.5 km, because the orders differ. Elsewhere it is the constant factor the
design describes.
Implementation-wise, Analysis.run() iterates over configured horizontal
resolutions and, within each, over schemes. For each resolution it:
reads
init_r*.nc,culled_mesh_r*.nc,vert_coord_r*.nc, andoutput_r*_<scheme>.nc;identifies the single internal edge via
_get_internal_edge()and derives the forward pseudo-heights via_get_forward_z_tilde_edge_mid();constructs a
ReferenceColumnwith the mesh-derivedx_signand callsref.layer_mean_hpga()on the edge interface pseudo-heights frominit.nc, over interfaces0 .. max_level_index + 1so that every layer valid in both columns is included, down to and including the bottom partial cell. An earlier version dropped that layer because the then-current five-column finite-difference reference could not form its stencil there; the analytic single-column reference is valid to the seafloor, so it is now kept;checks that Python and Omega pseudo-heights agree with
_check_vertical_match(), then computes the Omega-vs-Python RMS difference frominit.ncHPGA.
The forward solution always comes from output.nc via NormalVelocityTend.
rms() and power_law_fit() from metrics produce the convergence datasets
and plots.
the resting-state variants
polaris.tasks.ocean.horiz_press_grad.resting_state_task.HorizPressGradRestingStateTask
is a sibling of HorizPressGradTask rather than a mode of it. The existing
task pairs each entry of horiz_resolutions with one entry of
vert_resolutions and keys its step dictionaries by horizontal resolution
alone, so it cannot express the repeated horizontal resolutions the tilt sweep
needs. The resting-state task keys its steps by the
(horiz_res, vert_res, tilt) triple instead, and builds the outer product of
the resolution pairs with tilt_values.
Init and Forward are reused unchanged apart from two optional arguments:
subdir_suffixreplaces the horizontal resolution in the step name, so repeated horizontal resolutions do not collide. It is built bysweep_suffix(horiz_res, vert_res, tilt), giving names likeinit_4km_256m_tilt0p5. When it is not given, the horizontal resolution is used, givinginit_<res>andforward_<res>_<scheme>.tilt_optionandtilt(onInitonly) name a[horiz_press_grad]config option thatInit.run()sets in its own config before building the columns, in the same way it already setsvertical_grid:vert_levels.
Init.run() also calls _check_reference_grid_head_room() after
run_pstar_init(). The p-star iteration converges to a pseudo-bottom depth
somewhat greater than the geometric water-column thickness, because in-situ
density exceeds RhoSw at depth. If a tilt makes a column’s z_tilde_bot
shallower than that, the reference grid cannot span the water column, the
iteration diverges, and the resulting HPGA is of order 1 m s\(^{-2}\) rather than
the order 1e-5 m s\(^{-2}\) being measured. The guard raises with the head room
available in each column.
resting_analysis
polaris.tasks.ocean.horiz_press_grad.resting_analysis.RestingAnalysis
replaces Analysis for these variants. Per sweep point it:
locates the internal edge with
get_internal_edge();forms the valid layer range
0 .. min(maxLevelCell) - 1, inclusive of the deepest layer valid in both columns — the bottom partial cell, which carries the entirebathymetry_stepsignal.Analysisincludes it too;takes the RMS of Omega’s
NormalVelocityTendat that edge over those layers, for each scheme. The truth is zero, so this is the error, not a difference from a reference;does the same for the matching Polaris-side field from
init.nc–HPGAorHPGAFiniteVolumeperSCHEME_HPGA– and RMS-differences the two.
It then groups the sweep by resolution pair, fits the tilt exponent within each
group and scheme over the points at or below tilt_fit_max when tilt_fit is
set, writes resting_state.nc and resting_state.png, and applies four
checks:
_check_bathymetry()—bottomDepthmust match thebottomDepthRequestedwritten byInitto withinresting_state_max_bathy_error. The p-star column is anchored at the prescribed sea surface, sosshis exact by construction and cannot reveal a problem; partial-cell snapping instead moves the sea floor, which leaves the state at rest but means the swept tilt is no longer the geometry under test;_check_omega_vs_polaris()— the retainedomega_vs_polaris_rms_thresholdconsistency check, applied per scheme and independent of the resting-state property;_check_sensitivity()— the largest RMS anywhere in the sweep must reachresting_state_sensitivity_min_rms, or the sweep is not exercising the failure mode and must be redesigned. This is applied tocenteredonly: it asks whether the sweep is severe enough to exercise the failure the higher-order scheme exists to fix, so demanding it offinite_volumewould be requiring the new scheme to fail;_check_max_rms()— the consistency gate, applied per scheme and skipped whileresting_state_max_rmsisnone;_check_improvement()— the centered RMS divided by the finite-volume RMS must reachresting_state_improvement_minat every sweep point. The true HPGA is zero here, so both are pure error and the ratio is a clean statement of what the higher-order scheme buys, with no reference solution and none of its discretization involved. The ratio is logged whether or not a threshold is set.
The improvement gate is set per variant because the advantage spans four orders of magnitude, and a shared value would be vacuous at one end and unreachable at the other. Measured offline, the smallest ratio anywhere in each sweep:
variant |
worst ratio |
at |
gate |
|---|---|---|---|
|
2.58x |
4 km, 128 m, tilt 50 |
1.5 |
|
7.71x |
4 km, 64 m, tilt 50 |
4.0 |
|
1170x |
4 km, 256 m, grad 200 |
500 |
|
58828x |
4 km, 256 m, grad 200 |
20000 |
The ordering is the result, not an accident of the sweeps: the advantage is
smallest where the profile is curved and only the coordinate tilts, and largest
where the profile is resolved and the sea floor steps. Those numbers are
pinned by test_improvement_gate_is_below_what_the_kernel_delivers, so the
gates and the measurements cannot drift apart silently.