Resolve corner identity across the bipolar fold seam - #13
Conversation
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>
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
|
Hey @hdrake, nice work fixing this so quick! Glad that the sign flip was handled correctly, but good to catch the double counting. (The double counting seems the more important thing, because once that is fixed the sign flip becomes irrelevant -- at least for the NEMO style grid in which only duplicated points carry a sign flip. I'm not sure if other grids behave differently.) If I understand
This will work IF the transect only takes one step to cross the seam, but that is not the only case. Consider for example a transect that zigs along In this case where there are multiple twin points, Have I got that right? If so, as I said I did it by running the standard GriddedSection algorithm twice, once forwards and once with the section reversed. I then merged the lists of indices using a function like def interleave_first_difference(A, B):
"""
Given two lists A and B, return a list that is A until the first index that A and B
equal each other after the first index that they differ from each other, followed by
B from the first index A and B differ from each other.
Parameters
----------
A, B : lists
Returns
-------
C : list
Example
-------
# A and B the same except for a chunk in the middle
A = [1, 2, 3, 4, 7, 8]
B = [1, 2, 5, 6, 7, 8]
interleave_first_difference(A, B)
# produces [1, 2, 3, 4, 5, 6, 7, 8]
# A and B completely different
A = [1, 2]
B = [3, 4]
interleave_first_difference(A, B)
# produces [1, 2, 3, 4]
# A and B identical
A = B = [1, 2]
interleave_first_difference(A, B)
# produces [1, 2]
"""
n = len(A)
if n != len(B):
# This function is intended for lists of the same length;
# it could work for lists of different lengths but it becomes more
# complicated to describe.
raise ValueError(f"A has length {n} but B has length {len(B)}")
i1 = 0
i2 = n
for i in range(n):
if A[i] != B[i]:
i1 = i
break
for i in range(i1, n):
if A[i] == B[i]:
i2 = i
break
return A[:i2] + B[i1:]It would be nice not to have to run the basic algorithm twice though. Instead, we could have a function A = list(zip(j_c, i_c))
B = list(zip(_swap_twins(j_c, i_c)))
C = interleave_first_difference(A, B)
j_c, i_c = zip(*C) |
|
@geoffstanley , that's a good point about the zigzagging sections and a corner case I hadn't considered. I'm going to take a step back and think about what the best path forward is for this mega-PR MOM6-community#47 These are the constraints that I am working with, and which are often in conflict with each other:
Everything seemed pretty straight-forward in my head until I ran up against the reality that the grid topology gets very messy with output from models like ECCOv4r4, which provide outputs on The code currently makes some different choices for whether or how these Any thoughts welcome! |
|
I can see the complexity! I have just been working with NEMO's T-pivot and F-pivot tripolar grid, and not in conjunction with xgcm, so it's been easier for me. On 1., does xgcm contain carry the By the way, I didn't quite understand this:
If Sectionate does indeed handle the sign flip properly, then perhaps Approach 2 can work, without On 2., would it help to bring Your description of the ECCOv4r4 grid sounds like xgcm is not capable of dealing with it completely. If that's so, then taking a step back, either Sectionate relies fully on xgcm for neighbours and it can't handle certain rare cases of the ECCOv4r4 grid (and perhaps others?), or Sectionate needs to do neighbours itself (or perhaps somehow in conjunction with xgcm, just patching as needed). It does seem like the right approach to upgrade xgcm to handle these cases, which you've been working on, but I'm not familiar enough with xgcm to know how feasible that is. |
Targets
topology-driven-neighbors(the branch behind MOM6-community#47), notmaster.What & why
@geoffstanley asked on #47 how sectionate handles the ORCA-style duplicated, sign-flipped velocity rows at a bipolar fold, and how it avoids double counting if its list of F-point indices contains no duplicates. Chasing that down turned up a real bug, which this PR fixes.
The two halves of the question have opposite answers.
The sign flip needs no fold-specific handling. Sectionate never compares an index-frame velocity against a geographic direction: on a single-tile grid it compares the stored velocity against the traversal direction in the same index frame, and on multi-tile grids it evaluates both the velocity's positive direction and the section's direction of travel geographically. Both inputs flip together, so a frame reversal cancels. Sections running along the duplicated seam row — including straight through the fold pivot — were already exact and still are.
Corner identity does need handling, and single-tile grids were missing it. A seam gives one physical corner two index representations. Crossing a fold, the walk steps from one of them straight to a corner adjacent to the other: a real physical edge, but not an index-adjacent one. The single-tile arithmetic in
uvindices_from_qindicesassumes index adjacency and reads 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 — exactly the double counting Geoff predicted, for the reason he gave.On the real MOM6 tripolar grid, a closed 80°N latitude circle whose true transport is zero returned
1.26e-02; a section over the pole was off by 1.8%. In both cases exactly one edge was wrong — the one leaving the seam. Entering it is fine, because there the source corner is the off-seam one and its column is the right representative.This is not a regression from MOM6-community#47.
master'stopology="MOM-tripolar"branch takes the same non-adjacent step across the seam (up = (j-1, (nx-1) - i%nx)) and feeds it to the sameXinc/Yincarithmetic. What MOM6-community#47 changed is where the connectivity comes from, not how the crossing edge is attributed.Changes
transports._insert_seam_twinssplices the seam twin back into the corner chain wherever a step skips it, so every remaining step is index-adjacent. The crossing becomes one ordinary edge plus one zero-length twin edge, and zero-length edges already emit no velocity face — so nothing is counted twice. It runs inside the deterministic corner→face derivation, which means sections reloaded from saved(i_c, j_c)get it too, and it is a no-op unless a step actually skips a twin.This is the single-tile counterpart of the corner canonicalization
_OuterTopologyalready performs for multi-tile grids, which is why LLC90 and the cubed-sphere were already exact. It is also the same construction Geoff proposed, and structurally what NEMO does when it carries the duplicated points and masks them out of transports withumaskutil/vmaskutil.A declared fold whose coordinates do not carry the fold symmetry now raises. With no twins to splice, the crossing edge is genuinely undefined; the error says so instead of silently attributing it elsewhere.
grid_sectionoutput is unchanged — the walk is untouched, anddocs/make_algorithm_animation.py's replay assertion still matches.Tests
All use a streamfunction fixture: ψ is a single-valued function of physical position, so seam twins get identical ψ, and transports are built from index-space differences of it. That makes the flow exactly non-divergent, makes the expected answer analytic (
ψ(end) − ψ(start)), and reproduces the duplicated, sign-flipped seam row of an ORCA-style fold (NEMO 4.2 manual, Appendix E) without hard-coding the flip — the fixture asserts that structure rather than assuming it._pinched_fold_grid— a new synthetic grid that carries the corner-pivot fold identity exactly (a cylinder pinched shut at the top, so the seam row is mirror-symmetric underi ↔ nxh−i), needing no downloaded data. Unlike the existing_fold_grid, whose plain lat/lon coordinates do not carry the fold its metadata declares.With the fix reverted, three of these fail — the real-grid one by exactly the
1.26e-02quoted above — so they genuinely guard the behaviour.Verification
PYTHONHASHSEED.make html SPHINXOPTS="-W"builds clean.docs/make_algorithm_animation.pyreplay still matchesgrid_section(9 corners, 8 steps); committed artifacts unchanged.Docs
CLAUDE.mdgains a Seam twins (corner identity) concept entry covering both the multi-tile and single-tile paths and why the sign flip needs no special handling.docs/source/algorithm.mdgains a paragraph on why the chain is resolved to index-adjacent steps before faces are named.AI-assisted PR, disclosed per the project AI Usage Policy: commits carry
Co-Authored-By:trailers. 🤖 Drafted with Claude Code.