Skip to content

Add dynamic adjustment to realistic, global ocean workflow - #644

Draft
xylar wants to merge 270 commits into
E3SM-Project:mainfrom
xylar:add-realistic-global-ocean-dynamic-adjustment
Draft

Add dynamic adjustment to realistic, global ocean workflow#644
xylar wants to merge 270 commits into
E3SM-Project:mainfrom
xylar:add-realistic-global-ocean-dynamic-adjustment

Conversation

@xylar

@xylar xylar commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

(More complete description to come)

This branch is based on #643

Checklist

  • User's Guide has been updated
  • Developer's Guide has been updated
  • API documentation in the Developer's Guide (api.md) has any new or modified class, method and/or functions listed
  • Documentation has been built locally and changes look as expected
  • Testing comment in the PR documents testing used to verify the changes
  • New tests have been added to a test suite

@xylar

xylar commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

I have only tests:

ocean/spherical/realistic_global/u.oi240.lr240/dynamic_adjustment/task

with MPAS-Ocean and Gnu compilers on Chrysalis. it passes, but I haven't looked at output carefully.

I am able to set up:

ocean/spherical/realistic_global/u.oi30.lr10/dynamic_adjustment/task

and the processor count now makes sense (it was far too large before recent debugging). I'm launching a job now but likely won't have time to look at the results for a few days at least.

@xylar
xylar force-pushed the add-realistic-global-ocean-dynamic-adjustment branch 5 times, most recently from 61a02c8 to 60d461d Compare August 6, 2026 11:55
@xylar xylar added enhancement New feature or request ocean Related to the ocean component labels Aug 6, 2026
xylar and others added 22 commits August 8, 2026 06:41
land_frac and ocean_frac are fractions in [0, 1] that claimed to be a bedrock
elevation in metres: the masks they come from are built by comparing
base_elevation against zero, and xarray carries its attributes through the
comparison.  Label the derived masks in MaskTopoStep instead of letting them
inherit.  grounded_frac and ice_frac, which had no long_name or units at all,
pick theirs up from the combined topography now that it is labelled.

Also drop cell_measures, coordinates and grid_mapping from the remapped
fields.  ncremap stamps the first two on everything it writes and this step
drops area, lat and lon; grid_mapping arrives from the projected Antarctic
source and names a variable that does not survive remapping either.  All three
were left pointing at variables that are not in the file.

Copy attributes as a dict rather than by reference, so a masked field and its
source no longer share one attrs dict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
out_filename was computed once, outside the loop over components, from
whichever component the preceding loop happened to leave bound -- the last
step's.  Caching steps from two components in one invocation wrote both sets
of entries to a single file named after one of them, and the load at the top
of the loop read the wrong component's json into cached_files.  Move the
assignment inside the loop.

Also write the trailing newline json.dump leaves off, so a generated
cached_files.json no longer needs one added by hand.

Found while looking at what re-running polaris cache on the same day does; it
has nothing to do with attributes, but it is a small fix in a file this branch
already touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Labelling the combined fields wholesale dropped every inherited attribute,
including the one that was right: BedMachine and Bedmap3 both supply
standard_name = land_ice_thickness, which really does describe what the field
holds.  Put it back.

The other five fields get no standard_name.  bedrock_altitude and
sea_area_fraction look like candidates, but neither has been checked against
the CF standard-name table, and an unverified standard name is worse than
none.

This lands after the topography was regenerated, so the cached files do not
carry it; it will appear the next time the topography is rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The p-star initialization path bypassed init_vertical_coord/add_1d_grid,
so pstar_init.nc lacked the 1D reference vertical coordinate variables
(refBottomDepth, refZMid, refTopDepth, refInterfaces).  MPAS-Ocean
requires refBottomDepth as an input on its mesh stream.

Reuse the existing add_1d_grid() in the base PStarInitStep output
assembly so every p-star init step produces these fields, and expose a
shared REF_COORD_VARS constant for downstream routing/exclusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The base p-star init now emits refBottomDepth/refZMid/refTopDepth/
refInterfaces for MPAS-Ocean.  Omega uses RefPseudoThickness instead and
ignores these, so drop them from the Omega initial state file to keep
its output minimal and model-appropriate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assert the base PStarInitStep output contains refBottomDepth and its
companions, verify they are kept for MPAS-Ocean but dropped for Omega in
write_initial_state_dataset, and guard that REF_COORD_VARS stays in sync
with add_1d_grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pyremap's Remapper defaults to use_tmp=True, which puts the SCRIP files
and the MOAB .h5m files in a TemporaryDirectory() under /tmp.  On
Chrysalis (and many other HPC machines) /tmp is a node-local disk, so
the MPI tasks running mbtempest on other nodes cannot see the files and
abort during the parallel read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The final savefig used bbox_inches='tight' on a fixed-aspect cartopy
GeoAxes, which shrinks the map axes so only part of the domain is drawn --
Antarctica was cut off the bottom of every global lat-lon plot.

This is the same failure mode fixed for plot_global_mpas_field in "Fix
half-globe clipping in plot_global_mpas_field"; that fix was never
propagated to plot_global_lat_lon_field.

The fix there was to let constrained_layout manage the margins, but that
does not work here: this function attaches its colorbar with inset_axes
anchored outside the axes, which constrained_layout does not account for,
so the colorbar falls off the canvas entirely.  Reserve the margins
explicitly instead, which keeps the full domain, the colorbar and its tick
labels.

Affects all callers of plot_global_lat_lon_field: the woa23 and jra55
hydrography/forcing viz, the e3sm/init combined-topography viz and the
unified base-mesh viz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cartopy's Gridliner draws its own tick labels and constrained_layout does
not measure them, so the labels that stick out furthest were clipped at the
figure edge.  On Robinson the 30-degree labels sit left of the 60-degree
ones, so "30N"/"30S" rendered as bare "N"/"S" while "60N"/"60S" survived.

Reserve the horizontal margin explicitly on the layout engine.  Drawing the
figure once before saving, so the labels exist for a second layout pass,
does not work -- constrained_layout still does not account for them.

Companion to "Fix domain clipping in plot_global_lat_lon_field"; affects
every caller of plot_global_mpas_field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A get_*_steps() helper is called once per consumer, and
get_or_create_shared_step already makes that safe for the steps.  The
config those steps use had no equivalent, so a helper that built it
unconditionally handed the second caller a *different* config object at
the same filepath while the shared steps -- created on the first call --
went on using the first one.

Both failure modes are bad.  Quietly, options set on what the second
caller was handed reach nothing.  Loudly, passing it to
Task.set_shared_config raises, because add_config refuses a different
config at a path that already has one.

So Component gains get_or_create_shared_config, the companion to
get_or_create_shared_step, taking a setup callback that runs only when
the config is really created.  It registers the config immediately,
which is what lets the second caller find it.

_get_target_topo_steps was building its config unconditionally and now
goes through it.  Eight other modules already hand-roll the same guard,
which is why the gap was easy to miss; converting those is left for
later, since they are correct as they stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tests on the helper itself: that it creates and registers, that it
returns the same object on a second call, and that the setup callback
runs only once -- otherwise a second caller would re-add its packages on
top of options the first caller's steps had already been given.

Three more that the real helpers hand back the config their steps are
using, for woa23, jra55 and the lat-lon combined topography.  Each was
checked against the unfixed code: reverting a helper fails its test on
the identity assertion, so the tests cover the bug rather than just the
new method.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shared-steps section of the developer guide gains the companion
method and, more usefully, why building a shared config unconditionally
is a bug even though it looks harmless: the second caller gets a
different object at the same path while the steps keep the first, so its
options reach nothing and registering it raises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step.add_dependency was the odd one out among Polaris' registration
methods.  Component.add_step and Component.add_config both raise only
when a *different* object is registered at the same key and treat
re-registering the same object as a no-op; add_dependency raised on any
repeat of the name.

That punishes exactly the pattern shared steps are built around.  A
get_*_steps() helper is called once per consumer, so a dependency it
wires outside a step constructor -- a restart chain, whose links only
the helper knows -- was wired again on the second call and raised.  The
workaround is for the helper to track whether each call is the one that
created the step, which is bookkeeping the framework should not be
asking for.

So the same step under the same name now returns early.  Early, not just
past the raise: the rest of the method calls add_output_file() and
add_input_file(), neither of which de-duplicates.  The error worth
keeping is the name collision between two different steps, which is what
the `name` argument exists to resolve, and that one still raises -- with
a message that now says which case it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tests: that add_dependency wires the pickle output and input that
carry a dependency, that repeating it changes nothing -- checked all the
way down to the output and input lists, since those are what would grow
if the method fell through rather than returning -- and that a different
step under a name already in use still raises.

The second and third fail against the previous behaviour, so they cover
the change rather than just the method.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wiring inside the step constructor is the pattern to reach for, since
get_or_create_shared_step() passes constructor arguments only when it
really creates the step.  Where the helper is the only thing that knows
the link, add_dependency() may now simply be called again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven helpers still hand-rolled the guard this method replaces:

    if filepath in component.configs:
        return component.configs[filepath]

They were correct, which is why the helper that was missing it went
unnoticed until it handed a second caller a config its own shared steps
were not using.  Having one way to do this is what stops that recurring,
so the four unified-mesh helpers, topo/cull, topo/remap and mesh/base/add
now go through the method too.

That needed the method to change shape.  Three of the seven do not build
a config themselves; they delegate to a builder that creates and returns
one -- get_unified_mesh_config and get_sizing_field_config.  A callback
handed a config to populate cannot express those without duplicating
what the builders do or changing their signatures, so the callback now
*returns* the config instead.  An existing builder can be passed
straight through, and the method's job reads as what it is: caching, not
construction.

Because a builder could return a config with any filepath, one that does
not match the key is now an error -- it would be registered under a path
it does not know about, which is the same silent mismatch this method
exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "sets up only once" test becomes "builds only once" against the new
callback, and gains an assertion that the second call returns the same
object rather than only the same contents.

A new test covers the filepath check: a builder that ignores the
filepath raises, and nothing is registered under the key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The example builds and returns a config rather than filling one in, and
the text notes that an existing builder can be passed straight through,
along with the filepath requirement that makes that safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A get_*_steps() helper suggests one symlink name per step, chosen for
consumers whose tasks live elsewhere in the tree, and tasks forward those
names verbatim:

    for symlink, step in steps.items():
        self.add_step(step, symlink=symlink)

Four tasks -- CullTopoTask, RemapTopoTask, Woa23 and Jra55 -- live in the
directory that holds those very steps, so the symlink lands next to what
it points to: cull_mask beside mask, woa23_viz beside viz.  105 of the
3074 step symlinks in the tree are like that.  They are noise, and a
second name for a directory already in view is worse than no name.

add_step now drops those rather than asking every such task to filter
the names itself, which is the same mistake waiting to be made a fifth
time.  Paths are compared in full, since two components can have the
same subdirectory layout and a step from another component is somewhere
else on disk however its subdirectory reads.

A symlink to a step nested *deeper* under the task is kept -- the 40
like viz_remapped_unsmoothed_topo -> unsmoothed/viz.  Surfacing a step
from further down under a descriptive name is what symlinks are for.

Symlinks are navigational only: step_symlinks is read in one place,
_setup_step, and the run machinery keys off step.name.  Nothing about
what runs changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five tests on add_step: a symlink to a step elsewhere is kept, one that
would sit beside its step is dropped, one to a step nested deeper is
kept, a step from another component with a coincidentally matching
subdirectory keeps its symlink, and the degenerate case where the
symlink would name the step itself is dropped.  Two of the five fail
against the previous behaviour.

The two unified-topo tests asserted that every suggested name became a
symlink.  Their point -- that the task uses the factory's names rather
than inventing its own -- is unchanged, so they now say that with the
side-by-side ones excluded, and assert that the excluded set is not
empty so the check cannot pass vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shared-step section of the tasks guide now says that a symlink
landing in the directory that already holds the step is dropped, why a
task hits that at all -- it passes through names meant for consumers
elsewhere -- and that a symlink to a step nested deeper is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xylar and others added 29 commits August 12, 2026 07:56
The u.oi6to18.lr6to10 first stage failed its temperature check on the
WOA23 warm cell off Sumatra, which reaches 35.8 degC there rather than
the 33.5 it reaches at 30 km because a 6-18 km mesh averages NOAA's bad
source cell far less.  It is also much slower to erode: 35.77 after an
hour, 34.54 after two, and 34.29 at the end of the six-hour stage, which
extrapolates to more than a day before it falls below 33.  Skipping only
the sample written before the first time step, as the checks did, leaves
35.77 as the first sample judged.

So two changes, and both are needed.  The thresholds move to 35 degC and
44 PSU, because a stage-1 exclusion alone cannot save a bound at 33: the
artifact is still near 34.3 when stage 2 begins and near 33.9 when stage
3 does, so those stages would fail on it too.  And the tracer extremes
now skip a startup window as well as the first sample, because at 35 the
first hour's 35.77 would still fail.

The window is measured from the start of the sequence rather than from
each stage, since what it skips belongs to the initial condition.  A
per-stage window would instead stop looking just after every damping
change, which is where a schedule that steps the damping down too fast
would show itself.  For the same reason the CFL check does not get the
window: the opening hours are where a time step that is too long shows
up.  With the default 2 hours, stage 1 is judged from hour 3 (34.53
degC, 42.60 PSU) and no later stage is affected at all.

Lowering salinity_max from 45 rather than leaving it is a tightening the
evidence now supports.  A scan of the u.oi6to18.lr6to10 initial
condition finds every cell above 42 PSU in the Red Sea artifact at
1150 m -- 26 of them, peaking at 44.13 -- and nothing hypersaline
anywhere else, so 44 still clears the artifact while a real blow-up
produces far more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten tests, in three groups.

The window arithmetic: it covers the first stage of the real
u.oi6to18.lr6to10 schedule and no other, it carries what is left of
itself into a second stage when the first is shorter than the window,
and a zero-length window leaves only the first sample excluded.

What the extremes then see: the real hourly series from the failing
stage, which is the case worth pinning down, plus a window that lands
exactly on a sample time (the model writes two hours as 0.0833..., so
matching cannot rely on exact equality) and a stage that lies wholly
inside the window and so has nothing to judge.

What a stage check makes of it: the real series passes, a 99 degC
sample after the window still fails, and a CFL spike in the first hour
still fails -- the window is for the tracer artifacts, and a time step
too long for the flow shows up exactly where the window sits.

The summary test asserts the same window reaches the CSV, and only the
tracer columns, so what a user reads cannot disagree with what passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user guide gains the window itself, why it is measured from the
sequence rather than from each stage, and why the CFL check does not get
it.  Its WOA23 paragraph now covers what the artifacts do at 6-18 km --
35.78 degC rather than the 33.46 at 30 km, and slow enough to still be
there when the second and third stages begin -- since that is what the
thresholds are set against.  Its config block had drifted from the file
it quotes, missing salinity_max and cfl_max and still describing the
kinetic-energy check that was replaced; it is now a copy of the real
one.

The developer guide describes first_judged_index as the one place that
decides which samples a stage is judged on, and excluded_days_in_stage
as what turns a sequence-wide duration into a per-stage one, including
why the result is expressed relative to the stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebasing onto the fixed hmix scaling left two assertions behind: the
u.oi6to18.lr6to10 config now asks for ref_cell_width against a 6 km
reference rather than the meshDensity scaling that never worked.

