Skip to content

Topology-driven section-finding + outer-lattice multi-tile transports (from xgcm metadata) - #47

Open
hdrake wants to merge 67 commits into
MOM6-community:masterfrom
hdrake:topology-driven-neighbors
Open

Topology-driven section-finding + outer-lattice multi-tile transports (from xgcm metadata)#47
hdrake wants to merge 67 commits into
MOM6-community:masterfrom
hdrake:topology-driven-neighbors

Conversation

@hdrake

@hdrake hdrake commented Jun 23, 2026

Copy link
Copy Markdown
Member

Consolidated PR. #48 (outer-lattice-topology) built directly on this branch; its work has been folded in here by fast-forward so review and merge happen in one place, and #48 is now closed as superseded. The outer-lattice topology + exact padded-transport work from #48 is summarized under Outer-lattice multi-tile topology below.

What & why

Section-finding used to hard-code a handful of grid topologies behind a topology= string ("latlon"/"cartesian"/"MOM-tripolar"), and the transport/tracer pipeline assumed a single logically-rectangular (Y, X) tile. This PR drops the string and instead reads topology from the xgcm.Grid the caller already supplies — its per-axis boundary and its face_connections — so periodicity, the bipolar north fold, and inter-tile connectivity are inferred rather than hand-coded. That single change extends both section-finding and transports/tracers to multi-tile grids (lat-lon-cap, cubed-sphere) and to all three native C-grid vorticity staggerings, with real-world example notebooks that double as validation.

Changes

Topology from xgcm metadata — no more topology=. Neighbors follow from each axis' padding: "periodic" → wrap, fill/extend wall → clip, and a single-tile bipolar north fold {"Y": {"fold": ...}} → crossed natively via xgcm's fold padding. The topology= argument and the hard-coded north-fold index formula are gone. gridutils.build_neighbor_maps builds the topology-aware neighbor maps the pathfinder consumes.

Multi-tile geometry via face_connections. build_neighbor_maps builds index-valued corner arrays and reads their cross-tile neighbors with any axis rotation/reversal already applied. A reciprocity check raises rather than returning silently-wrong neighbors. Section state now carries a face index, which grid_section returns for multi-tile grids.

All three C-grid staggerings, natively. Vorticity may sit at "outer" (MOM6 symmetric, M+1×N+1), "right" (MOM6 non-symmetric, M×N), or "left" (MITgcm/ECCO incl. lat-lon-cap, M×N). They are distinguished only by a per-position velocity index offset (gridutils.corner_position/corner_offset); no up-front conversion to a symmetric grid is needed.

Deterministic walk + curve option. The section walk is deterministic and direction-independent — it admits neighbors strictly closer to the endpoint plus any seam twin of the current cell, breaking ties by index — so periodic/fold-seam crossings no longer depend on floating-point rounding. grid_section gains curve=: "great circle" (default geodesic) or "latitude circle" (constant-latitude paths that march in longitude).

Transports / tracers across seams. Each section edge's normal velocity is read in a single face's frame (source-first dual-anchor), so no velocity is ever rotated across a seam. The per-edge sign is computed geometrically — "does the stored velocity's positive direction point to the left of the section's direction of travel?" — which stays consistent across rotated faces without any global orientation factor. convergent_transport/extract_tracer pick the contributing face pointwise.

Outer-lattice multi-tile topology (folded in from #48)

Rebuilds all multi-tile corner topology on the outer (shared-corner) lattice so rotated/reversed seams, cube-vertex junctions, boundary folds, and grid cuts become ordinary graph nodes — each with one canonical native (face, j, i) — and adds an exact, xgcm-independent transport-padding path.

  • Outer-lattice corner graph (_OuterTopology). Replaces the earlier _correct_seam_neighbors (which walled off twisted 4-face cube-vertex junctions): reconstruct each face's full outer lattice, resolve every extended slot to the native corner storing its physical point (exact cell-identity matching, plus extrapolate+snap at face corners/cuts), merge slots into physical nodes, and project the global corner graph back onto native-frame neighbor maps. The walker and transport attribution consume these maps unchanged, preserving the public native (i_c, j_c, f_c) contract. On ECCO LLC90 the 8 twisted cube-vertices become ordinary 4-valent corners, the 3 Arctic-cap vertices 3-valent, and the south-pole fold + lon=−115 cut unify into single well-defined nodes.
  • Exact padded transports. _OuterTopology.padded_transports extends native staggered transports to the outer lattice by resolving each missing edge slot to the stored velocity of that physical edge — a topology-exact, xgcm-independent alternative to xgcm.pad(..., other_component=...). On ECCO LLC90 the cell convergence sums to exactly zero globally. This remains the exact path even with the correct vector pad (xgcm#749), because no halo can supply edges stored on no face.
  • Points stored on no face raise an error. A cubed-sphere stores 6N² corners for 6N²+2 points; LLC90 adds its 4th Arctic-cap vertex and the two sides of the lon=−115 cut. These become edge-less nodes — sections/traced boundaries through them raise an error rather than return invalid indices.
  • Geometry-first 'left' cubed-sphere fixture. A gnomonic cubed-sphere in native 'left' staggering with face_connections from corner coincidence; transports from a corner streamfunction are exactly non-divergent, giving cell-by-cell convergence that sums to zero, section transport = endpoint streamfunction difference, and exactly zero net transport around a stored cube vertex.

Examples

  • examples/4_sections_on_global_tripolar_grid.ipynb — sections traced across the bipolar north fold on a global single-tile tripolar grid, including a curve="latitude circle" global section.
  • examples/5_MOC_transports_ECCOv4r4.ipynb — end-to-end depth-space global meridional overturning streamfunction from ECCOv4r4 on its native 13-tile lat-lon-cap (LLC90) grid: rotated multi-tile seam crossings + native "left" staggering + latitude-circle sections, on real model output. Its small ECCO data subset is fetched from Zenodo (10.5281/zenodo.21051424) with MD5 checks, so it runs with no NASA Earthdata login.

Verification

  • Rotated multi-tile seams. Streamfunction-difference test on a genuinely rotated 2-face grid: transport across the rotated seam must equal psi(P2) − psi(P1), a known, non-zero, rotation-independent answer needing no model data. The ECCOv4r4 example is the real-grid counterpart.
  • Bipolar north fold. Dedicated single-tile tests in test_section_fold.py: fold neighbor connectivity and a real-grid Arctic-crossing latitude circle that only completes once the grid declares its {"Y": {"fold": ...}} boundary.
  • Split-grid equivalence. A 2-face grid is a flat grid cut in half; transport matches the uncut grid to rtol 1e-12 for both symmetric and non-symmetric axis-preserving seams.
  • Existing single-tile tests pass unchanged; the geographic sign reproduces the previous Xinc/Yinc results to machine precision.
  • Reversibility, "left"-staggering, and corner-case coverage across the new suites.
  • Full pytest: 61 passed, 0 skipped against released xgcm 0.10.1, deterministic across PYTHONHASHSEED. All five example notebooks re-executed cleanly.

Dependency / CI note

The fold + multi-tile paths need xgcm >= 0.10.1 (now released on PyPI), which introduces the bipolar fold xgcm#711, the face-connection padding fix #712, and the bare-DataArray other_component vector-pad fix xgcm#749. padded_transports is independent of #749; the vector Grid.diff/Grid.interp used elsewhere benefits from it. Because 0.10.1 is a plain PyPI release, pip install -e . pulls it in — the previous --force-reinstall --no-deps install of the unreleased hdrake/xgcm@dev-v1.0.0 dev build is gone from CI. Fold/multi-tile tests skip on xgcm < 0.10.1; everything else runs on the same floor.

Note: the C-grid boundary-condition API rename lands in the 0.10.x line, not a later v1.0.0: Grid(boundary=)padding=, Axis.boundary.padding, pad(boundary=)padding=, and periodic= is removed (periodic=Falsepadding="fill"). Sectionate source, tests, example loaders, and notebooks target this API; values and semantics are unchanged. (The remaining xgcm v1.0.0-line work not yet in 0.10.1 is not relevant to this PR.)

Merged with master

master has been merged into this branch (a merge, not a rebase, so the review history is preserved). Three conflicts, resolved as follows:

  • pyproject.toml — kept this branch's xgcm >= 0.10.1 over master's xgcm == 0.9.*. This is the one dependency change the PR still carries.
  • sectionate/version.py — took master's dynamic-versioning shim (Derive the version from the git tag instead of a checked-in file #50). The version now comes from the git tag via hatch-vcs, so the __version__ literal this branch carried is gone and no version string remains in the tree.
  • .gitignore — union of both sides: this branch's data/*.nc, *.tar.gz, .DS_Store, plus master's generated sectionate/_version.py.

Because the versioning work is now merged in rather than diverged from, it no longer appears in this PR's diff — pyproject.toml here is a one-line dependency change. conda/meta.yaml and .github/workflows/ci.yml merged cleanly, keeping master's hatch-vcs host requirement and fetch-depth: 0 alongside this branch's xgcm floor.

CLAUDE.md (new in this PR) now also specifies one conda environment per branch/worktree, named docs_env_sectionate_<branch-or-worktree-name>: branches differ in their xgcm floor, so a single shared environment silently tests the wrong versions. Its Definition of Done applies before pushing as well as committing.

Known limitations (guarded, not silent)

  • A tripolar/bipolar fold expressed as a face self-connection in face_connections raises NotImplementedError — build the grid as a single tile with the fold as a boundary ({"X": "periodic", "Y": {"fold": "corner"}}), which is supported natively.

AI-assisted PR, disclosed per the project AI Usage Policy: commits carry Co-Authored-By: trailers; the author has reviewed and validated the full diff. 🤖 Drafted with Claude Code.

hdrake and others added 3 commits June 22, 2026 17:36
Replace the hard-coded `topology=` string ("latlon"/"cartesian"/"MOM-tripolar")
with neighbor-finding inferred from the `xgcm.Grid` the caller already supplies,
and extend section-finding and transports to multi-tile grids (face_connections).

Single-tile grids: neighbors follow from each axis' `boundary` metadata
("periodic" -> wrap, else clip), read via the public `axis.boundary` property
(no more private `._boundary`). Behavior is unchanged; existing tests pass as-is.

Multi-tile grids (lat-lon-cap, cubed-sphere): `gridutils.build_neighbor_maps`
pads index-valued arrays with xgcm and reads the halos to get each corner's
neighbors with axis-rotation/reversal already applied, reusing xgcm's tested
padding. A reciprocity check raises instead of returning the silently-wrong
neighbors xgcm's padding produces (hash-seed-dependently) for the most complex
reversed topologies. Section state carries a `face` index; `grid_section`
returns the face component for multi-tile grids.

Transports/tracers across face seams: each section edge's normal velocity is
read in a single face's frame (source-first dual-anchor), so nothing is rotated
across the seam, and the per-edge sign is computed geometrically ("does the
velocity point left of travel?") so it stays consistent across rotated faces
without a single global orientation factor. Verified by split-grid equivalence
(a 2-face grid is a flat grid cut in half) for symmetric and non-symmetric
axis-preserving seams, and by a streamfunction-difference test (transport
P1->P2 = psi(P2)-psi(P1)) for a genuinely rotated 2-face grid.

The tripolar/bipolar fold (a face self-connection) is detected and rejected
with NotImplementedError; xgcm does not support it either (xgcm issue #194).

New tests in sectionate/tests/test_section_multitile.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…upport

Derive all grid topology uniformly from xgcm. build_neighbor_maps now pads
index arrays for both single- and multi-tile grids, so periodic wrap,
fill/extend walls, multi-tile face_connections, and the single-tile bipolar
north fold (boundary={"Y": {"fold": ...}}) all flow through one path.
simple_neighbor_maps is removed; infer_grid_path now requires neighbor_maps.

Seam handling: a single physical corner can carry two indices (a periodic
seam, a shared multi-tile boundary corner, or the fold seam). The walker steps
through both; the zero-length edge between them spans no cell and carries no
flux, so it is dropped when faces are derived in uvindices_from_qindices. This
is deterministic, so a section reloaded from its (i_c, j_c, f_c) behaves
identically. It replaces both the single-tile-only collapse_periodic_duplicate
and the multi-tile var="0" placeholder with one rule.

Transports:
- Open sections (no enclosing polygon -> positive_in undefined) use the
  left-of-transect convention; closed sections keep inward/outward. Warning and
  docstrings reworded; only closed sections consult is_section_counterclockwise.
- Fold-seam pathfinder termination via COINCIDENT_TOLERANCE_M.

Round-trip / bug fixes:
- save/load_gridded_section now persist and restore f_c, so multi-tile sections
  round-trip instead of silently reloading as single-tile.
- GriddedSection.copy returned None and mutated self; it now returns an
  independent copy with a shared grid.

Tests: fold connectivity + Arctic crossing (skipped when xgcm lacks the fold
boundary), zero-length-face dropping, infer_grid_path requires maps, f_c
round-trip, GriddedSection.copy. CLAUDE.md/architecture docs refreshed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
load_MOM6_example_grid now declares the grid's bipolar north fold
(boundary={"X": "periodic", "Y": {"fold": "corner"}}), so the tripolar examples
can trace sections across the Arctic seam. Sections south of the fold are
unchanged.

- Removed the obsolete topology="MOM-tripolar" kwarg from all grid_section calls
  (notebook 4).
- Notebook 4: updated the create_section_composite example to the new API (it
  walks precomputed neighbor maps; grid_section builds them from the grid), and
  reworded the tripolar-seam section -- fold crossings now trace directly, so the
  "place extra points to get across the seam" step is an optional refinement
  rather than a required workaround.
- Notebook 5: the global MOC loop now runs across all latitudes (incl. >66N over
  the fold) end-to-end.
- All five notebooks re-executed cleanly with the unreleased fold-aware xgcm.

Requires xgcm with the bipolar-fold boundary (xgcm/xgcm#711) to run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sectionate operates on symmetric ('outer') and 'right'-staggered grids. Native
MITgcm-style grids -- notably the ECCO lat-lon-cap -- put vorticity points on
the 'left' (SW) corner, which was unsupported. symmetrize() builds the missing
high-side corner row/column by padding the corner coordinates one cell with the
grid's own topology (periodic / face_connections / fold), yielding an exactly
equivalent symmetric grid the rest of sectionate can consume. Tracer-center
data is carried over; velocity-staggered variables are dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hdrake added a commit to hdrake/regionate that referenced this pull request Jun 25, 2026
New example demonstrating regionate on the ECCO lat-lon-cap (LLC90) grid -- a
real 13-tile face_connections topology:
- examples/load_example_ECCO_grid.py: downloads the ECCO geometry from PO.DAAC
  (earthaccess), renames XC/YC/XG/YG to geolon*/geolat*, builds the LLC90 grid
  with canonical face_connections, and converts the native MITgcm 'left'
  staggering to symmetric via sectionate.gridutils.symmetrize.
- examples/5_ECCO_LLC90_multiface_regions.ipynb: traces a region straddling the
  tile-1/tile-2 seam -> a single boundary loop whose face index spans both
  tiles; plots the mask and stitched boundary. Added to the docs toctree.
- test_real_grids.py: replace the ECCO stub with a concrete seam-stitching test
  (runs when the geometry file is present; skipped otherwise).
- docs/environment.yml: add earthaccess + pandoc (nbsphinx render dep).

Requires sectionate.symmetrize (MOM6-community/sectionate#47 branch, local commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hdrake and others added 5 commits June 26, 2026 09:01
`_check_supported_topology` read face connections from the per-axis
`grid.axes[axis]._connections`, which the fold-aware xgcm no longer populates
(connections live on `grid._face_connections`). The check therefore saw no
connections and silently failed to reject a north-fold self-connection. Read
from `grid._face_connections[facedim]` instead, matching where the rest of the
package (build_neighbor_maps, padding) gets its topology.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the verbose backstory about the upstream xgcm padding bug from the
neighbor-map reciprocity check; keep only why the check exists and why it runs
on multi-tile maps alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… offset

Replace the binary symmetric/non-symmetric switch with a per-position corner
offset so all three Arakawa C-grid vorticity staggerings are first-class:
'outer' (symmetric, offset 0), 'right' (non-symmetric, +1), and 'left'
(MITgcm/ECCO, 0). Add gridutils.corner_position/corner_offset as the single
source of truth; get_geo_corners and coord_dict accept 'left'; check_symmetric
is now a thin wrapper (True iff 'outer') that no longer raises on 'left'.
transports.py and tracers.py thread the offset uniformly across single- and
multi-tile paths, so the native lat-lon-cap (LLC) case -- 'left' staggering plus
face_connections -- works with no staggering-specific code.

Remove the now-redundant gridutils.symmetrize (it dropped velocity data and so
never supported transports). Add test_left_grid.py (helper coverage, single-tile
left-vs-outer transport equivalence, the multi-tile+left LLC seam known-answer,
and tracer parity); remove the obsolete test_symmetrize.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Notebook 1: correct the intro to list all three corner staggerings and note that
MITgcm/ECCO is the 'left' case (not "non-symmetric"). Notebook 4: grid_section
infers the vorticity corner staggering generally, not just the 'outer' layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…6-community#46)

Track CLAUDE.md on this branch so the architecture overview lands together with
the code it describes -- corner_position/corner_offset, native 'left'
(MITgcm/ECCO) grids, check_symmetric as a thin wrapper, and the removal of
symmetrize -- rather than in a separate PR that would document code not yet on
its base. Also includes the repo's Definition-of-Done checklist (run the full
test suite, re-execute the example notebooks, and scan for doc/code drift before
committing). Supersedes the standalone add-claudemd PR (MOM6-community#46).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hdrake hdrake mentioned this pull request Jun 26, 2026
hdrake and others added 3 commits June 26, 2026 12:37
Add a workflow rule: when pushing a new commit to a branch with an open PR, also
update the PR's top-comment description to match the latest commit -- revise the
summary/Changes prose and check off (or add) task/status checklist items via
`gh pr edit`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix a version-sensitive nondeterminism in infer_grid_path: a strict
"closer than the current point" test admitted the correct seam-crossing move
(a periodic/fold seam twin, which is equidistant) only by sub-ULP rounding, so
the path -- and hence transports -- could flip across numpy/libm builds (e.g.
the Python 3.12 CI gave 0 instead of -3 for the equatorial-loop convention
test). Now admit a neighbor if it is strictly closer OR a seam twin of the
current cell (by physical coincidence), exclude wall self-neighbors, and break
curve-deviation ties deterministically by index. The result is independent of
platform and, for normal sections, of travel direction.

Add a `curve` argument ("great circle", default, or "latitude circle") threaded
through grid_section/create_section_composite/create_section/
infer_grid_path_from_geo/infer_grid_path, with per-curve progress and deviation
metrics (deviation in radians for both). Raise ValueError for segments that span
>= 180 degrees (latitude circle) or whose endpoints are near-antipodal (great
circle), where the direction is ambiguous.

Verified: the equatorial-loop test gives -3 in both directions and is identical
across PYTHONHASHSEED; full suite (37) and all example notebooks pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add test_section_reversibility.py: walking a non-axis-aligned section (a ~30-degree
diagonal, plus steeper/shallower variants and a latitude-circle case) from A to B
must yield the exact reverse of walking it from B to A, and the transport magnitude
must be identical either way. Guards the deterministic, direction-independent
neighbor selection on genuinely 2-D paths, not just zonal/meridional lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hdrake added a commit to hdrake/regionate that referenced this pull request Jun 27, 2026
…titch

Replace the cell-based multi-tile tracer with the planned decomposition, which
isolates the only bespoke step (cross-seam stitching) and produces boundaries
that are grid-adjacent everywhere -- so they convert to velocity (u,v) faces and
obey the discrete flux-divergence theorem across rotated seams AND the Arctic cap.

Algorithm (regionate/boundaries.py):
  1. contourpy traces each face's mask boundary (reusing the single-tile path).
  2. a segment is internal (cut) iff BOTH cells it separates are in-mask in the
     topology-aware padded mask -- the entire seam-cutting rule.
  3. surviving open arcs are stitched by the global cell-set at their endpoints
     (cells are globally unique, so no coordinate coincidence is needed).
  4. face-local corners are converted to native (f,j,i) by a cell-set-matched
     adjacency walk; seam crossings the cell-set over-merges are repaired by
     inserting the corner where the two tiles meet; the loop is closed once.
Works for 'left' (MITgcm/ECCO), 'outer' (symmetric) and 'right' grids via
sectionate's corner_position/corner_offset; single-tile uses the same offset fix.

ECCO example now loads the native 'left' LLC90 grid directly (no symmetrize) and
uses the published regionmask North+South Atlantic basin (geographically correct,
no Pacific contamination). The basin traces to one 720-corner boundary across
six tiles and converts to 720 velocity faces. New real-data test asserts the
Atlantic boundary converts to velocity faces (the transport-consistency check).

Requires the topology-driven sectionate (MOM6-community/sectionate#47): native
'left' support + corner_position/corner_offset; and xgcm with the face_connections
padding fix (xgcm#713) for the cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hdrake and others added 3 commits June 27, 2026 01:07
Replace the CM4p25 density-space MOC notebook with a depth-coordinate
overturning streamfunction psi(lat, z) computed from ECCOv4r4 output on
its native lat-lon-cap (LLC90) grid. This is the first end-to-end use of
sectionate's new multi-tile (face_connections), native MITgcm/ECCO
'left'-staggered, and curve="latitude circle" support on a real LLC grid.

- Add examples/load_example_ECCO_grid.py (adapted from regionate): loads
  the native 'left' 13-tile LLC90 xgcm.Grid and builds U/V-face volume
  transports utr=UVELMASS*dyG*drF, vtr=VVELMASS*dxG*drF via earthaccess.
  Drops the regionmask/Atlantic-basin helpers and the stale 'symmetrize'
  docstring (that API was removed on this branch).
- Rename 5_MOC_transports_CM4p25.ipynb -> 5_MOC_transports_ECCOv4r4.ipynb;
  trace global latitude circles (four <180-deg arcs), compute meridional
  transport per depth level with convergent_transport, cumulatively
  integrate in depth, and plot psi(lat, z).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On native 'left'-staggered (MITgcm/ECCO lat-lon-cap) and 'right' grids the
vorticity lattice sits half a cell from the centers a `face_connections` seam is
defined on, so xgcm's corner-array halo padding lands a cross-tile neighbor one
corner off (diagonal instead of edge-adjacent) across rotated/reversed seams --
systematic on the LLC grid, invisible on symmetric 'outer' grids (shared seam
corners). Add `gridutils._correct_seam_neighbors`, which derives the true
edge-adjacent neighbor from center-cell adjacency (centers pad correctly across
any seam): index every corner by its four edges (the cell pair each separates)
and substitute the corner that genuinely shares a broken neighbor's edge, then
wall off any residual non-reciprocal entries at cube-vertex triple junctions.
'outer' grids are exempt. Add a left-grid rotated-seam regression test asserting
all interior corner neighbors are edge-adjacent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document that local development/testing/notebooks use the docs_env_sectionate
conda env, which must carry the unreleased fold-aware xgcm
(hdrake/xgcm@tripolar-north-fold, with #711 fold + #713 padding fix) plus
example deps (earthaccess); note the dev-version reinstall caveat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hdrake and others added 3 commits June 29, 2026 19:15
Replace the earthaccess/Earthdata-login download path in the ECCO MOC
example with a stdlib urllib fetcher pointed at the redistributed V4r4
subset on Zenodo (concept DOI 10.5281/zenodo.21051424, this version
record 21051920 / v2.0.0). Files are verified against their published
MD5s and any already present under ../data/ are reused without
re-downloading, so the example now runs with no NASA Earthdata login.

The loader now always uses the twelve published 2010 monthly volume-flux
files (the temporal= argument is gone), so the MOC is an annual mean over
2010 rather than over whatever happened to be on disk; the notebook now
reports 12 months in the time average. Notebook prose and CLAUDE.md are
updated to describe the Zenodo source and to cite the original NASA
datasets via the Zenodo record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `curve="latitude circle"` example to notebook 4 — a global
constant-latitude section (0->360 in 120-degree arcs) plotted on a south
polar stereographic projection — and drop the now-redundant bipolar-fold
example that placed extra waypoints on either side of the seam, since the
walk no longer needs them to trace correctly across the fold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add data/*.nc (the example data cache fetched from Zenodo/PO.DAAC at
runtime), *.tar.gz build artifacts, and .DS_Store to .gitignore so they
stop showing up as untracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread sectionate/gridutils.py Outdated
Comment thread sectionate/gridutils.py Outdated
Comment thread sectionate/gridutils.py Outdated
hdrake and others added 5 commits June 29, 2026 19:36
…k_symmetric

Per PR review:
- Remove the editorial mapping of corner positions to model conventions
  ('outer'=symmetric, 'right'=non-symmetric, 'left'=MITgcm/ECCO) from
  package docstrings/comments in gridutils.py and transports.py; refer to
  positions only by their xgcm names. The MOM6/MITgcm correspondence now
  lives only in the example notebooks, where it is relevant.
- Rename gridutils.check_symmetric -> check_outer (and update its docstring
  and the test usages) to describe what it checks rather than a convention.

The math-sense "symmetric" in section.py (a direction-symmetric cost
function) is unrelated and left as-is. Full suite: 41 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs: point the toctree at examples/5_MOC_transports_ECCOv4r4 (the
  notebook was renamed from the old CM4p25 MOC example), which was the
  stale reference breaking the Sphinx build (RTD uses fail_on_warning).
  Verified `sphinx-build -W` now succeeds.
- CLAUDE.md: update the stale check_symmetric() reference to check_outer()
  and stop labelling corner positions as symmetric/non-symmetric/MITgcm-ECCO
  in the module blurbs; the MOM6/MITgcm correspondence is kept in the
  "Corner staggering" concept section and the example notebooks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From an independent PR review:
- docs/environment.yml: add `pandoc`. nbsphinx shells out to pandoc to
  convert notebook markdown even with nbsphinx_execute="never", and RTD
  builds the env solely from this file (its image doesn't ship pandoc), so
  the docs build would fail without it.
- examples/load_example_ECCO_grid.py: download to a `.part` temp file and
  os.replace() into place only after the MD5 verifies, so an interrupted
  download can't leave a truncated file at the real path. Drop unused
  imports/constants (glob, numpy, ECCO_VOLUME_FLUX_GLOB).
- transports.py: document the `f_c` parameter (and flesh out layer/interface)
  in convergent_transport's docstring.

Verified: 41 tests pass; `sphinx-build -W` succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New "Visualizing sections on the native grid" section in notebook 5: a
cartopy map of the 13-tile LLC90 grid with every cell tinted by its native
tile index and each face annotated with its number, overlaid with three
latitude-circle sections (70°S, 10°N, 70°N) and a legend listing the faces
each threads -- the 10°N circle crosses six faces and the 70°N circle clips
the Arctic-cap face. Includes a short note explaining the one-cell staircase
that appears at some tile seams (discretization of a smooth latitude circle
onto rotated, offset tiles; it does not bias the transport).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`is_mask_inside` located the "inside" tracer cell using the face-frame
`Usign`/`Vsign`. On multi-tile grids these diverge from the geometric
`Lsign` (which carries the topology), so `convergent_transport(
positive_in=mask)` selected the wrong-side cell and returned an inverted
sign -- e.g. the discrete divergence theorem on the native ECCO LLC grid
closed in magnitude but with flipped sign. Use `Lsign` instead.

This is a no-op on single-tile grids, where `Lsign` equals the face-frame
sign by construction, so existing 'outer'/'right' behavior is unchanged.

Add `test_left_multitile_mask_orientation`, which fails under the old
face-frame logic and passes now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@geoffstanley

Copy link
Copy Markdown

This looks great! I poked around and have a few small comments or questions:

  • "Bipolar-fold and reversibility tests; the ECCO MOC example reproduces the expected global overturning structure on a real lat-lon-cap grid." this seems like a typo here, as the lat-lon-cap grid of ECCO isn't testing the bipolar north fold. (Are more bipolar north fold unit tests needed?)
  • Thanks for implementing curve="latitude circle"!
  • _left_sign tests the sign of a cross product of two vectors in lat-lon space. I worry this won't work near the geographic poles, or at the dateline (though your comments suggest it would; you may have thought about this more than me). The approach I quickly implemented was to pick the sign of U or V based on the F point grid indices alone: If the current F point indices (ic,jc) and previous F point indices (ip, jp) have ic==ip then we want U not V, and if jc = jp + 1 then take +U from (ic,jc), otherwise take -U from (ip,jp); similarly for taking V. This requires inserting duplicate F points in the section's list of indices where it crosses a boundary like the north fold or dateline, so the same geographic point but with different F grid indices is represented twice. Food for thought.

hdrake and others added 5 commits August 3, 2026 17:38
Pad only over the axes the array actually spans
Both branches committed re-executed example notebooks, so all five conflict.
Neither branch changes any notebook result -- #8's notebook refresh is a no-op
by construction, and notebook 5's streamfunction is bit-identical on both -- so
the conflict is entirely re-executed output. Take this branch's copies wholesale
(`--ours`) rather than hand-merging notebook JSON; they are re-executed on the
merge result in a later commit, which is what actually reconciles them.

Everything else merged cleanly: #8 touches gridutils.py and two test files that
this branch does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`convergent_transport` asked the caller to name the vertical layer and interface
coordinates, then checked the two names against each other by string substitution
(`layer.replace("l", "i") != interface`, MOM6-community#51). Both the check and the arguments
turn out to be unnecessary.

The layer coordinate is a dimension of `utr`/`vtr` themselves, so it already rides
along into the section: on the CM4p25 example file `umo` has dims
`(time, sigma2_l, yh, xq)` and carries `sigma2_l`, and `dsout[layer] =
grid._ds[layer]` was re-attaching a coordinate that was on the output either way.

The interface coordinate genuinely cannot ride along -- it is one point longer than
the layer coordinate, so it is a dimension of nothing on the section -- but the grid
knows it, and knows it without being told which coordinate the transports are
resolved over: whichever axis registers their layer dimension at its "center"
position names the interface at "outer"/"inner"/"left"/"right". `_interface_coords`
reads it off from there. Keying on the data's own dimensions rather than on an axis
named "Z" means a vertical axis under any name is found, and grids that declare only
their horizontal axes (most of them, since sections are traced horizontally) simply
contribute nothing.

So both parameters are removed rather than re-validated, and the `<stem>l`/`<stem>i`
convention leaves the source entirely. Names now play no part at all: the pairing is
the grid's, whatever it spells.

Verified on the real CM4p25 OSNAP-west section: with the vertical axis declared on
the example grid, `convergent_transport(grid, i_c, j_c)` returns output that is
`xr.testing.assert_identical` to the previous call passing
`layer="sigma2_l", interface="sigma2_i"`.

All 32 in-tree call sites passed `layer=` explicitly and are updated. The 19 tests
covering the old validation are replaced by 11 covering the new behavior: the layer
coordinate arriving from the transports, the interface coordinate from the grid,
names following no convention, a vertical axis not named "Z", an interface position
naming a bare dimension with no values, transports with no vertical dimension at
all, and `TypeError` for both removed keywords.

Downstream callers that read the two names off `grid.axes["Z"].coords` only to hand
them back (xwmb does exactly this) can drop them; that needs its own PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Notebooks 2, 3 and 5 drop the `layer=`/`interface=` arguments, and the CM4p25
example grid now declares its vertical axis, so notebooks 2 and 3 still get
`sigma2_i` on their output -- from the grid rather than from a string.

All five were re-executed together in one env (xgcm 0.10.1, this branch installed
editable), which is also what finally reconciles the merge: the committed outputs
are now the ones this branch's code actually produces, rather than either side's
copy taken wholesale.

No numerical output changed. Comparing every textual output against the previously
committed ones, the only differences are the version banner
(`0.4.0rc2.dev1` -> `0.4.0rc2.dev3`, a hatch-vcs dev string from an editable
install of an untagged branch) and warning messages citing this checkout's paths.
Notebook 5's overturning streamfunction reproduces bit-for-bit:
`psi range (Sv): -58.09992975038242 to 53.35870875408872`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Take the vertical coordinates from the data and the grid; drop layer/interface

@hdrake hdrake left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments need to be addressed before this PR can be merged. Some of the comments are minor suggestions. Some are questions, which should be answered directly as a response to the comment, which @hdrake will either resolve or follow up on manually. Any questions that require large changes (e.g. changing at which step in the sectionate workflow the artificial cross-tile faces with zero physical length get dropped) should be implemented as separate PRs back into this PR.

Comment thread examples/load_example_ECCO_grid.py Outdated

The required files are a small subset of NASA's ECCO V4r4 state estimate
(geometry plus the twelve monthly volume-flux files for 2010), redistributed on
Zenodo (concept DOI `10.5281/zenodo.21051424`, this version `10.5281/zenodo.21051920`)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now outdated, should use v3.0.0 10.5281/zenodo.21479854; comments like this should not hard-core things like this, since the place where it should be tracked is the ZENODO_RECORD_ID below.

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to v3.0.0 and rewrote the paragraph so it no longer carries a version DOI at all — it now cites only the version-independent concept DOI and says explicitly that the version being downloaded is pinned by ZENODO_RECORD_ID below, which is the single place that version is recorded.

Worth flagging that the bump was more than a number: v3.0.0 concatenates each collection along time into one file per collection, so the twelve monthly volume-flux entries in ECCO_FILE_MD5 collapse to a single 2010 file with a new checksum. I downloaded it and checked that its MD5 matches the published one, that it carries the same UVELMASS/VVELMASS on the same dims with the same twelve monthly timestamps, and that load_ECCO_MOC_grid builds utr/vtr with identical dims and shapes. The geometry file's checksum is unchanged.

In hdrake#9.

Written by Claude Code.

Comment thread examples/load_example_ECCO_grid.py Outdated
import xgcm

# Zenodo record holding the redistributed ECCO V4r4 subset. ZENODO_RECORD_ID is the
# version-specific record (v2.0.0); ZENODO_CONCEPT_DOI is the version-independent

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up to v3.0.0

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, and I removed the version marker rather than bumping it, for the same reason as the docstring above: ZENODO_RECORD_ID should be the only thing that records which version we pull. The comment now explains what the two constants are for and notes that changing the record id means re-checking the checksum table, since each version publishes its own file list — which is exactly what bit here.

In hdrake#9.

Written by Claude Code.

Comment thread sectionate/gridutils.py Outdated
# ([face,] j, i) index of each of its four neighbors ("right", "left", "up",
# "down"). Walls (no neighbor, e.g. a "fill"/"extend" edge) are represented as
# the point itself, so the pathfinder simply never finds them closer to the
# target -- reproducing clip-to-edge.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid weird jargon like " -- reproducing clip-to-edge". Either explain it in sectionate's own terms or in plain language.

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped, in both places it appeared. It now says the thing plainly instead: at a closed boundary a corner is recorded as its own neighbour, so a step in that direction stays put, never gets the walk closer to the target, and is therefore never taken — the section stops at the boundary rather than running off the array.

In hdrake#9.

Written by Claude Code.

Comment thread sectionate/gridutils.py
"tracer-center coordinates to derive their corner topology."
)
return _multitile_padded_maps(grid, geocorners)
da = geocorners["X"]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment clarifying that this is the single tile case.

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, right after the multi-tile early returns. It notes that a single-tile grid's whole topology is its axis boundary conditions — periodic wrap, closed edge, north fold — that xgcm's halo padding already encodes all three, and that these maps come back with fmap = None.

In hdrake#9.

Written by Claude Code.

Comment thread sectionate/gridutils.py Outdated
Comment on lines +437 to +438
bit-reversed copy across a self-folded boundary; the coincident lips of a
grid cut). Any topology derived by padding the per-face corner lattice

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The grid cut terminology is a bit confusing. It should be defined more clearly. The other cases are more obvious.

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defined it in the class docstring and used it consistently from there. A grid cut is a seam along which the mesh has been slit open, so one line of physical corner points appears twice in the lattice, as the two coincident "lips" of the cut.

The part that makes it different from a face seam is now stated up front, because that is the bit that was doing the unexplained work: a cut is not declared in face_connections, so neither lip enters the other's halo and nothing combinatorial reveals that they are the same points — only their coordinates do. That is why the cut is the one place a coordinate fallback is needed at all. LLC90's 65°E/115°W slit under Antarctica is given as the example.

In hdrake#9.

Written by Claude Code.

Comment thread sectionate/transports.py
Comment on lines +159 to +164
# Resolve each corner to its canonical native representation on the
# grid's corner topology, dropping consecutive corners that are the
# same physical point. Sections saved before multi-tile paths became
# twin-free step through both native copies of a shared seam corner:
# that zero-length edge is not a velocity face, and the neighbor maps
# used below link only canonical representations.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in https://github.com/MOM6-community/sectionate/pull/47/changes#r3753380399, I think these should be dropped way earlier so that the public API never surfaces these artificial faces/index pairs.

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved into section-finding. The two halves were doing different jobs, and both belong there: resolving each corner to its canonical native (face, j, i) is what lets the face attribution look a corner up in either face's frame, and dropping consecutive duplicates is the fix you are asking for.

Both now happen in section.drop_repeated_corners, which needs the xgcm.Grid — so it is called from grid_section, from GriddedSection.__init__ when indices are passed in directly (covering .copy() and hand-built sections), and from load_gridded_section. That last one is what makes removing the old filter safe: sections saved before this change still contain both identities, and are normalised on load rather than silently mis-derived.

In hdrake#11.

Written by Claude Code.

Comment thread sectionate/transports.py
Comment on lines +230 to +236
# Drop zero-length faces. A single physical corner can carry two indices -- at a
# periodic seam, a shared multi-tile boundary corner, or the bipolar fold seam -- so
# the section path can contain a consecutive pair whose endpoints are the same point.
# That edge spans no grid cell and carries no flux, so it is not a velocity face. Both
# corners are kept (they anchor the real faces on either side), but the degenerate edge
# between them emits no face. Done here, in the deterministic corner->face derivation,
# so it applies identically when a saved section is reloaded from its (i_c, j_c[, f_c]).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in https://github.com/MOM6-community/sectionate/pull/47/changes#r3753380399, I think these faces should be dropped immediately after they are used in the section-finding algorithm.

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — it now happens immediately after the walk instead of at the end of the face derivation. The reason it lived in uvindices_from_qindices was to cover reloaded sections too; that is now handled by normalising on load, so this filter is unreachable and removed.

Which of the two identities to keep turned out to matter. Keeping the later one — the frame the path continues in — makes both flanking faces come out with exactly the indices they had before, so no transport number moves. I verified that rather than assuming it: reference values captured from the unmodified base checkout, compared against the branch across six ECCO LLC90 latitude circles and three MOM6 tripolar-fold sections. Exactly one recorded value changed, n_corners 308 -> 307 on the zonal periodic section, which is the point of the change. Face counts, index sums, index heads, U/V counts and the overturning streamfunction are all identical.

In hdrake#11.

Written by Claude Code.

Comment thread CLAUDE.md Outdated

## Project Overview

Sectionate is a Python package for sampling grid-consistent hydrographic sections from structured ocean model outputs (e.g. MOM6). It traces paths along C-grid velocity faces between geographic waypoints and computes transports/tracer values along those sections. Only structured grids are supported.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove "(e.g. MOM6)"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change "Only structured grids are supported" to supports any structured model whose grid can be defined by a xgcm.Grid object.

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

In hdrake#9.

Written by Claude Code.

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — it now reads that sectionate supports any structured model whose grid can be described by an xgcm.Grid object.

In hdrake#9.

Written by Claude Code.

Comment thread CLAUDE.md Outdated
Comment on lines +12 to +13
Branches diverge in their dependencies — the xgcm floor in particular — so a single
shared environment silently tests the wrong versions. Create it from

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove: "Branches diverge in their dependencies — the xgcm floor in particular — so a single
shared environment silently tests the wrong versions."

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

In hdrake#9.

Written by Claude Code.

Comment thread CLAUDE.md Outdated
Comment on lines +31 to +37
The environment must carry **xgcm >= 0.10.1**, which
introduces the bipolar north fold (`padding={"Y": {"fold": ...}}`, formerly #711),
the face-connection padding fix (#712), and the bare-`DataArray` `other_component`
vector-pad fix (#749). It is a plain PyPI release, so `pip install -e .` pulls it in
automatically (sectionate requires `xgcm>=0.10.1`). The ECCO example (notebook 5)
pulls its data subset from Zenodo (concept DOI `10.5281/zenodo.21051424`) via stdlib
`urllib` with MD5 checks — no `earthaccess`/Earthdata login needed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove all of this, which seems unnecessary.

@hdrake hdrake Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed, all of it. One dangling reference was left behind in the following paragraph, which cited xgcm < 0.10.1 as a skip cause; that now refers to the floor in pyproject.toml instead of restating a version. Nothing else in CLAUDE.md, README.md or docs/ depended on the removed text.

In hdrake#9.

Written by Claude Code.

hdrake and others added 3 commits August 10, 2026 15:23
…ogy check

Documentation and comment fixes from review 4899521952, plus two small
behaviour changes that came out of it.

- examples/load_example_ECCO_grid.py: point at the current Zenodo release
  (v3.0.0, record 21479854). The module docstring now names only the
  version-independent concept DOI and defers to ZENODO_RECORD_ID for the
  exact version, instead of restating a version DOI in prose. v3.0.0
  concatenates each collection into one file per collection, so the twelve
  monthly volume-flux entries collapse to a single 2010 file; its published
  MD5 is recorded and the geometry file's checksum is unchanged.

- gridutils.py: drop the "clip-to-edge" jargon in favour of plain language
  about closed boundaries (both occurrences), mark where the single-tile
  branch of build_neighbor_maps begins, define "grid cut" in the
  _OuterTopology docstring, and reframe that class's LLC90-specific remarks
  as illustrations of general statements. Measured tolerances are kept, now
  explicitly labelled as LLC90 measurements. Comments only.

- transports.py: explain in _in_velocity_range what the check is for -- it
  is how _uv_for_edge decides whether a seam edge's velocity is stored on
  the source face or the destination face -- and handle "U" explicitly, so
  an unexpected component raises instead of silently taking the U branch.

- section.py: _check_supported_topology no longer describes the north fold
  in terms of an xgcm face_connections representation that was never
  released. It now checks generically that the multi-tile topology is one
  sectionate can trace: every neighbour face exists, every connection is
  mutual, and no face is glued to itself. Verified that a self-glued face
  is not caught anywhere downstream (it silently traces a section as if the
  seam were absent), and that unknown/one-sided connections previously
  failed with a KeyError/TypeError from inside xgcm's padding.

- CLAUDE.md: apply the requested edits to the project overview and the
  development-setup section.

Tests: pin the self-glued, unknown-face and one-sided-connection failures,
check that supported topologies (rotated seam, LLC90's 13-tile connections)
still pass, and cover _in_velocity_range including the new raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sectionate now resolves every section segment to the shortest path between
its two waypoints, for both `curve="great circle"` and
`curve="latitude circle"`. Raw longitudes are no longer read as a request to
travel the long way round: a latitude-circle segment written 0 -> 270 runs
90 degrees west, matching what a great circle already did. The old
"each segment must span less than 180 degrees" ValueError is gone.

`_check_segment_span` now raises a single, shared ambiguity error for both
curve types, and only for genuinely ill-posed segments:

- endpoints exactly half a circle apart, where the two ways round are
  equally short so the shortest path is not unique (antipodal for a great
  circle, +/-180 degrees of longitude for a latitude circle);
- for "latitude circle" only, oblique endpoints that differ in both
  latitude and longitude, since no circle of constant latitude passes
  through them. Latitude-circle segments must be zonal (shared latitude) or
  meridional (shared longitude); a meridional segment is the connector used
  to join zonal arcs at different latitudes and walks straight down the
  meridian.

Previously an oblique latitude-circle segment was traced as an L-shaped
path -- zonal along the starting latitude, then meridional -- which was
direction-dependent and, when it crossed the periodic seam, degenerated
into a staircase of zero-length seam steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Answering the reviewer's two questions about `gridutils.py` turned up
documentation that did not survive measurement.

`_OuterTopology`'s corner/outer-lattice guard now says what it actually
catches. It is unreachable on a well-formed grid -- corner dims are Nc+1
for 'outer' and Nc for 'left'/'right', so the test is an equality for
'outer' and 'right' and leaves one slot of slack for 'left'. It fires only
when the declared corner position contradicts the corner arrays' length, or
when centers and corners come from different grids; xgcm does not validate
dim lengths, so such a grid builds without complaint. Its message was also
wrong for the commonest trigger -- a symmetric array declared 'right' is
not "larger than the outer lattice", it just cannot sit at the +1 offset
'right' implies -- so it now prints both shapes and names the likely cause.

`padded_transports` claimed that no halo pad can supply a value for edges
stored on no face. Measured against the pinned xgcm that is not what
happens: on ECCO LLC90 it and a dict-form vector pad agree bit for bit,
both summing cell convergence to exactly 0.0 globally, because the slots it
zeroes are exactly the ones the pad fills with a `fill_value` that is
already 0 there. The docstring now claims only what holds: independence
from the axis `fill_value` (on the cubed-sphere fixture, `fill_value=nan`,
a vector pad leaves 23 of 128 cells' convergence NaN where a wall's
transport is 0 by definition), independence from halo correctness across
rotated seams, and that it takes plain arrays.

CLAUDE.md's matching claim that the dict form of `xgcm.pad` "was always
exact" is softened for the same reason: xgcm#712 was a general
`_pad_face_connections` bug that could corrupt any face-connection pad,
dict vector form included, while #749 fixed only the bare-DataArray path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hdrake

hdrake commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Disclosure, per the project's AI usage policy: the replies I am posting on this review's comment threads were drafted with Claude Code, as were the three follow-up PRs linked from them. Every claim in them was checked against a run rather than asserted, and where a measurement contradicted what the PR already said, the PR is being corrected rather than the claim defended (see the padded_transports thread).

The review's comments are being addressed as three PRs back into topology-driven-neighbors, as you asked:

Each runs the full suite with 0 skips on its own branch. The example notebooks are re-executed once, on the integrated branch, after all three land.

Written by Claude Code.

The walk in `infer_grid_path` is documented only by inline comments, and the
example notebooks show traced sections but never the mechanism. Add
`docs/source/algorithm.md`, whose centrepiece is an animation stepping through
every iteration of a two-segment, eight-step section on a small synthetic grid,
plus a static contact sheet of the same eight steps.

The overlays make each of the algorithm's three decisions visible: the four
neighbours exactly as `build_neighbor_maps` returns them (with walls and the
backtrack marked), the admission circle -- centred on the target with radius
equal to the current corner's distance, so "admitted" is literally "strictly
inside" -- and the two spherical angles whose sum is `deviation`, shaded on the
corner that wins. Both angles are drawn, not just the target-side one, because
their symmetry is what makes the traced path independent of travel direction.

`docs/make_algorithm_animation.py` generates both artifacts. It does not
reimplement the walk: it replays `infer_grid_path` to record per-step state for
drawing, then asserts that the replayed path equals what `grid_section` returns.
A change to the walk therefore makes regeneration fail loudly rather than
letting the page drift out of sync with the code.

Docs plumbing: enable `myst_parser` so hand-written `.md` pages render, and add
`myst-parser` and `ffmpeg` to `docs/environment.yml` -- existing docs
environments need rebuilding. Also ignore `docs/source/examples/`, which
`conf.py` regenerates (and rmtree's) on every build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add an animated docs page explaining the section-tracing algorithm
@hdrake

hdrake commented Aug 11, 2026

Copy link
Copy Markdown
Member Author
  • The approach I quickly implemented was to pick the sign of U or V based on the F point grid indices alone: If the current F point indices (ic,jc) and previous F point indices (ip, jp) have ic==ip then we want U not V, and if jc = jp + 1 then take +U from (ic,jc), otherwise take -U from (ip,jp); similarly for taking V. This requires inserting duplicate F points in the section's list of indices where it crosses a boundary like the north fold or dateline, so the same geographic point but with different F grid indices is represented twice. Food for thought.

@geoffstanley, I looked into your approach but it got a bit messy when it came to multi-tile grids like ECCO's LLC because the meaning of U and V flip across the boundaries of some tiles. I like that with the geometric approach you don't need to worry about such things because it all comes for free from the coordinates.

@geoffstanley

Copy link
Copy Markdown

@geoffstanley, I looked into your approach but it got a bit messy when it came to multi-tile grids like ECCO's LLC because the meaning of U and V flip across the boundaries of some tiles. I like that with the geometric approach you don't need to worry about such things because it all comes for free from the coordinates.

I've continued a bit further with my approach and it actually seems to be working very well. I'll have some tests done soon to confirm either way though.

When crossing the north fold (or perhaps other tile boundaries), how do you handle the sign flip of the velocity components? In the ORCA north fold for example on a T-point pivot, the right half of the uppermost U grid is duplicate and carries a sign flip, while the entire uppermost V grid is duplicate and carries a sign flip. For an F-point pivot, the U grid has no duplicates but the V grid uppermost right half is duplicate with a sign flip. See Appendix E of the NEMO 4.2 manual.

If your list of F-point indices for the section does not include duplicates (i.e. different integer indices that represent the same geographic point), then how do you avoid double counting, and how do you capture the sign flip for certain grids if your transect happens to be on that side (the right half)? It will require considerable knowledge of the grid, which will get hard to generalize to many grids (LLC, cube-sphere, bifold, etc).

What I have done is to purposely include duplicate (i,j) points on the F grid, and then when calculating transport. Specifically, I run the GriddedSection algorithm twice: once forwards; and again with the section reversed (using Section.reverse()) followed by reversing the list of (i,j) F grid indices. If the section does not cross any duplicate points (on the F grid), then the two lists are identical and nothing needs to be done. But when the two lists disagree, I merge the two lists together, taking all the points that agree as they are, but where they differ, I take all the points from the first list first, then all the points from the second list second. The result is a list of indices that are all on the same "face" ending with points that are in the duplicate zone, followed by a jump to a list of indices that begin with points in the duplicate zone and are all on the other face.
(In practice, the two cases are handled identically; no check needs to be done on whether the two lists are identical.)

Then, accumulating transport across the section happens naturally with the above algorithm, with the simple modification that I mask out the contributions from duplicated U or V points. In NEMO, those masks are given by 2D fields called umaskutil and vmaskutil (not the standard 3D masks umask and vmask). Note, in NEMO, the non-duplicated points always carry the normal (positive) sign.

My only assumption is that the "jump" involves two points that are not adjacent to each other in (i,j) space (ie same i and j differs by 1, or same j and i differs by 1). If it did, then transport could be double counted there. With the bifold, this should not happen as the pivot points are in land -- so even if you made a section that went through the pivot points, you'd be double counting zero transport, so no problem. For the LLC or cube sphere or anything else involving multiple tiles, the logical coordinates are (tile, i, j) and so the tile coordinate would be different, so again no double counting would happen.

hdrake and others added 2 commits August 11, 2026 10:28
`curve` now takes three values instead of two:

- "great circle" (default) -- unchanged, every segment follows the geodesic.
- "latitude circle" -- every segment follows a parallel. A segment whose
  endpoints do not share a latitude lies on no circle of constant latitude, so
  it now raises. Previously meridional segments were quietly accepted here on
  the grounds that a meridian is unambiguous; they are not latitude circles,
  and accepting them hid a real failure (below).
- "latitude and great circle" (new) -- decided segment by segment: segments
  whose endpoints share a latitude follow the parallel, all others follow the
  geodesic. This is what a section that is zonal in places and joined up by
  meridional or slanted legs elsewhere actually wants.

This fixes a bug. The constant-latitude `progress` metric measures progress
purely in longitude, so along a meridian it is flat: the walk admits no
neighbour, falls through to the fallback branch, and on a real grid can fail to
converge. `grid_section(grid, [0., 0.], [80., 60.], curve="latitude circle")`
on ECCO LLC90 raised `RuntimeError: Should have reached the endpoint by now.`
in that direction while 60->80 traced fine -- a direction-dependent failure on
a section that never should have been routed to those metrics. Under
"latitude circle" the segment is now refused with a clear message; under
"latitude and great circle" it routes to the geodesic and traces identically
in both directions.

The per-segment choice is made once, in `infer_grid_path_from_geo` (which is
called once per segment), from the requested waypoints rather than from the
grid corners they snap to, and threaded down to the metric selection.
`_is_constant_latitude` is the single classification behind both the legality
check and the metric choice, so the two cannot disagree.

Also, from review of the shortest-path change:

- The segment tolerance was 1e-9 degrees, 0.11 mm. It is now a *classification*
  tolerance, and at that size a latitude that has been through float32 -- which
  is how ECCO and many models store corner coordinates -- reads as a different
  latitude and turns a zonal segment oblique. Split into two named constants:
  `CONSTANT_LATITUDE_ATOL_DEG` (1e-6 deg, ~11 cm, five orders of magnitude
  below any grid cell) and `HALF_CIRCLE_ATOL_DEG` (1e-9 deg), which measure
  different things.
- The shared half-a-circle error quoted a bare "separation ~180.0000 degrees"
  for two points 222 km apart at 89N, because along a parallel the number is
  degrees of longitude and not degrees of arc. Still one error, but it now
  names the measure it is reporting.
- The `180 - atol` guard is described for what it does: it catches endpoints
  written exactly half a circle apart. A hair short of that (0 -> 179.9999999
  east, 0 -> 180.0000001 west) resolves by round-off with no error, so the
  docstring says to subdivide rather than to rely on the check.
- The contract was restated in full in five places. It is now stated once, in
  `grid_section`, and cross-referenced from the rest; nothing points a user at
  a private name, since there is no rendered API page for one to land on.
- Dropped the comment describing the L-shaped oblique latitude-circle path,
  which no longer reaches that code.

Tests: all three options over zonal, meridional, oblique and exactly-half-a-
circle segments; a 2-degree global fixture where the two metrics visibly
differ (0->120 at 40N holds 40N for 61 points, while the geodesic bows to 60N
over 83) -- the existing 7x5 fixture has corner rows 40 degrees apart and
cannot show this; float32-derived waypoints classifying as constant-latitude;
and the ECCO LLC90 meridional segment tracing identically in both directions.
`test_oblique_latitude_circle_segments_reversible` is renamed: its legs are
zonal and meridional, so under these rules it was never oblique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit of this branch found several statements that are not true of the
code, plus a topology check that rejects a legal grid. Fix all of them.

`_check_supported_topology` claimed that face existence and connection
mutuality are things "neither of which xgcm enforces when the grid is built".
xgcm enforces both, at `Grid.__init__`: naming a face the grid does not have
raises KeyError, and a one-sided connection raises TypeError, each while it
builds its face-connection table. Only the self-gluing rejection is load
bearing -- xgcm accepts a face glued to itself and nothing downstream notices,
so a section is traced as though the seam were absent. The docstring now says
that, and labels the other two branches as the defence in depth they are. The
two tests that fired them by corrupting `grid._face_connections` after
construction are replaced by one that asserts xgcm rejects those grids at
construction, which is where the real enforcement lives.

The same function built its set of known faces as `range(n_faces)`, so a grid
labelled `face = [1, 2]` -- legal, and accepted by xgcm -- was rejected with
"only has 2 faces" even though face 2 exists. Read the labels instead, and
report them in the message. Covered by a new test.

`padded_transports` claimed to be independent of "the halo being right across
a rotated or reversed seam". It is not: `_OuterTopology` builds its whole node
graph by padding tracer-cell ids with the grid's own `face_connections` via
`xgcm.padding.pad`, and `padded_transports` reads that graph. What it really
buys over a *vector* pad is that it needs no vector rotation, takes plain
arrays, and is independent of the axis `fill_value`. The list also promised
three things and gave four. Separately, a zeroed cut lip was justified as "a
wall carries no transport" -- true for a wall, but for a cut lip or an
un-stored cap vertex the edge exists and zero is the conservative choice, not
a truth. Say so.

The corner-array guard rendered as "Corner dims (9, 9) do not fit the outer
lattice (9, 9)" for the very case it was written for -- two identical shapes
and "do not fit" -- and leaked "outer lattice", a term that appears in no
user-facing doc and is defined only inside the private `_OuterTopology`.
Rewritten in terms of the corner and centre array shapes and the offset the
declared staggering implies. Its twelve-line comment above a two-line `if` is
trimmed.

`_in_velocity_range` said a seam edge "is stored on only ONE of the two faces
it touches"; `_uv_for_edge`'s third branch exists precisely for edges stored on
neither. Corrected to "at most one", with that case named.

The LLC90 generalisation begun on this branch stopped at the class body: the
`gridutils.py` module header and the CLAUDE.md bullet still stated polar-cut
specifics as general truth. Generalised, but not over-generalised -- a corner
loses a cell at a cut that reaches a pole, not at "a grid cut or a pole", since
a bipolar fold alone loses none.

Per project policy, no source file references a merged PR or a closed issue
outside a TODO; the four such references introduced here are replaced by
descriptions of the behaviour or bug. (`test_section_fold.py` is left alone;
its references are handled elsewhere.)

Nits: define "fingerprint" and "seam twin" at first use, "bit-reversed" ->
"index-reversed", trim the ten-line grid-cut definition, and fix the leftovers
in `examples/load_example_ECCO_grid.py` from the twelve-files-to-one change --
including `load_ECCO_MOC_grid` claiming its transports are "time-averaged over
the twelve months of 2010" when `utr`/`vtr` keep a length-12 `time` dimension.

Full suite: 81 passed, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hdrake and others added 7 commits August 11, 2026 13:13
`_check_supported_topology` re-checked two things `xgcm.Grid.__init__` already
enforces: that every face named as a neighbour exists (an unknown one raises
KeyError) and that every connection is mutual (a one-sided one raises
TypeError). Both abort while xgcm builds its face-connection table, so no grid
reaching this function could ever fail them. Keeping them as a backstop only
guards against a `_face_connections` dict corrupted after construction, which
is the caller's problem, not something to carry code for.

Remove both branches, along with the `faces` label set that existed only to
serve the existence check and the `neighbor_axis` unpacking that existed only
to serve the mutuality check. What remains is the self-gluing rejection, which
is load bearing: xgcm accepts a face glued to itself, nothing further
downstream notices, and a section is then traced as though the seam were
absent.

The docstring now describes a function that enforces exactly one rule and
records why that one is sectionate's to catch while the others are not.

The test that pinned the face-label indexing bug would have passed vacuously
once the existence check was gone, so it is reframed rather than dropped: it
now asserts both that a legal `face = [1, 2]` grid passes and that the same
grid with face 2 glued to itself is still rejected and named as face 2. That
keeps a real regression guard on the surviving check comparing labels rather
than positions along the face dimension. The test asserting xgcm rejects the
malformed grids at construction is kept and its docstring updated -- that
upstream contract is now the entire reason not to check here.

Full suite: 81 passed, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Always take the shortest path between section waypoints
Removed outdated comments regarding self-glued grids and clarified the purpose of the section tracing function.
Review follow-up: wording, docstrings, ECCO record v3.0.0, generic topology check
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

face_connections padding returns PYTHONHASHSEED-dependent / incorrect halos (scalar, cubed-sphere)

2 participants