Corner identity from the grid's declared topology - #15
Draft
hdrake wants to merge 13 commits into
Draft
Conversation
One physical vorticity corner can be stored under several native indices, and sectionate currently rediscovers which ones from geographic coincidence, in five places with four different tolerances. That is fragile in a way that is easy to miss: two corners a grid genuinely distinguishes can sit at the same position (a bipolar cap's singular meridian stores one position for a whole column of corners), and a corner's stored position can simply be wrong. Every identification a structured grid can express is instead an affine integer map on the corner lattice, uniform along the seam it belongs to -- a periodic wrap, a fold's mirror, a `face_connections` gluing with its rotation. So take the identity of a corner to be what the metadata declares, and nothing else: the corner topology is the quotient of the lattice by the group those maps generate. `CornerTopology` builds that for any grid, single- or multi-tile, on the 'outer' lattice so every corner of every cell has a slot even where the native arrays drop a row. The fold's mirror is taken straight from xgcm's own fold padding, so a fold means the same thing here as it does when xgcm fills a halo across one. Seam maps across `face_connections` are *fitted* from tracer cells, which pad reliably however a seam is rotated, and then applied along the whole seam -- including its two end corners, where the cells needed to see the correspondence have run out. That extension is what identifies the corners where a seam runs into a wall or a pole. Identifications can also be declared directly, for topology `face_connections` cannot express: its schema maps a whole tile edge to a whole tile edge, so it cannot describe an edge glued to several partners over different index ranges -- which is the shape of a lat-lon-cap grid's southern boundary fold. Nothing consumes this yet; it is added alongside the existing machinery so the two can be compared before anything switches over. Measured against `_OuterTopology` on the cubed sphere and on ECCO LLC90, the new partition is a strict refinement: no corner is merged that the cell-identity construction kept apart, and the 179 nodes that differ on LLC90 are exactly the merges it had inferred from coordinates. It also resolves what the old one could not: a 2-cell-wide periodic axis (whose three corner columns share one pair of cells, so no cell-set test can separate them), and a walled row's periodic twins (a wall corner touches two cells, and two cells meet along an edge, not at a point). On the real MOM6 tripolar grid the node count matches the declaration exactly, and the build is 0.01s against 1.7s; LLC90 is 0.05s against 3.7s, and a quarter-degree global grid resolves in 1.5s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What the rest of the pipeline needs from a corner, beyond knowing which indices are the same one. A node takes the position of its canonical representation, so a corner reports the same position however it is spelled, and a distance measured along a section no longer depends on which side of a seam the walk happened to be on. A corner stored on no face -- a cubed sphere stores 6N^2 corners for 6N^2+2 points, and a 'left' staggering drops a whole row -- is placed at the centroid of the tracer cells meeting there, which is what the node is, so the estimate follows from the topology rather than from extrapolating a lattice. On the cube that lands the two un-stored vertices on their analytic positions, latitude arcsin(1/sqrt(3)). Where fewer than three cells survive there is nothing to average and the position is left unknown rather than guessed; on LLC90 that is 180 corners, all on the outer rows the staggering drops. Asking for the native index of a corner stored on no face raises. A sentinel is not available: -1 is a perfectly good numpy index, so it would not fail, it would select the wrong cell. `edge_velocities` reports each face in the frame it is stored in, together with the tracer cells its positive direction points from and to -- enough to attribute a transport across a rotated seam without rotating any vector. `validate_positions` closes the loop the other way: the coordinates now *check* the topology instead of setting it. Every representation of a corner should carry the same position, and where they do not the grid's metadata and its coordinates disagree. The fold fixture that declares a fold its plain lat/lon does not carry is resolved correctly all the same, and reported -- 1.27e7 m between two copies of one corner -- where before the disagreement silently steered identity itself. On the real tripolar grid the copies agree to 8e-10 m, and on LLC90 exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NOT GREEN: 1 of 118 tests fails, deliberately. See the end of this message. The walk now moves on physical corner nodes instead of native indices. A periodic wrap, a fold seam and a rotated tile boundary are all just edges, so the three rules -- enumerate neighbours, admit those that get closer, step to the one nearest the curve -- need no case for any of them and never ask whether two indices are the same corner. Gone with the index frame: the endpoint coincidence stop, the seam-twin admission, the right/left/up/down vocabulary, and `COINCIDENT_TOLERANCE_M` from section.py entirely. Corners that are distinct but coincident are crossed as a group. Greedy descent over a tripolar grid's polar fan has a flat metric across the whole column, so the route through it was settled by index order; now the walk picks where to *leave* the group from the neighbours outside it and crosses by the shortest chain. The old walk took six-plus zero-length steps up that column. Transports are attributed per node pair. Two spellings of one corner give `a == b`, an integer comparison, and emit no face -- which is how a seam crossing is written and why it is no longer counted twice. A pair that is not a grid edge raises instead of quietly losing a face. `uvindices_from_qindices` also reports `q`, the corner step each face came from, since faces and steps are no longer one to one. The transport sign is now read entirely in the frame the velocity is stored in: does the section's step run along `+i` or `+j`, and is this face's `+x` or `+y`? That is index arithmetic, so it is exact where the old cross product of two local displacements was degenerate -- on a polar fan both displacements are zero and it returned -1 arbitrarily, which matters because `is_mask_inside` takes the whole section's orientation from one face. The one thing an index frame cannot know is whether it is mirrored relative to the world, so that is measured once per face rather than per edge. Every transport oracle passes unchanged: the cube's streamfunction difference and divergence theorem, the rotated-seam and split-grid equivalences, LLC90, and the 'left' multi-tile mask orientation. Paths change where they were not minimal. A geodesic from (0,40N) to (120,40N) on a 2-degree grid now takes 81 corners rather than 83: 60 steps of longitude plus 10 up and 10 back down, one cell at a time, never diagonal and never doubling back, which is exactly the distance covered. The test now asserts that property instead of the recorded number. Neighbour maps are no longer used by anything and their tests are re-derived on the corner graph, which states the same properties more directly -- a seam shares its corners, a rotated seam maps them reversed, a section's consecutive corners are one step apart. **The known failure.** Deriving a seam's index map by fitting it to padded tracer cells inherits whatever the halo gives, and for a *reversed* gluing the halo does not carry the reversal -- the case the previous code refused outright as unreliable. The fit then folds a face onto itself, merging two cube vertices into one corner. The arithmetic is right (a reversed seam correctly derives K2 = n - K); the input is not. Rather than let that resolve to something quietly wrong, it is now detected: two corners of one face cannot be the same point unless the grid glues that face to itself, and saying so names the face and the corners. Both cube fixtures with reverse gluings now raise, and `test_outer_reverse_gluing_matches_by_coincidence` fails on that raise. Every grid without a reversed gluing -- LLC90, the 'left' cube, all single-tile grids -- is exact. The fix is to read the seam map from `face_connections` directly instead of fitting it to a halo, which is what the rest of this design already does and would also drop the tracer-centre requirement for multi-tile identity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes the failure the previous commit left in place. A `face_connections` gluing states which face, which of its axes, and -- through `reverse`, which says the two faces meet on the *same* side of their axes -- which end of it. Deriving the seam's index map by fitting it to padded tracer cells instead went through the halo, and the halo does not carry a declared reversal. The fitted map then folded a face onto itself: on a cubed sphere it merged two opposite vertices into one corner, which would have handed their velocity faces to the wrong place. The fourth thing a gluing needs -- whether the tangential index runs the same way along both edges -- does not have to be measured either. A grid is one oriented surface, so the map between two faces that meet is a rotation, not a reflection: crossing the seam turns one face's outward direction into the other's inward one, there is exactly one rotation that does that, and where it sends the tangential direction is then not a choice. A 90-degree gluing reverses the seam even though nothing declares a reversal, and a same-side gluing reverses it too; both fall out of the one rule rather than needing a case each. Both cubed-sphere fixtures now close on the polyhedron: 6N^2+2 corners with exactly 8 three-valent vertices, the synthetic one (which was merging two of them) and the geometric one alike. LLC90 and the real tripolar grid are unchanged, node for node. Two things follow. Corner identity no longer reads tracer cells at all, so a multi-tile grid without centre coordinates resolves exactly as one with them -- verified to give the same nodes either way. And the guard added last commit stays useful rather than becoming dead: two corners of one face are the same point only where the grid glues that face to itself, so if a future set of connections contradicts itself it is still named rather than resolved to something plausible. 118 passed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fold stores one physical velocity face twice, in mirrored frames and therefore with opposite sign -- the duplicated, sign-flipped row of an ORCA-style fold. The face lookup returned both copies and the direction of travel was worked out separately, so the two could come from different copies. A section that runs out and back along such a row then failed to cancel: measured on a pinched-cylinder fold, a path along the seam through the pivot came out at -0.86 and +2.31 times the answer instead of +-1. The velocity and the step through it now travel together, and the pair the section was actually written in is preferred, so each traversal is read in its own frame and an out-and-back cancels exactly. Two expectations move, both to something truer. The pinched-fold fixture is a *left-handed* chart: `+i` runs northward and `+j` eastward. Its streamfunction identity is stated in index space, while `convergent_transport` reports transport to the left of travel, which is a geographic side -- so on a mirrored chart the two differ by a sign. That was invisible while the sign came from a cross product of local displacements, which answers in geography directly, and it is the one fixture in the suite separating index handedness from the world's. Arbitrated independently: the boundary integral of a divergent field over one cell equals that cell's convergence computed straight from the staggered arrays, exactly, and for either traversal direction -- the discrete divergence theorem, which no sign convention of ours takes part in. The fold connectivity test asked about neighbour maps, which no longer exist; it now asks the question those maps existed to answer -- a declared fold makes the seam row the same line of corners as itself reversed, and a wall does not. New coverage. A section that zigs into the seam, runs along it and leaves down the mirrored side: the shape raised against repairing a corner list after the fact, and nothing covered it. Measured on the branch that repairs the list instead, these paths come out right, so it is pinned as a property rather than claimed as a reproduction. And a crossing of the degenerate polar column, where the metric is flat and the two routes over an exactly mirror-symmetric cap are genuinely tied: what must not depend on the direction of travel is the answer, so both routes are the same length and both carry the transport the streamfunction demands, every step one step. 124 passed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All of it is unreachable now that the walk moves on corner nodes: the four-direction pathfinder and its two entry points, the neighbour-map builder and its multi-tile fallback with the reciprocity check that guarded it, and in transports the direction lookup, the velocity anchor, the range test that told a source frame from a destination frame, and the geographic left-of-travel sign. Around 850 lines, and with them the third convention for what a corner is: the fallback kept each face's own copy of a shared corner where the outer-lattice path canonicalised it, so which one you were looking at depended on which branch had run. Their tests are re-derived on what the maps existed to answer. "A vertical axis must not change horizontal connectivity" is now asked of the topology rather than of the padding that built the maps. The removed low-level walk was covered by recorded path lengths, which are replaced by the counts the grid forces: an axis-aligned section on a one-degree grid takes one step per cell, so 80S to 80N is 161 corners and 179 degrees of longitude is 180. Two tests are dropped rather than ported, because what they pinned no longer exists: the walk terminating on a corner within a millimetre of its target, and the velocity-range check that distinguished the two frames. In their place, the seam contract stated directly: how many spellings a seam corner has is a property of the staggering -- an 'outer' grid stores the column twice, a 'right' grid once -- so what is asserted is that every step which is not a repeat is one real velocity face, that `q` skips exactly the repeats, and that consecutive corners really are one step apart on the grid. 120 passed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_OuterTopology` and `CornerTopology` answered the same question two ways, which was the thing this set out to remove. The outer-lattice class goes, and with it gridutils drops from 1204 lines to 216: the cell-fingerprint index and its 3-cell junction variant, the diagonal recovery for a halo that is a pad of a pad, the extrapolate-and-snap sweep with its two fractions and its ambiguous-band error, the union-find over four different merge keys, and the projection back down to native neighbour maps. The whole suite now runs in 10s rather than 25. `padded_transports` moves across and gets shorter, because the corner graph already knows what it needs: which physical edge a slot is, and which tracer cells the stored velocity points between. Matching by cell also fixes a case the old code needed padded halos for -- the face that stores a seam edge names only the cell on its own side, since the other lies on the face being filled, so the comparison matches whichever side is identifiable. An edge separates exactly two cells, so one is enough to fix the direction. Bit-identical to the old output on the cubed sphere, with cell convergence summing to 6e-17. Three tests change because the behaviour deliberately did. A corner stored on no face used to be given no edges at all, so it was a wall: sections could not pass through a cubed sphere's un-stored vertices, and a same-side gluing on a staggered grid -- which leaves a whole seam line unstored, because it meets along the edge the staggering drops -- was refused outright. Those corners are now resolved like any others and can be walked through; what they do not have is a native index, and asking for one says so. So the cube's eight vertices are all three-valent whether stored or not, and the same-side variant resolves to 6N^2+2 corners instead of raising. The LLC90 well-formedness test asked about the native neighbour maps, which no longer exist. It now asks the question those maps existed to answer, and a sharper one: a corner's edges and its cells count the same way round it -- a closed ring of k cells has k edges, an open fan against a boundary has one more. 120 passed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`grid_section` always returns `(i_c, j_c, f_c, lons_c, lats_c)`. A single-tile grid is one face, so `f_c` is zeros there rather than absent, and callers stop having to branch on the arity of a tuple to find out what kind of grid they were given -- which is what `regionate` currently keeps a normalising wrapper and seven `if f_c is not None` guards to do. `GriddedSection` gains `n_c`, the physical corner each of its points is. It is always derived from the grid and never stored: a node id is an artefact of how the topology was built, so a saved one could disagree with the grid it is loaded against. Deriving it is also what makes a section reloaded from indices, or built by hand, behave exactly like one this package traced -- there is no repair pass to skip. The save format follows: `f_c` unconditionally, `n_c` never. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rners From an independent review. Four things were wrong. **The fold fixture was inside out, and a constant was hiding it.** Its two semi-axes moved in opposite directions as `j` rose, so consecutive ellipses crossed and 16 of its 80 cells were mirror images of the rest. The handedness probe read cell (0,0) -- one of the 16 -- concluded the whole chart was left-handed, and I added a `MIRRORED_CHART = -1` to the tests to match. That made four tests assert the negative of the right answer, consistently, so they could not fail. Both semi-axes now shrink, the ellipses nest, every cell runs the same way round, and the factor is gone. The probe deserved the blame more than the fixture: it read one cell and never checked the rest agreed. It now measures every cell and raises if a face is not consistently oriented, so a grid of this shape says so instead of being quietly mis-signed. Measuring it on the sphere rather than in a local east/north frame matters too -- that frame degenerates at a pole, where one cell of an otherwise uniform face reported the opposite handedness. **Sections were refused where a route existed.** A corner stored on no face has no native index, so a section through one cannot be written down -- but the walk was picking such corners as readily as any other, and 2 of 100 random sections on the cube died at the projection step where the old code traced all 100. The walk now prefers corners the grid can name, and endpoints never snap to one. Nothing is lost geometrically: a cube vertex and a dropped row always have stored neighbours. 0 of 100 now fail. Where a section genuinely has no other way through -- a seam whose corners are stored on neither side -- it still raises, and the message now says which staggering causes it and what to do about it. **Orientation inverted for a closed section with a corner on a pole.** The signed area is measured on a stereographic projection, which sends one pole to infinity, so a corner sitting there projects to a radius that swamps the area. That is not an exotic case: it is a vertex of every cell of an Arctic cap. The configuration is now turned to put its corners away from the projection point first -- a rotation, so the orientation it measures is the same one. On the 'outer' cube 2 of 384 single-cell loops were inverted, and on LLC90 1 of 1035; both are now 0, and a test pins it against the divergence theorem, which knows nothing of projections. **Two tests had become tautologies.** Aliasing `outer_topology` to the new implementation left them comparing it with itself. The refinement check against the old implementation is deleted with its reason, and the LLC90 counts are pinned to the numbers the declared topology gives rather than to `> 0`. Also gone: `Usign`/`Vsign`, written into every result and read by nothing since `Lsign` became the only sign; and two helpers left behind by the changes above. 121 passed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs page and its animation still described the four-direction walk over neighbour maps, and every notebook unpacked four values from `grid_section`. `make_algorithm_animation.py` replays the walk to record what each step decided, so it had to move onto the corner graph too. Its fidelity assertion -- that the replay reproduces `grid_section` exactly -- still holds, and the traced path is unchanged: the same nine corners the page quotes. The panel changes, because two of the eight steps used to be "arrivals" that the endpoint short-circuit took without evaluating anything, and there is no short-circuit now; the walk stops when it reaches the end corner, which is an integer comparison. Direction labels survive only as display: the walk has no directions, so they are derived from where the picture moved. `algorithm.md` follows. "Enumerate the four neighbours" becomes "enumerate the corner's neighbours", since a cube vertex has three and a walled corner has three -- a wall is a *missing* neighbour now, not a corner that is its own. The seam-twin exception to the admission rule is gone rather than reworded: a corner a grid spells twice is one corner here, so stepping onto its twin is not a move the walk can make. "Why the neighbour maps carry the topology" becomes "why the corner graph carries the topology", and gets to say something truer -- the mesh drawn behind the animation *is* that graph, edge for edge. Notebook 4's low-level cell used two functions that no longer exist. It now shows what `grid_section` actually resolves first: 43621 corner slots on the tripolar grid resolving to 43321 physical corners, and the two identifications -- the periodic wrap and the fold -- that say so. Definition of Done: 121 tests, 0 skipped; all five notebooks re-executed cleanly; `make html SPHINXOPTS="-W"` clean; the animation regenerated. Reviewing the notebook diff, no figure and no numerical output changed -- notebook 5's overturning range is the same to the last digit -- only timestamps, the recorded version, and the cells edited above. Also from the review, as a strict xfail rather than a fix: a closed section with a corner exactly at a geographic pole gets `positive_in` inverted. It is pre-existing, and unreachable on the base branch only because such sections could not be traced at all. Fixing it needs a decision this change should not make -- measuring the area on the sphere is exact at the poles but degenerate for a section following a great circle, and choosing the projection point per section silently redefines which cap `positive_in` means. Both were tried; both moved results that had no business moving. The xfail carries the reproduction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LLC90's domain edge folds onto itself along the great circle through 65E and 115W under Antarctica, and `face_connections` declares those four tile edges as walls. Not an oversight in the archive: its schema maps a whole tile edge to a whole tile edge, and this edge is glued to several partners over different index ranges -- tile 0's runs into tile 3's, tile 9's and tile 12's in turn, and two of them fold onto themselves at the pivots. So one line of physical corners is stored twice with nothing in the metadata to say the two copies are the same point, and the previous code recovered it by matching coordinates. An identification is an affine map whether or not `face_connections` can hold it, so it can simply be handed in: `declare_identifications` attaches extra ones to a grid, and the ECCO loader declares the fold as five blocks of `k -> const - k`. That the blocks are right is checkable independently, and the numbers are exact. They merge 179 corner pairs, taking LLC90 from 105481 nodes to **105302** and its un-stored corners from 181 to **78** -- both precisely what the coordinate-matching implementation used to produce. And `validate_positions()` returns **0.0 m**: every pair the declaration merges is bit-identically coincident in the archived coordinates. The geometry agrees with the declaration without having been asked. The test now pins both readings of the grid -- what `face_connections` alone says, and what the fold adds -- so it is clear which is which. 120 passed, 1 xfailed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups. The two fixes from last round had no tests, which the reviewer rightly called the thing most likely to regress silently. `test_sections_route_around_corners_the_grid_cannot_name` uses waypoints found by fuzzing with the preference disabled -- 8 of 400 random sections on the cubed sphere route into an un-stored vertex without it -- so it fails when the fix is removed and passes with it. There are now also tests that a seam stored on neither face raises with a message naming the staggering and the way out, that most sections on such a grid still trace, and that a face with a mirrored cell is reported rather than signed backwards. Two docstrings claimed more than the code does. Preferring corners the grid can name is applied *before* the curve comparison, so it can discard a candidate that lay closer to the requested curve -- it now says so, with the measurement: the identity on every grid that stores all its corners, including LLC90; no path longer on a cubed sphere; 12 of 200 longer only where a whole seam line is un-stored, on a grid that could not be traced at all before. And endpoints no longer snap to un-stored corners, which shifts a waypoint by up to a cell on such a grid; that is user-visible and is now documented. The polar limitation reaches further than the xfail implied: **all four cells around a lat-lon-cap grid's Arctic pole** get an inverted `positive_in`, on the production grid rather than a synthetic one. Still not fixed here, for the reasons recorded last commit, but no longer silent -- a closed section with a corner on a pole warns, and points at passing `positive_in` as a cell mask, which is decided from the cells either side of a face and never touches the projection. Also: the stale comment block describing the deleted neighbour maps is gone (a previous pass left it truncated mid-sentence); `seam_halo` is deleted as dead -- no call sites, no coverage, and its sole helper with it; the `outer_topology` alias is retired from the tests, including a test *named* after a function that no longer exists; `_resolve_nodes` bounds-checks like `uvindices_from_qindices` already did; and `load_gridded_section`'s refusal of pre-`f_c` files is marked as deliberate so it does not get filed as a bug. CLAUDE.md regains the corner-axis-versus-`sect`-axis contract and `q`, which is what regionate consumes and which the rewrite had dropped, plus the polar limitation; and its artifact-size note now matches the artifacts. 123 passed, 1 xfailed, 0 skipped; docs build clean; animation regenerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The message quoted a position, and a corner stored on no face usually has too few tracer cells around it to place -- so the one thing it printed was the one thing unavailable: "near lon=nan, lat=nan". Reviewing the downstream port, that message was what made a missing declaration look like a snapping bug for an hour. It now names the corner slot, which is always known, and says plainly when the grid does not determine the position either. 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 |
hdrake
marked this pull request as draft
August 12, 2026 14:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Targets
topology-driven-neighbors(the branch behind MOM6-community#47), notmaster.Supersedes #11, #13 and #14, which answered the same question three ways. It carries #14's
qcontract and its fold fixture forward; the mechanism underneath is different.The problem
One physical vorticity corner can be stored under several native
(face, j, i)indices. MOM6-community#47 re-derives which ones in five separate places, with four different tolerances, and mostly from geographic coincidence:section.infer_grid_path1e-3mtransports.uvindices_from_qindices(multi-tile)_OuterTopology.node_id— exacttransports._insert_seam_twins(single-tile)1e-3mtransports.uvindices_from_qindices(both)regionate.boundaries(single-tile)round(xyz, 9)hash bucket, ~6.4 mmOnly one was exact. Each new grid shape got absorbed by adding a special case to whichever of the five it hit.
Geographic coincidence is the wrong instrument. On the MOM6 tripolar example grid, 260 corner points share a stored position, one of them 82 times — the bipolar pole, at exactly 0.0 m separation. Those are genuinely distinct corners. Conversely a grid's stored coordinates can simply be wrong.
What this does
Identity is the quotient of the corner lattice by the affine integer maps the grid declares — a periodic axis'
(J, I) ≡ (J, I + Nxc), a fold's seam row glued to its own mirror (taken from xgcm's own_seam_partner_indices, so a fold means the same thing here as when xgcm pads across one), eachface_connectionsgluing with its rotation and reversal. Labelled withscipy.sparse.csgraph.connected_components.A section then becomes a sequence of physical corner nodes; native indices are a projection used for reporting and for indexing velocity arrays. The walk moves on that graph, so a periodic wrap, a bipolar fold and a rotated tile seam are all just edges: no direction vocabulary, no wrap-around arithmetic, and no test for whether two indices are the same corner. Faces are emitted per node pair, so "these two steps are the same corner" is an integer comparison rather than a distance.
New:
sectionate/topology.py,sectionate/walk.py. Gone:_OuterTopology,build_neighbor_maps,_multitile_padded_maps,_insert_seam_twins, the four-direction walk, and every coordinate tolerance in the identity path.gridutils.pygoes 1397 → 216 lines; the suite runs in 10 s rather than 29.Geometry keeps two jobs it is good at — the walk's distance metric, and
validate_positions, which checks the topology and never sets it.Bugs this fixes
reverse. On a cubed sphere that merged two opposite vertices into one corner. Both cube fixtures now close on the polyhedron:6N² + 2corners with exactly 8 three-valent vertices.lons_cdisagreed with the emittedi_cat a seam, which also broke the longitude continuityregionate's antimeridian logic depends on.Things that now work
6N²corners for6N² + 2points — are real corners the walk can cross. They have no native index and asking for one raises rather than returning a sentinel (-1is a valid numpy index). Sections route around them where a stored alternative exists.face_connectionscannot express can be declared. Its schema maps a whole tile edge to a whole tile edge, so it cannot describe LLC90's southern boundary, which folds onto itself across four tile edges. Declaring it takes LLC90 from 105481 nodes to 105302 and un-stored corners from 181 to 78 — exactly what the coordinate matching used to produce — andvalidate_positions()returns 0.0 m, so the archived coordinates confirm the declaration without having decided it.Performance
Identity is O(seam) and labelling O(cells), both vectorized.
API
No backwards compatibility, by agreement.
grid_sectionalways returns(i_c, j_c, f_c, lons_c, lats_c)— a single-tile grid is one face, sof_cis zeros.GriddedSectiongainsn_c, the physical corner each point is, always derived and never persisted (a node id is an artefact of construction order, so a stored one could disagree with the grid it is loaded against).Known limitation
A closed section with a corner exactly on a geographic pole gets
positive_ininverted — it affects the four cells around a lat-lon-cap grid's Arctic pole. It is pre-existing, and unreachable ontopology-driven-neighborsonly because such sections could not be traced at all. It is not fixed here, because both repairs move things that should not move: measuring the area on the sphere is exact at the poles but degenerate for a section following a great circle (an equatorial latitude circle encloses exactly half the sphere either way round), and choosing the projection point per section silently redefines which cappositive_inmeans — it flipped notebook 5's tropical overturning. Both were tried and reverted. It is a strict xfail carrying the reproduction, andconvergent_transportnow warns.Verification
'left'multi-tile mask orientation.make html SPHINXOPTS="-W"clean; the algorithm animation regenerated, its replay still matchinggrid_sectionexactly.Tests
No assertion was carried over on the grounds that it currently passes. Recorded numbers were replaced by the invariant they stood in for wherever one exists — an axis-aligned section on a regular grid takes one step per cell; a cubed sphere has
6N² + 2corners; a corner's edges and its cells count the same way round it. New coverage: a section that zigs into a fold seam, runs along it and leaves down the mirrored side; a crossing of the degenerate polar column; identity under a 2° coordinate jitter; a 2-cell-wide periodic axis; a walled row's periodic twins; and a performance gate.AI-assisted PR, disclosed per the project AI Usage Policy: commits carry
Co-Authored-By:trailers.