They also now check what the reference width and the del4 divergence
factor are, since those are the part that actually differs per mesh --
E3SM's per-grid coefficients are referenced to the finest cells of their
own mesh, so borrowing a coefficient without its reference width is how
this goes wrong quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stages, their checks, validate and viz were constructed directly by
the task, so they lived only in that task.  A downstream workflow that
wants what the adjustment produces -- e3sm/init's component inputs want
the relaxed restart -- could not reach them, and building its own copy
would mean running the whole chain a second time.

So they are now built the way every other unified-mesh workflow builds
its steps: a get_realistic_dynamic_adjustment_steps() in steps.py that
composes the shared init chain and creates each of its own steps through
Component.get_or_create_shared_step, returning (steps, config, stages)
and taking include_viz, exactly as get_realistic_init_steps does.  The
task keeps only what is particular to running the sequence standalone.

The stages come back alongside the steps because which stage hands off
the adjusted state, and what that restart file is called, both depend on
the schedule: stages[-1].restart_out is what a consumer is after.

Two things did not fit the pattern without care.  The restart chain is
wired with add_dependency rather than input/output files, because a
stage's restart filename comes from the schedule rather than the step,
and add_dependency raises on a duplicate -- so _get_or_create reports
whether this call is what created the step, and only a new one is wired.
And because the set of steps depends on the schedule, configure() still
has to rebuild when a user's setup-time config changed it: a stage whose
name survived the change would otherwise come back from the shared-step
cache still carrying its old run duration.  Only the adjustment steps
are rebuilt, since re-requesting the init chain is not the same as
leaving it alone -- it would build a second copy of the shared configs
its own upstream steps own.

StageCheck, Validate and VizDynamicAdjustmentStep take subdir rather
than indir, which is what get_or_create_shared_step passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six tests for what "shared" has to mean here.  That a second consumer
gets the same instances rather than a second chain, and that asking
twice does not re-wire the restart chain -- which would raise on the
duplicate dependency.  That every adjustment step is registered on the
component under its work directory, which is what makes it reachable.
That viz is created either way but only returned when asked for.  That
the last stage names the restart a consumer is after.  And that the
shared config is reused rather than rebuilt.

The configure() test covers both halves of the rebuild: an unchanged
schedule leaves every step alone, and a changed one replaces the stages
-- including a stage whose name survived, which has to come back with
the override's run duration rather than the built-in schedule's -- while
leaving the init steps upstream untouched.

The step helpers move from indir to subdir to match the constructors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A "shared steps" section in the developer guide: what
get_realistic_dynamic_adjustment_steps composes, why it hands back the
stages as well as the steps and config, and the two details that do not
follow the init pattern -- the restart chain wired with add_dependency,
and the configure() rebuild that only touches the adjustment steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two workarounds went in with the shared steps, both for framework gaps
that are now fixed on add-realistic-ocean-init and reached this branch
with the rebase.

_get_or_create tracked whether each call was the one that created a
step, because Step.add_dependency raised on a repeat and the restart
chain is wired from the helper rather than from a step constructor.
add_dependency now returns early when the same step is added again, so
the tracking and the two `if created:` guards are gone and the helper
calls get_or_create_shared_step directly.

get_adjustment_steps existed so that configure() could rebuild the
stages without re-requesting the init chain, which used to build a
second copy of the shared configs its upstream steps own.
Component.get_or_create_shared_config fixed that, so there is one
public builder again and no _init_steps held on the task.

configure() still rebuilds only the steps under this mesh's
dynamic_adjustment directory, but for a different reason than before,
now stated: Task.remove_step carries through to the component once no
task is using a step, so emptying the whole task would evict the init
chain as well and leave a consumer holding stale instances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sharing test asserted only that a second call does not raise, which
was what the removed `created` guard bought.  Now that the chain is
wired unconditionally, it snapshots every forward and check step's
dependencies before and after the second call and requires them to
match, with a guard that the snapshot is not vacuously empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The developer guide said the restart chain could only be wired for a
newly created step and that configure() rebuilt through a separate
builder to avoid re-requesting the init chain.  Neither is true now: the
chain is wired unconditionally because re-adding a dependency is a
no-op, and configure() asks for the whole chain again.  The reason the
rebuild is still scoped to the adjustment steps is now the one that
actually applies -- Task.remove_step reaches the component.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
check_ke_growth_decelerates and its two config options are removed, and
Validate now only collects, writes and logs the diagnostics.

The check was rewritten three times -- comparing kinetic-energy levels,
then the fractional change stage over stage, then that change per unit
time -- and every revision was forced by a healthy run failing it.  The
last was u.oi6to18.lr6to10, which reached the end of its schedule with a
falling CFL, monotonically decaying tracer extremes and machine-noise
tracer conservation, and was failed for a 178% change in mean kinetic
energy across its final stage.

The quantity is not comparable across stages, for three reasons at once.
Stage lengths span 0.25 to 18 days, so a longer stage changes more for
no interesting reason.  The final stage switches the Rayleigh damping
off, so its kinetic energy rises by design -- that is what the ramp is
for.  And a stage whose energy dips, as damped_adjustment_7 did, makes
the next ordinary rise read as an acceleration, because the criterion
takes a magnitude.  Normalising by stage duration and restricting to the
damped stages makes all three shipped meshes pass, but a 3% dip
mid-ramp still fails it, so that was not a fix either.

Three samples cannot separate any of that from a run that is genuinely
running away.  What remains are bounds on quantities with real failure
modes behind them: the tracer maxima detect a blow-up, and the CFL
number detects a time step too long for the flow.  Whether an adjustment
has settled is read from the diagnostics table and the viz figure, which
show the whole series rather than three samples of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seven unit tests for check_ke_growth_decelerates and the three
Validate tests that exercised it go with it.

Two are replaced rather than deleted.  A sequence whose mean kinetic
energy accelerates -- the one the old check rejected -- now has to run
cleanly and still write its summary, which pins the new behaviour rather
than just removing the old.  And the test that required at least
ke_check_num_stages stages per unified schedule, whose only purpose was
to keep that check running, is replaced by one asserting the damping
ramps monotonically down, which is what the schedules are actually for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both guides said validate applied a fourth, cross-stage check.  They now
say it applies none, and record why: the three rewrites, the three
reasons the quantity is not comparable across stages, and that the
judgement is made from the figure and the table instead.

Written as a warning against re-adding it, since the reasoning is not
obvious from the code that is left and this is the kind of metric that
looks like an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
temperature_drift_per_day and salinity_drift_per_day are the change in a
volume-weighted mean tracer across a stage, divided by the stage's run
duration.  "Drift" invited reading them as a settling diagnostic -- a
mean still on the move meaning the stage had not finished adjusting --
and the comment above them said exactly that.

They cannot mean that.  These runs are forced by wind alone, with no
surface fluxes, so the volume-weighted mean tracers are conserved
exactly and the expected value of both columns is zero regardless of
how settled the stage is.  The completed runs bear that out: oi30
reaches -4e-17 degC/day and oi6to18 -6e-14, which is machine noise.  A
value larger than that says the model lost or gained tracer, which is
worth knowing and is not what the name suggested.

So they become mean_temperature_change_per_day and
mean_salinity_change_per_day, describing the computation and leaving
the interpretation to the comment, which now says the expected value is
zero and why.  The viz panels follow, since they plot the same quantity.

Also drops a comment on the 'last' reduction that still referred to the
settling check as its reason for existing; that check is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two assertions that name the columns move to the new names, and the
test that covers them is renamed to match: it records the mean tracer
change over a stage, not a drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both guides described these columns as a settling signal -- the user's
guide told a reader the "tracer drift should be shrinking" from stage to
stage, which is not something a conserved quantity does.

The user's guide now says what the columns are for, that both should be
zero to within machine noise, and quotes what the completed oi30 and
oi6to18 runs actually reached, so a reader has a number to compare
against rather than a direction to look for.  The developer's guide
gains the same reasoning next to where the columns are derived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On u.oi6to18.lr6to10 the first five stages span 0.25 to 1 day on a
30-day axis, and their labels -- two lines of stage name over damping,
lying flat -- overprinted into a smear that could not be read at all.
The figure is what settling is judged from, so a label band that cannot
be read is worse than a cosmetic problem.

