Skip to content

Shared configs and framework fixes - #686

Open
xylar wants to merge 19 commits into
E3SM-Project:mainfrom
xylar:share-configs-and-framework-fixes
Open

Shared configs and framework fixes#686
xylar wants to merge 19 commits into
E3SM-Project:mainfrom
xylar:share-configs-and-framework-fixes

Conversation

@xylar

@xylar xylar commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

This branch collects framework fixes that were developed while working on realistic global ocean initialization but that stand on their own. No new tasks are added and no existing task's results change, apart from the plotting and p-star output fixes noted below.

Shared config options

Polaris lets several tasks share a single step (via Component.get_or_create_shared_step()) and a single set of config options. The helpers that build shared steps are called once per consumer, but the code that built the config for those steps ran unconditionally on every call. The second caller therefore got a brand-new config object at a path where a different config was already registered: options set on it reached nothing, and handing it to Task.set_shared_config() raised an error. Several places worked around this with an ad-hoc "is it already in component.configs?" check, and others simply didn't.

  • Add Component.get_or_create_shared_config(), the config-level companion to the existing get_or_create_shared_step(); it returns the registered config if one exists and otherwise calls a create callback to build it.
  • The callback returns the config rather than filling one in, so existing config builders can be passed straight through; it is checked to return a config whose filepath matches the path it is registered under.
  • Route every shared config in the repo through the new helper (base mesh, combined/culled/remapped topography, and the unified-mesh base mesh, coastline, river and sizing-field steps), replacing the hand-rolled lookups.
  • Step.add_dependency() now treats adding the same step under the same name as a no-op, so a get_*_steps() helper that wires dependencies outside a step constructor is safe to call once per consumer. Adding a different step under a name already in use is still an error, and the error message says so.
  • Document both in the developers' guide (organization/steps.md) and the API listing.

1D reference vertical coordinate in p-star initial conditions

  • PStarInitStep now writes the 1D reference vertical coordinate (refTopDepth, refZMid, refBottomDepth, refInterfaces) to its output, since MPAS-Ocean requires refBottomDepth as an input.
  • Omega does not use those fields, so they are dropped when writing an Omega initial state, alongside the other MPAS-Ocean-only vertical coordinate variables.
  • The variable list lives in one place, polaris.ocean.vertical.grid_1d.REF_COORD_VARS, so the writer and the Omega filter cannot drift apart.

Other fixes

  • MappingFileStep no longer lets pyremap write its SCRIP and MOAB files under /tmp. That directory is node-local on many HPC machines, while the MPI tasks building a mapping file are typically spread over several nodes, so the files have to be in the step's work directory on a shared filesystem.
  • plot_global_lat_lon_field() no longer saves with bbox_inches='tight', which could shrink the fixed-aspect map axes so that only part of the domain was drawn (Antarctica was being clipped off the bottom). It uses explicit margins instead — the same fix already applied to plot_global_mpas_field(), which had never been propagated here.
  • plot_global_mpas_field() reserves a margin for cartopy's gridline labels, which constrained_layout does not measure and which were being clipped at the figure edge.

Testing

  • New unit tests cover the shared-config helper and its filepath check, the repeated-dependency no-op, the 1D reference coordinate in p-star output and its removal for Omega, and the MappingFileStep temporary-file setting.
  • The full unit test suite passes (273 tests).
  • The plotting changes are best checked by eye on any task that produces global plots.

Checklist

  • 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
  • Testing comment in the PR documents testing used to verify the changes

@xylar xylar self-assigned this Aug 5, 2026
@xylar xylar added clean-up framework Changes relating to the polaris framework as opposed to individual tests or analysis ocean Related to the ocean component labels Aug 5, 2026
@xylar
xylar force-pushed the share-configs-and-framework-fixes branch 2 times, most recently from 9bfe276 to 9fd808c Compare August 8, 2026 10:07
@xylar
xylar requested a review from katsmith133 August 8, 2026 10:42
@xylar
xylar marked this pull request as ready for review August 8, 2026 10:42
@xylar

xylar commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Testing

Polaris omega_pr suite

  • Baseline workdir: /lcrc/group/e3sm/ac.xylar/polaris_1.1/chrysalis/test_20260808/omega-pr-ers-fix
  • Baseline build: /lcrc/group/e3sm/ac.xylar/polaris_1.1/chrysalis/test_20260808/omega-pr-ers-fix/build
  • PR build: /lcrc/group/e3sm/ac.xylar/polaris_1.1/chrysalis/test_20260808/omega-pr-ers-fix/build
  • PR workdir: /lcrc/group/e3sm/ac.xylar/polaris_1.1/chrysalis/test_20260808/omega-pr-fix-shared-configs
  • Machine: chrysalis
  • Partition: debug
  • Compiler: intel
  • Build type: Release
  • Log: /lcrc/group/e3sm/ac.xylar/polaris_1.1/chrysalis/test_20260808/omega-pr-fix-shared-configs/polaris_omega_pr.o1265923
  • Result:
    • Failures (1 of 22):
      • ocean/column/ekman

@xylar

xylar commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

I will also test this with the full unified mesh workflow and will report any issues that arise, but this can be reviewed and merged without waiting for that testing.

xylar and others added 19 commits August 8, 2026 06:43
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>
Component._read_cached_files() asks importlib.resources for
cached_files.json under polaris.<name>, but a component name need not
correspond to an importable package.  Only FileNotFoundError was caught,
so anything else escaped from Component.__init__.

Two cases got through.  A name that is not an importable module at all
raises ModuleNotFoundError on every Python version.  A name that
resolves to a plain module rather than a package raises TypeError on
Python 3.11, where importlib.resources.files() rejects a non-package
anchor; Python 3.12 dropped that check and returns the containing
directory instead, so the missing file was caught as before and the
failure only showed up under 3.11.

Catch all three, since each one means the same thing: there is no
cached_files.json to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xylar
xylar force-pushed the share-configs-and-framework-fixes branch from 9fd808c to 721a10a Compare August 8, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

clean-up framework Changes relating to the polaris framework as opposed to individual tests or analysis ocean Related to the ocean component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant