Match layer/interface through the grid, not string substitution - #7
Open
hdrake wants to merge 3 commits into
Open
Match layer/interface through the grid, not string substitution#7hdrake wants to merge 3 commits into
hdrake wants to merge 3 commits into
Conversation
`convergent_transport` validated its `layer`/`interface` arguments with
`layer.replace("l", "i") != interface`, which rewrites every "l" in the
name. That rejected any consistent pair whose stem contains an "l"
("lam_l"/"lam_i", "level_l"/"level_i") and accepted unrelated pairs whose
substitution happened to coincide ("ml_l"/"mi_i").
Ask the grid instead: accept the pair if any of its axes registers `layer`
at "center" and `interface` at an interface position (outer/inner/left/
right). Grids handed to sectionate usually declare only their horizontal
axes (as `examples/load_example_model_grid.py` does), so a pair the grid
says nothing about falls back to the `<stem>l`/`<stem>i` convention --
anchored at the end of the name rather than substituted throughout. The
error now names both arguments and the axes the grid does offer.
Fixes MOM6-community#51
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`convergent_transport` made the caller name the vertical coordinates even when the grid already knew them. `xwmb` shows what that costs: it builds the arguments as `grid.axes["Z"].coords['center']` / `['outer']` and hands them back as strings, which sectionate then re-derives the relationship of. Read them off the axis directly instead, and let the caller stay silent. An explicit name that contradicts the grid's Z axis now raises rather than overriding it: a caller who disagrees with the grid about which vertical coordinate its transports live on is confused, and quietly preferring either one would label the output with a coordinate that need not describe the data. Names that agree stay valid, which is how such callers invoke it. The defaults change from "z_l"/"z_i" to None. That default was unusable anyway -- it looks up `grid._ds["z_l"]` and so raised KeyError on any grid without that variable, which is why all 32 call sites in this tree pass `layer=` explicitly and none relies on it. Grids that declare no vertical axis and pass no names now get output with no vertical coordinate instead of that KeyError. Fixes MOM6-community#51 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All five run cleanly against this branch's code. Results are unchanged: comparing every textual output against the previous ones, the only differences are the two artifacts below. Notebook 5's overturning streamfunction reproduces bit-for-bit (psi range (Sv): -58.09992975038242 to 53.35870875408872). Two things in these outputs are artifacts of how they were produced, not of the change under review: 1. The version banner reads "Sectionate version: 0.4.0rc2.dev1" rather than a release number. hatch-vcs derives the version from the git tag, and this branch is untagged and was installed editable, so it resolves to a .devN string off the last tag. It will read a real version again once the notebooks are refreshed from a tagged release. 2. Warning messages cite paths under a build worktree (/Users/hfdrake/code/wt-sectionate-layer-iface/...) instead of a normal checkout, and their line numbers reflect this branch's transports.py. Both would be resolved by a refresh on a tagged release in a normal checkout; neither reflects anything about the code being reviewed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes MOM6-community#51.
convergent_transportvalidated itslayer/interfacearguments by string substitution:str.replacerewrites every"l", so the check only worked for stems that contain nol.lam_l/lam_iandlevel_l/level_iwere rejected outright — those models simply could not callconvergent_transportwithlayer=/interface=— while unrelated pairs whose substitution coincided (ml_l/mi_i) were silently accepted.Changes
_validate_layer_interfaceaccepts the pair if any axis of the grid registerslayerat its"center"position andinterfaceat an interface position of that same axis (outer/inner/left/right). That is the question the substitution was standing in for, and the grid already answers it for arbitrary names. It is also how callers construct the arguments in the first place —xwmbreads them offgrid.axes["Z"].coords["center"]/["outer"]and hands them straight back.examples/load_example_model_grid.pyregistersXandYonly, and notebooks 2 and 3 then passlayer="sigma2_l", interface="sigma2_i". So the grid has nothing to say about that pair and a purely axis-based check would break the examples. The fallback accepts<stem>l/<stem>ianchored at the end of the name (layer[:-1] == interface[:-1]), which is what the substitution was reaching for; it coversz_l/z_i,sigma2_l/sigma2_iandlam_l/lam_ialike, and does not depend on what the stem spells."Inconsistent layer and interface grid variables!".interfacedocstring, which described the old substitution rule.Behavior is unchanged for every pair that was previously accepted and is genuinely consistent; the only pairs that change from accept to reject are coincidental matches like
ml_l/mi_i.Tests
Added to
sectionate/tests/test_convergent_transport.py, using a minimal 1x1-cell grid with a single-layer vertical coordinate:test_layer_interface_pairs_accepted—z_l/z_i,sigma2_l/sigma2_i,lam_l/lam_i,level_l/level_i, each with and without the vertical axis registered on the grid (8 cases); asserts both coordinates reach the output and that the transport is unchanged.test_layer_interface_from_grid_axis_without_naming_convention—MyCenters/MyEdgespaired by the grid axis and following no_l/_iconvention.test_inconsistent_layer_interface_rejected—z_l/sigma2_i,ml_l/mi_i(the old false accept), andz_l/z_lstill raiseValueError.These add 12 cases to the file (15 total). 8 of the 12 fail on the current branch and pass with the fix; the other 4 are the
z_l/z_iandsigma2_l/sigma2_ipairs that already worked and must keep working.Verification
Full suite in an env with xgcm 0.10.1: 73 passed, 0 skipped, 0 failed (
data/symlinked from an existing checkout, so the ECCO LLC90 and MOM6-fold tests ran rather than skipping).I did not re-execute the example notebooks. Instead I checked the exact path they take directly, against the real CM4p25 data file:
load_MOM6_example_grid()returns a grid registering onlyXandY, andconvergent_transport(..., layer="sigma2_l", interface="sigma2_i")on an OSNAP-like section returns as before, with bothsigma2_landsigma2_ion the output. Worth a notebook run before merge.Separate issue noticed while testing
A grid that does register a vertical axis cannot be traced at all:
build_neighbor_mapsbuildspadding_width = {ax: (1, 1) for ax in grid.axes}and pads the 2-D corner arrays over every axis, sogrid_sectionraisesKeyError: "None of the DataArray's dims ('yq', 'xq') were found in axis coords."as soon as aZaxis exists. Unrelated to this change and left alone here — the new tests supply section indices directly rather than working around it — but it should probably get its own issue.Drafted with AI assistance (Claude Code). I have read the diff and ran the tests and the reproducer myself.
Update: take
layer/interfacefrom the grid's Z axis (second commit)The check above stops at validating the caller's strings against the grid. If the grid can answer the question, though, the caller should not have to ask it.
convergent_transportnow reads the pair off the grid's"Z"axis when it has one, and both parameters default toNone.This is the same evidence as MOM6-community#51, followed one step further:
xwmb/budget.pybuilds the arguments as— reads them off the axis, hands them back as strings, and sectionate re-derives the relationship. Deriving directly deletes the round trip.
Behavior
layer=/interface=passedZaxiscenter+outer/inner/left/right)Zaxisxwmbcall patternZaxisValueErrorZaxisZaxisA contradicting name raises rather than overriding. A caller who disagrees with the grid about which vertical coordinate its transports live on is confused, and quietly preferring one would label the output with a coordinate that need not describe the data — which is the same failure mode that produced MOM6-community#51 in the first place. The error names both the argument and what the axis says.
Only names that are actually present in
grid._dsare derived: xgcm requires an axis position to name a dimension, but a dimension need not carry coordinate values, and these names are attached to the output by lookup.The defaults change from
"z_l"/"z_i"toNoneThis is a breaking change on paper. In practice the old default was unusable: it looks up
grid._ds["z_l"], so on any grid without that variable it raisedI audited every
convergent_transportcall in the tree — 32 call sites (test_left_grid.py7,test_section_multitile.py9,test_convergent_transport.py5,test_cube_left_grid.py4,test_section_reversibility.py2, examples 2/3/5). Every one passeslayer=explicitly (26 asNone, 4 as"sigma2_l", 2 asNonethrough a splatted**kw). Zero rely on the implicit default — because it could not be relied on. So the change fixes a landmine rather than removing a working convenience, and nothing in the tree needed updating.Worth a release note nonetheless, for any downstream caller on a genuine MOM6
z_l/z_igrid that omitted the arguments. Those callers now get output with no vertical coordinate rather than thez_lone — unless their grid registers aZaxis, in which case they get it back automatically.Tests
7 more cases in
test_convergent_transport.py: derivation from the axis; derivation identical to passing the same names explicitly (xr.testing.assert_identical); the three contradiction cases; no-axis-no-names attaching nothing; and aZposition naming a bare dimension with no coordinate values, which is not derived.test_inconsistent_layer_interface_rejectednow builds its grid withregister_z=False. It tests the name-only validation path, and on a Z-registered grid a contradicting name is now caught earlier by the more specific check.Verification
I think this belongs here rather than on #8: it is
transports.py, this PR's file, and #8 isgridutils.pyand should stay single-purpose. Happy to move it if you would rather.Example notebooks
Re-executed all five in a dedicated env built from
docs/environment.ymlwith this branchpip install -e'd (xgcm 0.10.1, sectionate0.4.0rc2.dev1), data symlinked from an existing checkout:1_creating_an_OSNAP_section.ipynb2_OSNAP_transports_CM4p25.ipynb3_Labrador_convergence_CM4p25.ipynb4_sections_on_global_tripolar_grid.ipynb5_MOC_transports_ECCOv4r4.ipynbOutputs are unchanged apart from the version banner: comparing every textual output against the committed ones, the only differences are
Sectionate version: 0.3.3→0.4.0rc2.dev1, the absent "Downloading … from Zenodo" lines (the data was already present), and warning messages carrying my paths. Notebook 5's overturning streamfunction reproduces bit-for-bit:psi range (Sv): -58.09992975038242 to 53.35870875408872, identical to the committed output.The re-executed notebooks are committed (last commit on this branch). They were re-run fresh against this branch's current HEAD, so the committed outputs are the ones this branch's committed code actually produces. Two caveats are recorded in that commit message: the version banner reads
0.4.0rc2.dev1, a hatch-vcs dev string from an editable install of an untagged branch, and warning text cites build-worktree paths rather than a normal checkout. Both are artifacts of how the notebooks were run, not of this change, and both resolve on a refresh from a tagged release. See the merge-order note at the end.This section drafted with AI assistance (Claude Code); I ran the notebooks and the comparisons myself.
The re-executed notebooks are committed here, and the same is true on the other PR, so #7 and #8 no longer merge in either order without a conflict. I verified this rather than assuming it — a test merge of the two pushed refs conflicts on exactly the five notebooks:
Everything else merges cleanly:
sectionate/transports.py,sectionate/gridutils.pyand both test files are disjoint between the two branches. The conflict is entirely re-executed output, not code.Suggested order: #8 first, then #7
gridutils.pyonly) and its notebook refresh is a no-op by construction — every notebook grid registers onlyXandY, so_pad_axesreturns exactly["X", "Y"], the same mapping the old code built. Its outputs differ from the current ones only in the version banner and warning paths.convergent_transport's API (thelayer/interfacedefaults), so its notebook outputs are the ones with any semantic content. Merging it second means the surviving copy is the one produced by the later code.Resolving the conflict
Don't hand-merge the notebook JSON. Take one side wholesale and, ideally, re-execute once on the merge result:
Since neither branch changes any notebook result (verified: notebook 5's streamfunction is bit-identical on both,
psi range (Sv): -58.09992975038242 to 53.35870875408872), taking either side is numerically safe; re-executing afterwards just makes the banner and warning paths consistent with the merged tree.If you would rather not carry the churn at all, dropping the notebook commit from both branches (
git revertof the last commit on each) restores clean either-order merging — which is what I had originally recommended. Either is fine; this note is just so nothing is a surprise at merge time.