Three changes, all needed together.  The labels rotate to read upwards,
which cuts a label's footprint from its length to one line height.  The
names shorten from 'damped 1' to 'd1', and 'simulation' to 'sim', so a
label is one short line rather than two.  And because 0.25 days is 3 pt
on that axis -- narrower than any legible label, rotated or not -- a
label that still would not fit climbs to the next row instead of landing
on its neighbour.  Rows are assigned greedily, so the crowded opening
stages climb and the wide ones that follow drop back to the bottom row.

The stacking is measured in points from the top of the axes rather than
in axes fractions, so it does not depend on how tall tight_layout ends
up making the panel.  The manual headroom this used to reserve is gone:
tight_layout counts annotations in the axes' bounding box already, so
reserving space as well pushed the panels an extra two inches down.

Schedules whose stages are all comfortably wide -- every other mesh --
stay on one row and look as they did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The short-name test follows the abbreviations, and gains a case for a
name matching neither pattern, which is passed through.

Two new tests cover the stacking, which is the part that could regress
quietly: four 10-day stages must stay on one row, because stacking them
would only waste headroom, and the u.oi6to18.lr6to10 opening must use
more than one.  The upper bound matters as much as the lower: eight rows
would mean every stage had climbed rather than only the crowded ones.

These assert on the row count rather than on measured text extents.  A
pixel-exact assertion would pin the result to a font, and the row count
is what the layout decision actually is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The users' guide said each stage is "labelled with its damping", which
no longer tells a reader enough to find the label: it now reads upwards,
the names are abbreviated, and on a crowded schedule it may sit a row or
two above its neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Omega's GlobalStats moved from daily temporal reductions to snapshots on
the configured cadence, which renamed the file it writes: the period is
part of the name, so it is global_stats_1DayInstants for a daily stage
and global_stats_1HourInstants for u.oi6to18.lr6to10's hourly ones.
This module still asked for global_stats_1DayTimeStats, from a dict
keyed by model alone -- which cannot express a name that depends on the
stage.

The failure this caused is quiet, which is the reason to care: a stats
file that is not found is a stage that wrote none, and the diagnostics
legitimately fall back to output.nc for that.  An Omega run would have
produced a summary with the volume-weighted columns blank and the
extremes computed the coarse way, and said nothing about why.

Take the name from the stage instead, via ForwardStage.stats_filename(),
so it cannot disagree with what the stage told the model to write.  The
dict and its lookup helper go; stage_stats_path() takes the stage rather
than its name, which every caller already had.

Dropping the Optional return with them is not a separate change: it was
only ever None for a model outside the dict, and the callers' real guard
is the one that is left, that the file exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test helper wrote its synthetic Omega statistics under hard-coded
names -- Temperature_SpatialMax_TimeMean1Day and the rest -- into a file
named from the dict this branch just removed.  Both were the pre-snapshot
form, so the tests agreed with each other and with nothing else.

Read the names from mpaso_to_omega.yaml, the same map the code under test
reads them through, and build the filename from the stage.  A test that
restates what the map says cannot catch the map changing.

The statistics Omega does not report at all stay listed here, since that
is not something the map can express: a name absent from the map and a
statistic Omega has no counterpart for look identical from the outside,
and only one of them should silently produce a blank column.

The new test pins what went wrong rather than what broke: the path has to
follow the stage's statistics interval, because Omega's name moves with
it and MPAS-Ocean's does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The developer guide pointed at diagnostics.STATS_FILENAMES, which is
gone, and described the Omega name as differing by model.  It differs by
stage: the analysis period is part of the name, so two stages of one
sequence can write to different files.

Say that, and say what the failure looks like, because it is the reason
this is worth a paragraph rather than a cross-reference -- a name that is
not found reads as a stage that wrote no statistics, so getting it wrong
produces a thinner summary rather than an error.

The rest of what this section said about Omega's statistics was already
correct: the user guide's copy came from the reconciled forward branch
during the rebase, and only this paragraph predated it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hmix_ref_cell_width was the one option the 240 km entry of
FORWARD_EXPECTED did not pin, and it is the option that decides whether
the Compass coefficients beside it mean what they say.  The mesh is
quasi-uniform, so its reference is its own width; the shared default of
30 km would scale every coefficient by 240/30 while still looking like a
correctly configured mesh.

The existing test for this covers the variable-resolution meshes, whose
reason is different -- they borrow E3SM's per-grid values, which are
referenced to their finest cells.  A quasi-uniform mesh has no finest
cells to speak of, so it belongs here rather than there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The boundary layer, the submesoscale parameterization and the pressure
gradient became config options upstream when the forward runs grew a
second purpose.  Because the schedule vocabulary is the ForwardStage
fields, that made all three stage-settable here without a line of code.

Which is exactly why they are worth a test.  Nothing in this package
names them, so if one were renamed or folded away upstream, the symptom
would be a schedule key that quietly stopped applying -- the schedule
validator would still accept it, since it would still be a field, and a
schedule that had been turning KPP off would go back to leaving it on
without saying so.

The test also pins the defaults, because the interesting property is
that a stage saying nothing keeps E3SM's physics rather than the
Registry's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The schedule-format section said a key must name a forward-run setting
and left it there.  That is true but unhelpful for the three options that
arrived from upstream after these schedules were written: nothing points
at them, and their being stage-settable is a consequence of how the
vocabulary is built rather than a decision anyone recorded.

Name them, say they default to E3SM's physics, and say what varying them
would be for.  Also say plainly that no shipped schedule does it, so a
reader treats it as untried rather than as a supported idiom -- the
distinction the config options themselves cannot express.

The note about Omega is the part most worth having: unlike damping,
which raises, these are dropped silently for Omega, because they are
applied only under config_model='mpas-ocean'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two stage-label tests failed on Python 3.13 and passed on 3.14, with
strptime rejecting '1-01-01_00:00:00'.  The helper built its start times
with strftime('%Y-...'), and whether strftime zero-pads a year below 1000
is not portable: 3.14 pads it, 3.13 leaves it to the platform.  These
runs start in year 1, so the helper handed the code under test a
one-digit year that %Y then refused to parse back.

Use _format_time, which is what a schedule uses.  It builds the string
with an explicit :04d rather than going through strftime, and its
docstring says "with a four-digit year" -- the production side had
already met this and avoided it.

So the test was not exercising a string any schedule can produce, which
is the actual defect here; the version split only decided whether it was
visible.  Nothing in polaris/ round-trips a year through strftime, and
this was the only place in tests/ that did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
density is in the forward output stream so that MPAS-Ocean and Omega can
be compared on the same fields.  A dynamic adjustment is not that
comparison, and it pays for the field once per stage on the largest
meshes Polaris builds: about 13% of the output volume, 2.4 GB per stage
on u.oi6to18.lr6to10, eight stages deep.

Nothing in this workflow reads it.  The checks and the summary come from
the global statistics, and the stratification questions density would
answer are asked of the initial condition rather than of a stage.  ssh
stays -- it is 2-D, nearly free, and is the surface field worth having.

This is the first time this workflow overrides a [realistic_global_forward]
option in its own config rather than through a schedule or a per-mesh
file, so the section says where it sits in the layering: on top of the
forward config, and still under the per-mesh ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checked once per mesh rather than once, because the per-mesh config files
are applied after this workflow's own and could quietly turn it back on;
a single-mesh test would not notice.

ssh is asserted alongside it.  The two sit together in the same block of
the output stream, and dropping both is the obvious way to get this
wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user guide embeds this workflow's config verbatim, so it carries the
new section and the reason the stages drop density but keep ssh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xylar
xylar force-pushed the add-realistic-global-ocean-dynamic-adjustment branch from 0be7a20 to 19ac892 Compare August 12, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request ocean Related to the ocean component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant