Skip to content

Accept either seam representation, and map velocity faces back to corner steps - #14

Draft
hdrake wants to merge 7 commits into
topology-driven-neighborsfrom
seam-corner-identity
Draft

Accept either seam representation, and map velocity faces back to corner steps#14
hdrake wants to merge 7 commits into
topology-driven-neighborsfrom
seam-corner-identity

Conversation

@hdrake

@hdrake hdrake commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Targets topology-driven-neighbors (the branch behind MOM6-community#47), not master.

Supersedes #13 and #11, which answered the same question in opposite directions and could not both be merged. This carries #13's work forward in full and takes nothing from #11 — see Why #11 is not folded in below.

The question

A seam gives one physical vorticity corner more than one index: the duplicated column of a symmetric ('outer') periodic grid, a corner shared by two tiles, the mirrored columns of a bipolar fold seam. So a section crossing a seam can be written two ways — seam-explicit, carrying both indices with a step between them that spans no cell, or twin-free, carrying one. Both describe the same path.

#13 normalised toward the seam-explicit form and dropped the zero-length faces. #11 normalised toward the twin-free form inside grid_section and made uvindices_from_qindices raise on anything else. They conflict directly: #11 deletes the drop #13 depends on.

This PR settles it as a contract instead of a representation:

  • Input is liberal — either form is accepted and yields the same faces.
  • Output is strict — every returned face is a real velocity face.
  • q relates the two — since faces and corner steps are not one-to-one.

The bug this fixes

_insert_seam_twins tested index adjacency before physical coincidence. The two indices of a fold-seam corner sit far apart in the index lattice — column i against column nx-i — so a pair that was already the zero-length twin edge looked like a step needing repair, found no twin to splice, and raised.

Sections traced on a cell mask arrive in exactly that form. regionate.boundaries keeps both coincident corners at a seam junction and closes its loops with a coincident edge, on purpose, because zero-length edges emit no velocity face. Its own comment says so. Reloading a section from saved indices does the same.

Measured, running regionate's suite against this branch's parent:

2 failed, 18 passed     # test_fold_regions, test_periodic_regions, test_multitile_regions
ValueError: Section steps between corners (j=4, i=2) and (j=4, i=4), which are neither
adjacent in index space nor separated by a seam twin...

On that 7-column fold row the identity is i ↔ 6−i, so (4,2)→(4,4) and (4,5)→(4,1) are both coincident pairs — the two frame hand-offs of a region joined to its mirror through the fold. Testing coincidence first passes them to the drop, which is where they were always meant to go. With the fix regionate is 48 passed, 10 skipped, including its discrete-divergence-theorem test on a fold-straddling region.

q: which corner step each face came from

Because a step at a seam carries no face, len(sect) ≠ len(i_c) − 1 and nothing said which face came from where. uvindices_from_qindices now reports q, the index into the caller's corner arrays that each face starts at:

corner m:   0        1        2        3         4        5        6        7
 (j,i):   (1,1)    (1,0)    (0,0)   (0,16)    (0,15)   (0,14)   (0,13)   (1,13)
                                 ^^^^^^^^^  same physical point → no face

face  k:     0        1                 2         3        4        5
q[k]:        0        1                 3         4        5        6

8 corners → 6 faces, q = [0,1,3,4,5,6]; the gap at m=2 is the seam hand-off. q is strictly increasing, face k spans corners q[k] and q[k]+1, and the steps it skips are exactly the ones that carried no flux. convergent_transport and extract_tracer carry it as a coordinate on the same sect axis, so a result can be put back onto the section's corners — to plot against them, to slice out the part belonging to one stretch, or to locate a child section inside its parent (which regionate.BoundedRegion currently does by matching lon/lat floats).

Note the two axes are different lengths on purpose. lons_c/lats_c are corner coordinates, 1:1 with i_c/j_c including any repeat; the lon/lat on sect are velocity-point coordinates. What is dropped is the face between a repeated pair, never the corner.

On multi-tile grids the last index of a run of repeated corners is now the one kept. Every index of a corner rewrites to the same canonical native one, so no emitted corner changes; it only decides which step q attributes a face to, and the face leaving a run is the real one.

Carried from #13

The whole of it: _insert_seam_twins, the _pinched_fold_grid streamfunction fixture, and its tests. Recapping what that fixed, since it is the numerically significant part — crossing a fold seam, the walk steps from one index representation straight to a corner adjacent to the other. The single-tile arithmetic reads the velocity column off the source corner, so it named the mirror image of the face actually crossed, with the opposite sign, and on a closed crossing emitted one face twice. On the real MOM6 tripolar grid a closed 80°N latitude circle whose true transport is zero returned 1.26e-02.

Why #11 is not folded in

Each item re-audited after merging topology-driven-neighbors, which already carries #9 and #10:

  • Terminology and error messages — already on this branch via Review follow-up: wording, docstrings, ECCO record v3.0.0, generic topology check #9.
  • create_section_composite(grid=) — its own docstring says it exists so the output satisfies "the invariant... which transports.uvindices_from_qindices refuses". This PR does not add that refusal, so the parameter fixes nothing.
  • Removing _multitile_padded_maps / _validate_reciprocitynot dead code here. build_neighbor_maps reaches _multitile_padded_maps for multi-tile grids without tracer-center coordinates, and two_face_x_to_x in test_neighbor_maps_x_to_x_seam is exactly that shape (verified by instrumenting the call). Removing it would delete a supported, tested path.
  • drop_repeated_corners / _collapse_single_tile_runs / _single_tile_face — a public API with no internal consumer once the precondition it served is gone.

Tests

New test_seam_representations.py:

  • the seam-explicit and twin-free writings of one section give identical faces — the property both of regionate's boundary back-ends depend on, and which nothing pinned before;
  • a mask-traced loop straddling the fold is accepted, and names each of its six faces exactly once (the two cells' shared seam face is interior, and correctly absent);
  • that loop's net convergence vanishes to 1e-12 under the exactly non-divergent streamfunction — the divergence theorem across the seam;
  • the q contract on periodic-seam, mask-traced, seam-free, and multi-tile sections.

Verification

  • Full suite: 102 passed, 0 skipped.
  • regionate's suite against this branch: 48 passed, 10 skipped (was 2 failed). Worth wiring into CI — it is the only thing that catches this class of break.
  • All five notebooks re-executed. No figure and no numerical output changed — only the recorded version string and one line number inside a captured warning.
  • make html SPHINXOPTS="-W" clean; docs/make_algorithm_animation.py replay still matches grid_section (9 corners, 8 steps) and the committed artifacts are byte-identical.

Found while testing, not fixed here

_check_segment_span (added by #10, already merged into this base) rejects a meridional segment under curve="latitude circle", so regionate.GriddedRegion(..., curve="latitude circle") on an axis-aligned box raises. Confirmed against plain topology-driven-neighbors, so it predates this branch. The intended migration is the "latitude and great circle" mode #10 added, which the error message itself names — a regionate-side change, not a sectionate one.

Next

The end state agreed for this is one representation everywhere, via corner-node identity extended to single-tile grids: _OuterTopology fingerprints corners by their surrounding tracer cells, and a single tile's periodic/fold padding supplies those halos exactly as face_connections does for thirteen. That deletes _insert_seam_twins and the single-tile coordinate tolerance outright, and lets regionate collapse its two boundary back-ends into one. It changes corner counts in saved sections and notebooks, so it belongs after MOM6-community#47 lands, coordinated with regionate MOM6-community#22. This PR's contract is unchanged by it — only the mechanism beneath.


AI-assisted PR, disclosed per the project AI Usage Policy: commits carry Co-Authored-By: trailers. 🤖 Drafted with Claude Code.

hdrake and others added 7 commits August 11, 2026 11:18
A seam gives one physical corner two index representations. Crossing a
single-tile bipolar fold, the walk steps from one representation straight
to a corner adjacent to the *other*, so the pair is a real physical edge
but not an index-adjacent one -- and the single-tile velocity-face
arithmetic in `uvindices_from_qindices` assumes index adjacency. It read
the velocity column off the source corner, which is then the wrong
representative: it named the mirror image of the face the section actually
crossed, with the opposite sign, and on a closed crossing emitted one face
twice.

`_insert_seam_twins` splices the twin back in wherever a step skips it, so
the crossing becomes one ordinary edge plus one zero-length twin edge, and
zero-length edges already emit no velocity face. This is the single-tile
counterpart of the corner canonicalization `_OuterTopology` performs for
multi-tile grids, which is why LLC90 and the cubed-sphere were already
exact. It runs in the deterministic corner->face derivation, so sections
reloaded from saved (i_c, j_c) get it too.

The ORCA-style sign flip of the duplicated seam row needs no handling of
its own: the traversal direction and the stored velocity are read in the
same index frame, so the flip cancels. Sections running *along* the seam
were already exact and still are.

A declared fold whose corner coordinates do not carry the fold symmetry
has no twins to splice; such a crossing now raises instead of silently
attributing the edge to some other face.

Tests use a streamfunction fixture -- psi single-valued in physical space,
transports from index-space differences of it -- so the flow is exactly
non-divergent, the expected answer is analytic (psi(end) - psi(start)),
and the duplicated, sign-flipped seam row is reproduced without being
hard-coded. `_pinched_fold_grid` carries the corner-pivot fold identity
exactly and needs no downloaded data; the real MOM6 tripolar grid covers
traced sections. Without the fix the crossing tests fail by 1.3e-2 on a
latitude circle whose true answer is zero.

Reported by Geoffrey Stanley in MOM6-community#47.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No figure or numerical output changed -- none of the notebooks compute
transports across a single-tile fold seam -- only execution metadata and
the line number in a captured UserWarning traceback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page already explained that a consecutive pair resolving to the same
physical point emits no face. Add why twins matter for the converse: a
fold crossing can leave one index representation of a seam corner and land
next to the other, so the chain has to be resolved to index-adjacent steps
before faces are named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A seam gives one physical corner more than one index, so a section crossing it can
arrive written two ways: seam-explicit, carrying both indices, or twin-free, carrying
one. `_insert_seam_twins` tested index adjacency before physical coincidence, so a pair
that was *already* a zero-length twin edge -- the two indices of a fold-seam corner sit
far apart in the index lattice, column i against column nx-i -- was sent down the
twin-splicing path, found no twin to splice, and raised.

Sections traced on a cell mask arrive in exactly that form: `regionate.boundaries` keeps
both coincident corners at a seam junction and closes its loops with a coincident edge,
deliberately, because zero-length edges emit no velocity face. So did sections reloaded
from saved indices. Testing coincidence first passes them straight through to the drop,
which is where they were always meant to go.

Because a corner step at a seam carries no face, faces and corner steps are not
one-to-one, and nothing said which face came from which step. `uvindices_from_qindices`
now reports "q": the index into the caller's corner arrays that each face starts at, so
face k spans corners q[k] and q[k]+1. It is strictly increasing and skips exactly the
steps that carried no face. `convergent_transport` and `extract_tracer` carry it as a
coordinate, so a result can be put back onto the section's corners -- for plotting, for
slicing out a sub-section, or for locating a child section inside its parent.

On multi-tile grids the last index of a run of repeated corners is now the one kept.
Every index of a corner rewrites to the same canonical native one, so no emitted corner
changes; it only decides which step "q" attributes a face to, and the face leaving a run
is the real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md gains a "Seam representation contract" concept entry: input is liberal (either
seam representation is accepted, coincidence tested before adjacency), output is strict
(only real velocity faces), the corner and `sect` axes therefore differ in length on
purpose, and "q" is what relates them. It also spells out that `lons_c`/`lats_c` stay 1:1
with `i_c`/`j_c` -- a repeated corner is kept, and it is the face between the pair that is
dropped -- because the two are easy to confuse.

`algorithm.md` says the same thing where it converts corners into faces, and notes "q" in
the worked example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No figure and no numerical output changed -- none of the notebooks depends on how a seam
crossing is written, and the faces of every section they trace are the same ones. Only the
recorded version string and one line number inside a captured warning traceback differ.

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

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

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.

1 participant