diff --git a/CLAUDE.md b/CLAUDE.md index 3e8276b..744cf5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,7 +148,7 @@ The package is organized around a pipeline: define sections → map to grid → - **`section.py`** — Section definition and grid path algorithms. `Section` holds named waypoint coordinates; `GriddedSection` extends it with grid index information. `grid_section()` is the main entry point that maps geographic waypoints to grid vorticity-point indices `(i_c, j_c)` by walking the grid between waypoints along the `curve` requested (`"great circle"`, the default geodesic, or `"latitude circle"`); each segment must span less than 180°. The walk is deterministic and direction-independent: it admits neighbors strictly closer to the endpoint plus any seam twin of the current cell (so periodic/fold-seam crossings do not depend on floating-point rounding), and breaks ties by index. Grid topology is inferred entirely from `xgcm.Grid` metadata — each axis' `boundary` (periodic wrap, fill/extend wall, or a single-tile bipolar north fold `{"Y": {"fold": ...}}`) and `face_connections` for multi-tile grids — so there is no `topology` keyword. The pathfinder consumes topology-aware neighbor maps built by `gridutils.build_neighbor_maps`. -- **`transports.py`** — Transport computation along sections. `uvindices_from_qindices()` converts vorticity-point indices to U/V velocity-point indices using a per-position corner offset (`gridutils.corner_offset`) covering all three C-grid staggerings: 'outer', 'right', and 'left' (see "Corner staggering" below). `convergent_transport()` is the main function: it lazily computes signed normal transports with configurable orientation (positive inward to the polygon defined by the section). +- **`transports.py`** — Transport computation along sections. `uvindices_from_qindices()` converts vorticity-point indices to U/V velocity-point indices using a per-position corner offset (`gridutils.corner_offset`) covering all three C-grid staggerings: 'outer', 'right', and 'left' (see "Corner staggering" below). Before doing so it resolves **corner identity**, since a seam gives one physical corner two indices: multi-tile grids canonicalize each corner through `_OuterTopology`, and single-tile grids run `_insert_seam_twins()`, which splices the seam twin into the corner list wherever a step skips it (see "Seam twins" below). `convergent_transport()` is the main function: it lazily computes signed normal transports with configurable orientation (positive inward to the polygon defined by the section). - **`tracers.py`** — `extract_tracer()` interpolates tracer data to U/V points along a section path for cross-section plotting. @@ -160,6 +160,7 @@ The package is organized around a pipeline: define sections → map to grid → - **Vorticity points (q-points):** Sections are defined as paths through vorticity-point indices `(i_c, j_c)`. Consecutive q-points define velocity faces (either U or V). - **Corner staggering (three positions):** Vorticity sits at one of three xgcm positions: `"outer"` (MOM6 symmetric, M+1×N+1), `"right"` (MOM6 non-symmetric, M×N), or `"left"` (MITgcm/ECCO, incl. the lat-lon-cap, M×N). All three are native; they differ only by a per-position velocity index offset (`gridutils.corner_offset`: outer→0, right→+1, left→0). `"left"` indexes like `"outer"`; it differs only in array length and in that the *high* corner row/column is absent (so a section exactly on the north/east domain wall clips one row inside). +- **Seam twins (corner identity):** A seam gives one physical corner more than one index — the periodic wrap's first/last column, a shared multi-tile boundary corner, and the bipolar fold's mirrored seam-row columns. Transport attribution therefore has to know *which* indices denote the same point, or it will name the mirror image of the face a section crossed (with the sign that goes with it) and can count a face twice. Multi-tile grids get this from `_OuterTopology`, which resolves every corner to one canonical native `(face, j, i)`. Single-tile grids get it from `transports._insert_seam_twins()`: wherever consecutive corners are a physical edge but *not* index-adjacent — which is how the walk crosses a fold seam — it splices in the twin, so the crossing becomes one ordinary edge plus one zero-length twin edge, and zero-length edges emit no velocity face. This is the same construction as the `umaskutil`/`vmaskutil` masking of duplicated points in NEMO. The **sign flip** of an ORCA-style duplicated seam row (NEMO 4.2 manual, Appendix E) needs no handling of its own: the traversal direction and the stored velocity are read in the same index frame (geographically on both sides, for multi-tile), so a frame reversal cancels. A declared fold whose corner coordinates do not carry the fold symmetry has no twins to splice, and a crossing raises rather than silently mis-attributing. - **Sign conventions:** For a *closed* section, `convergent_transport()` determines orientation (clockwise/counterclockwise) using stereographic projection and signed polygon area, then applies sign corrections so positive transport means "inward" (toward the enclosed polygon). For an *open* section there is no enclosing polygon, so `positive_in` is undefined; it instead uses the **left-of-transect** convention — `positive_in=True` makes positive transport point to the left of the section as traversed from the first to the last waypoint — and emits a `UserWarning`. `is_section_counterclockwise()` is only consulted in the closed case. - **xgcm.Grid dependency:** The package relies heavily on `xgcm.Grid` for grid metadata (axis boundaries, coordinate positions, dataset access via `grid._ds`). diff --git a/docs/source/algorithm.md b/docs/source/algorithm.md index e82a811..1c64f86 100644 --- a/docs/source/algorithm.md +++ b/docs/source/algorithm.md @@ -239,6 +239,16 @@ staggering (`outer`, `right` or `left`) and is read from the grid rather than as "Up to" `N-1`, because a consecutive pair that resolves to the same physical point — the twin corners of a seam again — spans no cell and carries no flux, so it emits no face at all. +Twins matter here for a second reason. Naming the face between two corners assumes they are +neighbours *in index space*, and across a bipolar fold that can fail: the walk may leave one +index representation of a seam corner and land next to the *other*, a real physical edge whose +endpoints are far apart in `i`. Attributing that step from the source corner alone would name +the mirrored column's face, with the sign that goes with it. So before converting the chain, +`uvindices_from_qindices` resolves corner identity — multi-tile grids canonicalise every corner +through the outer-lattice topology, single-tile grids splice the skipped twin back into the +chain — after which every remaining step is an ordinary adjacent one, and the zero-length twin +edge it introduces drops out by the rule above rather than being counted twice. + `transports.convergent_transport` then accumulates the signed normal transport through those faces. For a **closed** section it works out the traversal orientation and signs everything so that positive means *into* the enclosed region; for an **open** section there is no inside, so diff --git a/examples/1_creating_an_OSNAP_section.ipynb b/examples/1_creating_an_OSNAP_section.ipynb index 7f58a59..82fcafb 100644 --- a/examples/1_creating_an_OSNAP_section.ipynb +++ b/examples/1_creating_an_OSNAP_section.ipynb @@ -23,10 +23,10 @@ "execution_count": 1, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:42.488689Z", - "iopub.status.busy": "2026-08-04T02:01:42.488449Z", - "iopub.status.idle": "2026-08-04T02:01:45.442158Z", - "shell.execute_reply": "2026-08-04T02:01:45.441763Z" + "iopub.execute_input": "2026-08-11T18:13:51.227132Z", + "iopub.status.busy": "2026-08-11T18:13:51.226904Z", + "iopub.status.idle": "2026-08-11T18:13:53.583670Z", + "shell.execute_reply": "2026-08-11T18:13:53.583387Z" } }, "outputs": [ @@ -34,7 +34,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Sectionate version: 0.4.0rc2.dev3\n" + "Sectionate version: 0.4.0rc3.dev0\n" ] } ], @@ -74,10 +74,10 @@ "execution_count": 2, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:45.456437Z", - "iopub.status.busy": "2026-08-04T02:01:45.456296Z", - "iopub.status.idle": "2026-08-04T02:01:45.489296Z", - "shell.execute_reply": "2026-08-04T02:01:45.488946Z" + "iopub.execute_input": "2026-08-11T18:13:53.605094Z", + "iopub.status.busy": "2026-08-11T18:13:53.604862Z", + "iopub.status.idle": "2026-08-11T18:13:53.695014Z", + "shell.execute_reply": "2026-08-11T18:13:53.694727Z" } }, "outputs": [ @@ -122,10 +122,10 @@ "execution_count": 3, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:45.490519Z", - "iopub.status.busy": "2026-08-04T02:01:45.490459Z", - "iopub.status.idle": "2026-08-04T02:01:45.492106Z", - "shell.execute_reply": "2026-08-04T02:01:45.491756Z" + "iopub.execute_input": "2026-08-11T18:13:53.696391Z", + "iopub.status.busy": "2026-08-11T18:13:53.696310Z", + "iopub.status.idle": "2026-08-11T18:13:53.697927Z", + "shell.execute_reply": "2026-08-11T18:13:53.697718Z" } }, "outputs": [], @@ -147,10 +147,10 @@ "execution_count": 4, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:45.493150Z", - "iopub.status.busy": "2026-08-04T02:01:45.493100Z", - "iopub.status.idle": "2026-08-04T02:01:45.875493Z", - "shell.execute_reply": "2026-08-04T02:01:45.874991Z" + "iopub.execute_input": "2026-08-11T18:13:53.699136Z", + "iopub.status.busy": "2026-08-11T18:13:53.699057Z", + "iopub.status.idle": "2026-08-11T18:13:54.292906Z", + "shell.execute_reply": "2026-08-11T18:13:54.292549Z" } }, "outputs": [], @@ -167,10 +167,10 @@ "execution_count": 5, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:45.876703Z", - "iopub.status.busy": "2026-08-04T02:01:45.876630Z", - "iopub.status.idle": "2026-08-04T02:01:46.650395Z", - "shell.execute_reply": "2026-08-04T02:01:46.649969Z" + "iopub.execute_input": "2026-08-11T18:13:54.294387Z", + "iopub.status.busy": "2026-08-11T18:13:54.294305Z", + "iopub.status.idle": "2026-08-11T18:13:55.137695Z", + "shell.execute_reply": "2026-08-11T18:13:55.137360Z" } }, "outputs": [ @@ -218,10 +218,10 @@ "execution_count": 6, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:46.651743Z", - "iopub.status.busy": "2026-08-04T02:01:46.651652Z", - "iopub.status.idle": "2026-08-04T02:01:46.673811Z", - "shell.execute_reply": "2026-08-04T02:01:46.673484Z" + "iopub.execute_input": "2026-08-11T18:13:55.140166Z", + "iopub.status.busy": "2026-08-11T18:13:55.140060Z", + "iopub.status.idle": "2026-08-11T18:13:55.166293Z", + "shell.execute_reply": "2026-08-11T18:13:55.165848Z" } }, "outputs": [], @@ -249,10 +249,10 @@ "execution_count": 7, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:46.674910Z", - "iopub.status.busy": "2026-08-04T02:01:46.674851Z", - "iopub.status.idle": "2026-08-04T02:01:46.808809Z", - "shell.execute_reply": "2026-08-04T02:01:46.808475Z" + "iopub.execute_input": "2026-08-11T18:13:55.168440Z", + "iopub.status.busy": "2026-08-11T18:13:55.168321Z", + "iopub.status.idle": "2026-08-11T18:13:55.383254Z", + "shell.execute_reply": "2026-08-11T18:13:55.382975Z" } }, "outputs": [ @@ -307,10 +307,10 @@ "execution_count": 8, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:46.810059Z", - "iopub.status.busy": "2026-08-04T02:01:46.809995Z", - "iopub.status.idle": "2026-08-04T02:01:46.912776Z", - "shell.execute_reply": "2026-08-04T02:01:46.912484Z" + "iopub.execute_input": "2026-08-11T18:13:55.384829Z", + "iopub.status.busy": "2026-08-11T18:13:55.384705Z", + "iopub.status.idle": "2026-08-11T18:13:55.571638Z", + "shell.execute_reply": "2026-08-11T18:13:55.570890Z" } }, "outputs": [ @@ -318,7 +318,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/hfdrake/miniforge3/envs/docs_env_sectionate_fix-layer-interface-matching/lib/python3.14/site-packages/xgcm/grid.py:463: UserWarning: The north-fold (tripolar) boundary condition is experimental. Its API and numerical behavior may change in future releases, and it has not yet been validated across the full range of grid configurations. Please review results carefully and report any issues at https://github.com/xgcm/xgcm/issues.\n", + "/opt/anaconda3/envs/docs_env_sectionate_fix-fold-crossing-transports/lib/python3.14t/site-packages/xgcm/grid.py:463: UserWarning: The north-fold (tripolar) boundary condition is experimental. Its API and numerical behavior may change in future releases, and it has not yet been validated across the full range of grid configurations. Please review results carefully and report any issues at https://github.com/xgcm/xgcm/issues.\n", " warnings.warn(\n" ] } @@ -343,10 +343,10 @@ "execution_count": 9, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:46.913869Z", - "iopub.status.busy": "2026-08-04T02:01:46.913814Z", - "iopub.status.idle": "2026-08-04T02:01:46.915373Z", - "shell.execute_reply": "2026-08-04T02:01:46.915068Z" + "iopub.execute_input": "2026-08-11T18:13:55.575078Z", + "iopub.status.busy": "2026-08-11T18:13:55.574948Z", + "iopub.status.idle": "2026-08-11T18:13:55.577084Z", + "shell.execute_reply": "2026-08-11T18:13:55.576764Z" } }, "outputs": [], @@ -370,10 +370,10 @@ "execution_count": 10, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:46.916215Z", - "iopub.status.busy": "2026-08-04T02:01:46.916166Z", - "iopub.status.idle": "2026-08-04T02:01:46.932327Z", - "shell.execute_reply": "2026-08-04T02:01:46.931936Z" + "iopub.execute_input": "2026-08-11T18:13:55.578526Z", + "iopub.status.busy": "2026-08-11T18:13:55.578406Z", + "iopub.status.idle": "2026-08-11T18:13:55.615644Z", + "shell.execute_reply": "2026-08-11T18:13:55.615073Z" } }, "outputs": [], @@ -390,10 +390,10 @@ "execution_count": 11, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:46.933351Z", - "iopub.status.busy": "2026-08-04T02:01:46.933294Z", - "iopub.status.idle": "2026-08-04T02:01:47.029043Z", - "shell.execute_reply": "2026-08-04T02:01:47.028616Z" + "iopub.execute_input": "2026-08-11T18:13:55.619166Z", + "iopub.status.busy": "2026-08-11T18:13:55.618896Z", + "iopub.status.idle": "2026-08-11T18:13:55.875675Z", + "shell.execute_reply": "2026-08-11T18:13:55.875273Z" } }, "outputs": [ @@ -433,10 +433,10 @@ "execution_count": 12, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.030083Z", - "iopub.status.busy": "2026-08-04T02:01:47.030000Z", - "iopub.status.idle": "2026-08-04T02:01:47.045217Z", - "shell.execute_reply": "2026-08-04T02:01:47.044885Z" + "iopub.execute_input": "2026-08-11T18:13:55.877087Z", + "iopub.status.busy": "2026-08-11T18:13:55.876976Z", + "iopub.status.idle": "2026-08-11T18:13:55.906195Z", + "shell.execute_reply": "2026-08-11T18:13:55.905737Z" } }, "outputs": [], @@ -453,10 +453,10 @@ "execution_count": 13, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.046152Z", - "iopub.status.busy": "2026-08-04T02:01:47.046094Z", - "iopub.status.idle": "2026-08-04T02:01:47.111056Z", - "shell.execute_reply": "2026-08-04T02:01:47.110790Z" + "iopub.execute_input": "2026-08-11T18:13:55.910245Z", + "iopub.status.busy": "2026-08-11T18:13:55.909992Z", + "iopub.status.idle": "2026-08-11T18:13:56.021033Z", + "shell.execute_reply": "2026-08-11T18:13:56.020748Z" } }, "outputs": [ @@ -529,10 +529,10 @@ "execution_count": 14, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.112255Z", - "iopub.status.busy": "2026-08-04T02:01:47.112186Z", - "iopub.status.idle": "2026-08-04T02:01:47.114192Z", - "shell.execute_reply": "2026-08-04T02:01:47.113925Z" + "iopub.execute_input": "2026-08-11T18:13:56.022672Z", + "iopub.status.busy": "2026-08-11T18:13:56.022573Z", + "iopub.status.idle": "2026-08-11T18:13:56.025055Z", + "shell.execute_reply": "2026-08-11T18:13:56.024801Z" } }, "outputs": [ @@ -593,10 +593,10 @@ "execution_count": 15, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.115147Z", - "iopub.status.busy": "2026-08-04T02:01:47.115093Z", - "iopub.status.idle": "2026-08-04T02:01:47.117491Z", - "shell.execute_reply": "2026-08-04T02:01:47.117191Z" + "iopub.execute_input": "2026-08-11T18:13:56.026353Z", + "iopub.status.busy": "2026-08-11T18:13:56.026253Z", + "iopub.status.idle": "2026-08-11T18:13:56.030299Z", + "shell.execute_reply": "2026-08-11T18:13:56.030053Z" } }, "outputs": [ @@ -644,10 +644,10 @@ "execution_count": 16, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.118350Z", - "iopub.status.busy": "2026-08-04T02:01:47.118300Z", - "iopub.status.idle": "2026-08-04T02:01:47.120829Z", - "shell.execute_reply": "2026-08-04T02:01:47.120449Z" + "iopub.execute_input": "2026-08-11T18:13:56.031550Z", + "iopub.status.busy": "2026-08-11T18:13:56.031475Z", + "iopub.status.idle": "2026-08-11T18:13:56.034202Z", + "shell.execute_reply": "2026-08-11T18:13:56.033847Z" } }, "outputs": [ @@ -702,10 +702,10 @@ "execution_count": 17, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.121682Z", - "iopub.status.busy": "2026-08-04T02:01:47.121634Z", - "iopub.status.idle": "2026-08-04T02:01:47.123385Z", - "shell.execute_reply": "2026-08-04T02:01:47.123069Z" + "iopub.execute_input": "2026-08-11T18:13:56.035749Z", + "iopub.status.busy": "2026-08-11T18:13:56.035645Z", + "iopub.status.idle": "2026-08-11T18:13:56.037505Z", + "shell.execute_reply": "2026-08-11T18:13:56.037259Z" } }, "outputs": [ @@ -745,10 +745,10 @@ "execution_count": 18, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.124215Z", - "iopub.status.busy": "2026-08-04T02:01:47.124167Z", - "iopub.status.idle": "2026-08-04T02:01:47.162616Z", - "shell.execute_reply": "2026-08-04T02:01:47.162264Z" + "iopub.execute_input": "2026-08-11T18:13:56.038815Z", + "iopub.status.busy": "2026-08-11T18:13:56.038727Z", + "iopub.status.idle": "2026-08-11T18:13:56.106945Z", + "shell.execute_reply": "2026-08-11T18:13:56.105751Z" } }, "outputs": [], @@ -765,10 +765,10 @@ "execution_count": 19, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.163631Z", - "iopub.status.busy": "2026-08-04T02:01:47.163580Z", - "iopub.status.idle": "2026-08-04T02:01:47.255905Z", - "shell.execute_reply": "2026-08-04T02:01:47.255590Z" + "iopub.execute_input": "2026-08-11T18:13:56.109208Z", + "iopub.status.busy": "2026-08-11T18:13:56.109074Z", + "iopub.status.idle": "2026-08-11T18:13:56.257673Z", + "shell.execute_reply": "2026-08-11T18:13:56.257292Z" } }, "outputs": [ @@ -817,10 +817,10 @@ "execution_count": 20, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.256958Z", - "iopub.status.busy": "2026-08-04T02:01:47.256900Z", - "iopub.status.idle": "2026-08-04T02:01:47.296309Z", - "shell.execute_reply": "2026-08-04T02:01:47.295955Z" + "iopub.execute_input": "2026-08-11T18:13:56.259429Z", + "iopub.status.busy": "2026-08-11T18:13:56.259319Z", + "iopub.status.idle": "2026-08-11T18:13:56.313586Z", + "shell.execute_reply": "2026-08-11T18:13:56.313274Z" } }, "outputs": [ @@ -868,10 +868,10 @@ "execution_count": 21, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.297356Z", - "iopub.status.busy": "2026-08-04T02:01:47.297301Z", - "iopub.status.idle": "2026-08-04T02:01:47.299807Z", - "shell.execute_reply": "2026-08-04T02:01:47.299484Z" + "iopub.execute_input": "2026-08-11T18:13:56.314870Z", + "iopub.status.busy": "2026-08-11T18:13:56.314780Z", + "iopub.status.idle": "2026-08-11T18:13:56.319651Z", + "shell.execute_reply": "2026-08-11T18:13:56.319388Z" } }, "outputs": [], @@ -895,10 +895,10 @@ "execution_count": 22, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:47.300821Z", - "iopub.status.busy": "2026-08-04T02:01:47.300769Z", - "iopub.status.idle": "2026-08-04T02:01:47.393286Z", - "shell.execute_reply": "2026-08-04T02:01:47.392961Z" + "iopub.execute_input": "2026-08-11T18:13:56.320980Z", + "iopub.status.busy": "2026-08-11T18:13:56.320909Z", + "iopub.status.idle": "2026-08-11T18:13:56.450492Z", + "shell.execute_reply": "2026-08-11T18:13:56.450206Z" } }, "outputs": [ diff --git a/examples/2_OSNAP_transports_CM4p25.ipynb b/examples/2_OSNAP_transports_CM4p25.ipynb index a1e02c6..59ab903 100644 --- a/examples/2_OSNAP_transports_CM4p25.ipynb +++ b/examples/2_OSNAP_transports_CM4p25.ipynb @@ -21,10 +21,10 @@ "execution_count": 1, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:48.388420Z", - "iopub.status.busy": "2026-08-04T02:01:48.388248Z", - "iopub.status.idle": "2026-08-04T02:01:49.080762Z", - "shell.execute_reply": "2026-08-04T02:01:49.080434Z" + "iopub.execute_input": "2026-08-11T18:13:57.587869Z", + "iopub.status.busy": "2026-08-11T18:13:57.587724Z", + "iopub.status.idle": "2026-08-11T18:13:58.798097Z", + "shell.execute_reply": "2026-08-11T18:13:58.797780Z" } }, "outputs": [ @@ -32,7 +32,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Sectionate version: 0.4.0rc2.dev3\n" + "Sectionate version: 0.4.0rc3.dev0\n" ] } ], @@ -60,10 +60,10 @@ "execution_count": 2, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.082054Z", - "iopub.status.busy": "2026-08-04T02:01:49.081933Z", - "iopub.status.idle": "2026-08-04T02:01:49.208403Z", - "shell.execute_reply": "2026-08-04T02:01:49.207974Z" + "iopub.execute_input": "2026-08-11T18:13:58.799532Z", + "iopub.status.busy": "2026-08-11T18:13:58.799367Z", + "iopub.status.idle": "2026-08-11T18:13:59.000995Z", + "shell.execute_reply": "2026-08-11T18:13:59.000540Z" } }, "outputs": [ @@ -71,7 +71,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/hfdrake/miniforge3/envs/docs_env_sectionate_fix-layer-interface-matching/lib/python3.14/site-packages/xgcm/grid.py:463: UserWarning: The north-fold (tripolar) boundary condition is experimental. Its API and numerical behavior may change in future releases, and it has not yet been validated across the full range of grid configurations. Please review results carefully and report any issues at https://github.com/xgcm/xgcm/issues.\n", + "/opt/anaconda3/envs/docs_env_sectionate_fix-fold-crossing-transports/lib/python3.14t/site-packages/xgcm/grid.py:463: UserWarning: The north-fold (tripolar) boundary condition is experimental. Its API and numerical behavior may change in future releases, and it has not yet been validated across the full range of grid configurations. Please review results carefully and report any issues at https://github.com/xgcm/xgcm/issues.\n", " warnings.warn(\n" ] } @@ -94,10 +94,10 @@ "execution_count": 3, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.209584Z", - "iopub.status.busy": "2026-08-04T02:01:49.209516Z", - "iopub.status.idle": "2026-08-04T02:01:49.211241Z", - "shell.execute_reply": "2026-08-04T02:01:49.210950Z" + "iopub.execute_input": "2026-08-11T18:13:59.003387Z", + "iopub.status.busy": "2026-08-11T18:13:59.003078Z", + "iopub.status.idle": "2026-08-11T18:13:59.010771Z", + "shell.execute_reply": "2026-08-11T18:13:59.006555Z" } }, "outputs": [], @@ -124,10 +124,10 @@ "execution_count": 4, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.212231Z", - "iopub.status.busy": "2026-08-04T02:01:49.212180Z", - "iopub.status.idle": "2026-08-04T02:01:49.226750Z", - "shell.execute_reply": "2026-08-04T02:01:49.226317Z" + "iopub.execute_input": "2026-08-11T18:13:59.025892Z", + "iopub.status.busy": "2026-08-11T18:13:59.024237Z", + "iopub.status.idle": "2026-08-11T18:13:59.116734Z", + "shell.execute_reply": "2026-08-11T18:13:59.116391Z" } }, "outputs": [], @@ -144,10 +144,10 @@ "execution_count": 5, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.227864Z", - "iopub.status.busy": "2026-08-04T02:01:49.227804Z", - "iopub.status.idle": "2026-08-04T02:01:49.343097Z", - "shell.execute_reply": "2026-08-04T02:01:49.342741Z" + "iopub.execute_input": "2026-08-11T18:13:59.118993Z", + "iopub.status.busy": "2026-08-11T18:13:59.118861Z", + "iopub.status.idle": "2026-08-11T18:13:59.367867Z", + "shell.execute_reply": "2026-08-11T18:13:59.367497Z" }, "tags": [] }, @@ -195,10 +195,10 @@ "execution_count": 6, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.344132Z", - "iopub.status.busy": "2026-08-04T02:01:49.344069Z", - "iopub.status.idle": "2026-08-04T02:01:49.364171Z", - "shell.execute_reply": "2026-08-04T02:01:49.363778Z" + "iopub.execute_input": "2026-08-11T18:13:59.369696Z", + "iopub.status.busy": "2026-08-11T18:13:59.369596Z", + "iopub.status.idle": "2026-08-11T18:13:59.417103Z", + "shell.execute_reply": "2026-08-11T18:13:59.416861Z" } }, "outputs": [ @@ -206,7 +206,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/hfdrake/code/sectionate/sectionate/transports.py:565: UserWarning: This section is open (its first and last waypoints are far apart), so it does not enclose a polygon and the inward/outward sense of `positive_in` is undefined. Sectionate applies the left-of-transect convention instead: with `positive_in=True` positive transport points to the LEFT of the section as it is traversed from the first to the last waypoint (set `positive_in=False` to flip to right-of-transect). Verify the sign matches your expectations, e.g. by plotting the section's normal vectors.\n", + "/Users/hfdrake/code/sectionate/sectionate/transports.py:675: UserWarning: This section is open (its first and last waypoints are far apart), so it does not enclose a polygon and the inward/outward sense of `positive_in` is undefined. Sectionate applies the left-of-transect convention instead: with `positive_in=True` positive transport points to the LEFT of the section as it is traversed from the first to the last waypoint (set `positive_in=False` to flip to right-of-transect). Verify the sign matches your expectations, e.g. by plotting the section's normal vectors.\n", " warnings.warn(\n" ] } @@ -220,10 +220,10 @@ "execution_count": 7, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.365281Z", - "iopub.status.busy": "2026-08-04T02:01:49.365213Z", - "iopub.status.idle": "2026-08-04T02:01:49.446445Z", - "shell.execute_reply": "2026-08-04T02:01:49.446021Z" + "iopub.execute_input": "2026-08-11T18:13:59.418404Z", + "iopub.status.busy": "2026-08-11T18:13:59.418318Z", + "iopub.status.idle": "2026-08-11T18:13:59.538455Z", + "shell.execute_reply": "2026-08-11T18:13:59.538141Z" } }, "outputs": [ @@ -255,10 +255,10 @@ "execution_count": 8, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.447595Z", - "iopub.status.busy": "2026-08-04T02:01:49.447529Z", - "iopub.status.idle": "2026-08-04T02:01:49.522301Z", - "shell.execute_reply": "2026-08-04T02:01:49.521934Z" + "iopub.execute_input": "2026-08-11T18:13:59.540158Z", + "iopub.status.busy": "2026-08-11T18:13:59.540062Z", + "iopub.status.idle": "2026-08-11T18:13:59.689274Z", + "shell.execute_reply": "2026-08-11T18:13:59.688872Z" } }, "outputs": [ @@ -317,10 +317,10 @@ "execution_count": 9, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.523408Z", - "iopub.status.busy": "2026-08-04T02:01:49.523349Z", - "iopub.status.idle": "2026-08-04T02:01:49.524914Z", - "shell.execute_reply": "2026-08-04T02:01:49.524639Z" + "iopub.execute_input": "2026-08-11T18:13:59.690890Z", + "iopub.status.busy": "2026-08-11T18:13:59.690784Z", + "iopub.status.idle": "2026-08-11T18:13:59.692701Z", + "shell.execute_reply": "2026-08-11T18:13:59.692421Z" } }, "outputs": [], @@ -340,10 +340,10 @@ "execution_count": 10, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.525806Z", - "iopub.status.busy": "2026-08-04T02:01:49.525746Z", - "iopub.status.idle": "2026-08-04T02:01:49.540458Z", - "shell.execute_reply": "2026-08-04T02:01:49.540125Z" + "iopub.execute_input": "2026-08-11T18:13:59.694298Z", + "iopub.status.busy": "2026-08-11T18:13:59.694195Z", + "iopub.status.idle": "2026-08-11T18:13:59.719846Z", + "shell.execute_reply": "2026-08-11T18:13:59.719152Z" } }, "outputs": [], @@ -360,10 +360,10 @@ "execution_count": 11, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.541502Z", - "iopub.status.busy": "2026-08-04T02:01:49.541448Z", - "iopub.status.idle": "2026-08-04T02:01:49.631238Z", - "shell.execute_reply": "2026-08-04T02:01:49.630940Z" + "iopub.execute_input": "2026-08-11T18:13:59.722608Z", + "iopub.status.busy": "2026-08-11T18:13:59.722483Z", + "iopub.status.idle": "2026-08-11T18:13:59.887954Z", + "shell.execute_reply": "2026-08-11T18:13:59.887650Z" } }, "outputs": [ @@ -410,10 +410,10 @@ "execution_count": 12, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.632524Z", - "iopub.status.busy": "2026-08-04T02:01:49.632463Z", - "iopub.status.idle": "2026-08-04T02:01:49.670014Z", - "shell.execute_reply": "2026-08-04T02:01:49.669657Z" + "iopub.execute_input": "2026-08-11T18:13:59.889643Z", + "iopub.status.busy": "2026-08-11T18:13:59.889548Z", + "iopub.status.idle": "2026-08-11T18:13:59.933828Z", + "shell.execute_reply": "2026-08-11T18:13:59.933554Z" } }, "outputs": [ @@ -421,7 +421,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/hfdrake/code/sectionate/sectionate/transports.py:565: UserWarning: This section is open (its first and last waypoints are far apart), so it does not enclose a polygon and the inward/outward sense of `positive_in` is undefined. Sectionate applies the left-of-transect convention instead: with `positive_in=True` positive transport points to the LEFT of the section as it is traversed from the first to the last waypoint (set `positive_in=False` to flip to right-of-transect). Verify the sign matches your expectations, e.g. by plotting the section's normal vectors.\n", + "/Users/hfdrake/code/sectionate/sectionate/transports.py:675: UserWarning: This section is open (its first and last waypoints are far apart), so it does not enclose a polygon and the inward/outward sense of `positive_in` is undefined. Sectionate applies the left-of-transect convention instead: with `positive_in=True` positive transport points to the LEFT of the section as it is traversed from the first to the last waypoint (set `positive_in=False` to flip to right-of-transect). Verify the sign matches your expectations, e.g. by plotting the section's normal vectors.\n", " warnings.warn(\n" ] } @@ -435,10 +435,10 @@ "execution_count": 13, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.671028Z", - "iopub.status.busy": "2026-08-04T02:01:49.670972Z", - "iopub.status.idle": "2026-08-04T02:01:49.749043Z", - "shell.execute_reply": "2026-08-04T02:01:49.748684Z" + "iopub.execute_input": "2026-08-11T18:13:59.935275Z", + "iopub.status.busy": "2026-08-11T18:13:59.935180Z", + "iopub.status.idle": "2026-08-11T18:14:00.046029Z", + "shell.execute_reply": "2026-08-11T18:14:00.045755Z" } }, "outputs": [ @@ -470,10 +470,10 @@ "execution_count": 14, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.750155Z", - "iopub.status.busy": "2026-08-04T02:01:49.750092Z", - "iopub.status.idle": "2026-08-04T02:01:49.836438Z", - "shell.execute_reply": "2026-08-04T02:01:49.836017Z" + "iopub.execute_input": "2026-08-11T18:14:00.047501Z", + "iopub.status.busy": "2026-08-11T18:14:00.047391Z", + "iopub.status.idle": "2026-08-11T18:14:00.180084Z", + "shell.execute_reply": "2026-08-11T18:14:00.179615Z" } }, "outputs": [ @@ -530,10 +530,10 @@ "execution_count": 15, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.837429Z", - "iopub.status.busy": "2026-08-04T02:01:49.837368Z", - "iopub.status.idle": "2026-08-04T02:01:49.838781Z", - "shell.execute_reply": "2026-08-04T02:01:49.838537Z" + "iopub.execute_input": "2026-08-11T18:14:00.181807Z", + "iopub.status.busy": "2026-08-11T18:14:00.181697Z", + "iopub.status.idle": "2026-08-11T18:14:00.183489Z", + "shell.execute_reply": "2026-08-11T18:14:00.183141Z" } }, "outputs": [], @@ -553,10 +553,10 @@ "execution_count": 16, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.839688Z", - "iopub.status.busy": "2026-08-04T02:01:49.839633Z", - "iopub.status.idle": "2026-08-04T02:01:49.867487Z", - "shell.execute_reply": "2026-08-04T02:01:49.867055Z" + "iopub.execute_input": "2026-08-11T18:14:00.184714Z", + "iopub.status.busy": "2026-08-11T18:14:00.184639Z", + "iopub.status.idle": "2026-08-11T18:14:00.222897Z", + "shell.execute_reply": "2026-08-11T18:14:00.222555Z" } }, "outputs": [], @@ -573,10 +573,10 @@ "execution_count": 17, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.868502Z", - "iopub.status.busy": "2026-08-04T02:01:49.868449Z", - "iopub.status.idle": "2026-08-04T02:01:49.898245Z", - "shell.execute_reply": "2026-08-04T02:01:49.897927Z" + "iopub.execute_input": "2026-08-11T18:14:00.224889Z", + "iopub.status.busy": "2026-08-11T18:14:00.224779Z", + "iopub.status.idle": "2026-08-11T18:14:00.265665Z", + "shell.execute_reply": "2026-08-11T18:14:00.265387Z" } }, "outputs": [ @@ -584,7 +584,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/hfdrake/code/sectionate/sectionate/transports.py:565: UserWarning: This section is open (its first and last waypoints are far apart), so it does not enclose a polygon and the inward/outward sense of `positive_in` is undefined. Sectionate applies the left-of-transect convention instead: with `positive_in=True` positive transport points to the LEFT of the section as it is traversed from the first to the last waypoint (set `positive_in=False` to flip to right-of-transect). Verify the sign matches your expectations, e.g. by plotting the section's normal vectors.\n", + "/Users/hfdrake/code/sectionate/sectionate/transports.py:675: UserWarning: This section is open (its first and last waypoints are far apart), so it does not enclose a polygon and the inward/outward sense of `positive_in` is undefined. Sectionate applies the left-of-transect convention instead: with `positive_in=True` positive transport points to the LEFT of the section as it is traversed from the first to the last waypoint (set `positive_in=False` to flip to right-of-transect). Verify the sign matches your expectations, e.g. by plotting the section's normal vectors.\n", " warnings.warn(\n" ] } @@ -598,10 +598,10 @@ "execution_count": 18, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:49.899254Z", - "iopub.status.busy": "2026-08-04T02:01:49.899198Z", - "iopub.status.idle": "2026-08-04T02:01:50.002350Z", - "shell.execute_reply": "2026-08-04T02:01:50.001979Z" + "iopub.execute_input": "2026-08-11T18:14:00.266927Z", + "iopub.status.busy": "2026-08-11T18:14:00.266841Z", + "iopub.status.idle": "2026-08-11T18:14:00.427704Z", + "shell.execute_reply": "2026-08-11T18:14:00.427414Z" } }, "outputs": [ @@ -658,10 +658,10 @@ "execution_count": 19, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:50.003392Z", - "iopub.status.busy": "2026-08-04T02:01:50.003333Z", - "iopub.status.idle": "2026-08-04T02:01:50.004876Z", - "shell.execute_reply": "2026-08-04T02:01:50.004533Z" + "iopub.execute_input": "2026-08-11T18:14:00.429225Z", + "iopub.status.busy": "2026-08-11T18:14:00.429128Z", + "iopub.status.idle": "2026-08-11T18:14:00.430782Z", + "shell.execute_reply": "2026-08-11T18:14:00.430538Z" } }, "outputs": [], @@ -681,10 +681,10 @@ "execution_count": 20, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:50.005800Z", - "iopub.status.busy": "2026-08-04T02:01:50.005751Z", - "iopub.status.idle": "2026-08-04T02:01:50.008642Z", - "shell.execute_reply": "2026-08-04T02:01:50.008176Z" + "iopub.execute_input": "2026-08-11T18:14:00.432079Z", + "iopub.status.busy": "2026-08-11T18:14:00.432005Z", + "iopub.status.idle": "2026-08-11T18:14:00.435618Z", + "shell.execute_reply": "2026-08-11T18:14:00.435397Z" } }, "outputs": [], @@ -709,10 +709,10 @@ "execution_count": 21, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:50.009563Z", - "iopub.status.busy": "2026-08-04T02:01:50.009509Z", - "iopub.status.idle": "2026-08-04T02:01:50.084867Z", - "shell.execute_reply": "2026-08-04T02:01:50.084458Z" + "iopub.execute_input": "2026-08-11T18:14:00.436928Z", + "iopub.status.busy": "2026-08-11T18:14:00.436853Z", + "iopub.status.idle": "2026-08-11T18:14:00.542888Z", + "shell.execute_reply": "2026-08-11T18:14:00.542452Z" } }, "outputs": [ diff --git a/examples/3_Labrador_convergence_CM4p25.ipynb b/examples/3_Labrador_convergence_CM4p25.ipynb index 2c28820..dc00786 100644 --- a/examples/3_Labrador_convergence_CM4p25.ipynb +++ b/examples/3_Labrador_convergence_CM4p25.ipynb @@ -21,10 +21,10 @@ "execution_count": 1, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:50.972072Z", - "iopub.status.busy": "2026-08-04T02:01:50.971866Z", - "iopub.status.idle": "2026-08-04T02:01:51.710112Z", - "shell.execute_reply": "2026-08-04T02:01:51.709792Z" + "iopub.execute_input": "2026-08-11T18:14:02.030178Z", + "iopub.status.busy": "2026-08-11T18:14:02.029890Z", + "iopub.status.idle": "2026-08-11T18:14:03.371488Z", + "shell.execute_reply": "2026-08-11T18:14:03.371166Z" } }, "outputs": [ @@ -32,7 +32,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Sectionate version: 0.4.0rc2.dev3\n" + "Sectionate version: 0.4.0rc3.dev0\n" ] } ], @@ -60,10 +60,10 @@ "execution_count": 2, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:51.711277Z", - "iopub.status.busy": "2026-08-04T02:01:51.711166Z", - "iopub.status.idle": "2026-08-04T02:01:51.832845Z", - "shell.execute_reply": "2026-08-04T02:01:51.832479Z" + "iopub.execute_input": "2026-08-11T18:14:03.373236Z", + "iopub.status.busy": "2026-08-11T18:14:03.373032Z", + "iopub.status.idle": "2026-08-11T18:14:03.578535Z", + "shell.execute_reply": "2026-08-11T18:14:03.578228Z" } }, "outputs": [ @@ -71,7 +71,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/hfdrake/miniforge3/envs/docs_env_sectionate_fix-layer-interface-matching/lib/python3.14/site-packages/xgcm/grid.py:463: UserWarning: The north-fold (tripolar) boundary condition is experimental. Its API and numerical behavior may change in future releases, and it has not yet been validated across the full range of grid configurations. Please review results carefully and report any issues at https://github.com/xgcm/xgcm/issues.\n", + "/opt/anaconda3/envs/docs_env_sectionate_fix-fold-crossing-transports/lib/python3.14t/site-packages/xgcm/grid.py:463: UserWarning: The north-fold (tripolar) boundary condition is experimental. Its API and numerical behavior may change in future releases, and it has not yet been validated across the full range of grid configurations. Please review results carefully and report any issues at https://github.com/xgcm/xgcm/issues.\n", " warnings.warn(\n" ] } @@ -94,10 +94,10 @@ "execution_count": 3, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:51.833990Z", - "iopub.status.busy": "2026-08-04T02:01:51.833928Z", - "iopub.status.idle": "2026-08-04T02:01:51.835664Z", - "shell.execute_reply": "2026-08-04T02:01:51.835344Z" + "iopub.execute_input": "2026-08-11T18:14:03.580105Z", + "iopub.status.busy": "2026-08-11T18:14:03.580026Z", + "iopub.status.idle": "2026-08-11T18:14:03.581866Z", + "shell.execute_reply": "2026-08-11T18:14:03.581635Z" } }, "outputs": [], @@ -120,10 +120,10 @@ "execution_count": 4, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:51.836649Z", - "iopub.status.busy": "2026-08-04T02:01:51.836596Z", - "iopub.status.idle": "2026-08-04T02:01:51.864344Z", - "shell.execute_reply": "2026-08-04T02:01:51.864033Z" + "iopub.execute_input": "2026-08-11T18:14:03.583068Z", + "iopub.status.busy": "2026-08-11T18:14:03.583002Z", + "iopub.status.idle": "2026-08-11T18:14:03.620485Z", + "shell.execute_reply": "2026-08-11T18:14:03.620200Z" } }, "outputs": [], @@ -140,10 +140,10 @@ "execution_count": 5, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:51.865600Z", - "iopub.status.busy": "2026-08-04T02:01:51.865535Z", - "iopub.status.idle": "2026-08-04T02:01:51.980369Z", - "shell.execute_reply": "2026-08-04T02:01:51.979958Z" + "iopub.execute_input": "2026-08-11T18:14:03.621967Z", + "iopub.status.busy": "2026-08-11T18:14:03.621882Z", + "iopub.status.idle": "2026-08-11T18:14:03.775562Z", + "shell.execute_reply": "2026-08-11T18:14:03.775242Z" } }, "outputs": [ @@ -192,10 +192,10 @@ "execution_count": 6, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:51.981486Z", - "iopub.status.busy": "2026-08-04T02:01:51.981410Z", - "iopub.status.idle": "2026-08-04T02:01:52.020015Z", - "shell.execute_reply": "2026-08-04T02:01:52.019586Z" + "iopub.execute_input": "2026-08-11T18:14:03.776886Z", + "iopub.status.busy": "2026-08-11T18:14:03.776802Z", + "iopub.status.idle": "2026-08-11T18:14:03.824717Z", + "shell.execute_reply": "2026-08-11T18:14:03.824406Z" } }, "outputs": [], @@ -208,10 +208,10 @@ "execution_count": 7, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:52.021157Z", - "iopub.status.busy": "2026-08-04T02:01:52.021084Z", - "iopub.status.idle": "2026-08-04T02:01:52.062912Z", - "shell.execute_reply": "2026-08-04T02:01:52.062595Z" + "iopub.execute_input": "2026-08-11T18:14:03.826081Z", + "iopub.status.busy": "2026-08-11T18:14:03.825998Z", + "iopub.status.idle": "2026-08-11T18:14:03.877300Z", + "shell.execute_reply": "2026-08-11T18:14:03.877050Z" } }, "outputs": [], @@ -225,10 +225,10 @@ "execution_count": 8, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:52.064165Z", - "iopub.status.busy": "2026-08-04T02:01:52.064104Z", - "iopub.status.idle": "2026-08-04T02:01:52.141159Z", - "shell.execute_reply": "2026-08-04T02:01:52.140798Z" + "iopub.execute_input": "2026-08-11T18:14:03.878667Z", + "iopub.status.busy": "2026-08-11T18:14:03.878590Z", + "iopub.status.idle": "2026-08-11T18:14:03.981853Z", + "shell.execute_reply": "2026-08-11T18:14:03.981564Z" } }, "outputs": [ @@ -264,10 +264,10 @@ "execution_count": 9, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:52.142231Z", - "iopub.status.busy": "2026-08-04T02:01:52.142172Z", - "iopub.status.idle": "2026-08-04T02:01:52.278672Z", - "shell.execute_reply": "2026-08-04T02:01:52.278340Z" + "iopub.execute_input": "2026-08-11T18:14:03.983452Z", + "iopub.status.busy": "2026-08-11T18:14:03.983352Z", + "iopub.status.idle": "2026-08-11T18:14:04.195311Z", + "shell.execute_reply": "2026-08-11T18:14:04.194981Z" } }, "outputs": [ diff --git a/examples/4_sections_on_global_tripolar_grid.ipynb b/examples/4_sections_on_global_tripolar_grid.ipynb index a5b13f9..f4951ff 100644 --- a/examples/4_sections_on_global_tripolar_grid.ipynb +++ b/examples/4_sections_on_global_tripolar_grid.ipynb @@ -23,10 +23,10 @@ "execution_count": 1, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:53.171667Z", - "iopub.status.busy": "2026-08-04T02:01:53.171402Z", - "iopub.status.idle": "2026-08-04T02:01:55.635990Z", - "shell.execute_reply": "2026-08-04T02:01:55.635261Z" + "iopub.execute_input": "2026-08-11T18:14:05.328279Z", + "iopub.status.busy": "2026-08-11T18:14:05.327946Z", + "iopub.status.idle": "2026-08-11T18:14:11.213026Z", + "shell.execute_reply": "2026-08-11T18:14:11.212514Z" } }, "outputs": [ @@ -34,7 +34,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Sectionate version: 0.4.0rc2.dev3\n" + "Sectionate version: 0.4.0rc3.dev0\n" ] } ], @@ -66,10 +66,10 @@ "execution_count": 2, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:55.638398Z", - "iopub.status.busy": "2026-08-04T02:01:55.638098Z", - "iopub.status.idle": "2026-08-04T02:01:55.646099Z", - "shell.execute_reply": "2026-08-04T02:01:55.645540Z" + "iopub.execute_input": "2026-08-11T18:14:11.216200Z", + "iopub.status.busy": "2026-08-11T18:14:11.215700Z", + "iopub.status.idle": "2026-08-11T18:14:11.224542Z", + "shell.execute_reply": "2026-08-11T18:14:11.224169Z" } }, "outputs": [], @@ -143,10 +143,10 @@ "execution_count": 3, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:55.647677Z", - "iopub.status.busy": "2026-08-04T02:01:55.647564Z", - "iopub.status.idle": "2026-08-04T02:01:55.794379Z", - "shell.execute_reply": "2026-08-04T02:01:55.793983Z" + "iopub.execute_input": "2026-08-11T18:14:11.226445Z", + "iopub.status.busy": "2026-08-11T18:14:11.226314Z", + "iopub.status.idle": "2026-08-11T18:14:11.501873Z", + "shell.execute_reply": "2026-08-11T18:14:11.501499Z" } }, "outputs": [ @@ -154,7 +154,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/hfdrake/miniforge3/envs/docs_env_sectionate_fix-layer-interface-matching/lib/python3.14/site-packages/xgcm/grid.py:463: UserWarning: The north-fold (tripolar) boundary condition is experimental. Its API and numerical behavior may change in future releases, and it has not yet been validated across the full range of grid configurations. Please review results carefully and report any issues at https://github.com/xgcm/xgcm/issues.\n", + "/opt/anaconda3/envs/docs_env_sectionate_fix-fold-crossing-transports/lib/python3.14t/site-packages/xgcm/grid.py:463: UserWarning: The north-fold (tripolar) boundary condition is experimental. Its API and numerical behavior may change in future releases, and it has not yet been validated across the full range of grid configurations. Please review results carefully and report any issues at https://github.com/xgcm/xgcm/issues.\n", " warnings.warn(\n" ] } @@ -177,10 +177,10 @@ "execution_count": 4, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:55.795569Z", - "iopub.status.busy": "2026-08-04T02:01:55.795503Z", - "iopub.status.idle": "2026-08-04T02:01:55.798005Z", - "shell.execute_reply": "2026-08-04T02:01:55.797632Z" + "iopub.execute_input": "2026-08-11T18:14:11.504562Z", + "iopub.status.busy": "2026-08-11T18:14:11.504444Z", + "iopub.status.idle": "2026-08-11T18:14:11.507370Z", + "shell.execute_reply": "2026-08-11T18:14:11.507123Z" } }, "outputs": [], @@ -203,10 +203,10 @@ "execution_count": 5, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:55.799062Z", - "iopub.status.busy": "2026-08-04T02:01:55.799000Z", - "iopub.status.idle": "2026-08-04T02:01:56.913038Z", - "shell.execute_reply": "2026-08-04T02:01:56.912614Z" + "iopub.execute_input": "2026-08-11T18:14:11.508658Z", + "iopub.status.busy": "2026-08-11T18:14:11.508563Z", + "iopub.status.idle": "2026-08-11T18:14:13.875563Z", + "shell.execute_reply": "2026-08-11T18:14:13.875186Z" } }, "outputs": [ @@ -251,10 +251,10 @@ "execution_count": 6, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:56.915401Z", - "iopub.status.busy": "2026-08-04T02:01:56.915332Z", - "iopub.status.idle": "2026-08-04T02:01:57.818895Z", - "shell.execute_reply": "2026-08-04T02:01:57.818522Z" + "iopub.execute_input": "2026-08-11T18:14:13.887283Z", + "iopub.status.busy": "2026-08-11T18:14:13.887134Z", + "iopub.status.idle": "2026-08-11T18:14:15.277081Z", + "shell.execute_reply": "2026-08-11T18:14:15.276780Z" } }, "outputs": [ @@ -305,10 +305,10 @@ "execution_count": 7, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:57.821173Z", - "iopub.status.busy": "2026-08-04T02:01:57.821093Z", - "iopub.status.idle": "2026-08-04T02:01:57.822690Z", - "shell.execute_reply": "2026-08-04T02:01:57.822323Z" + "iopub.execute_input": "2026-08-11T18:14:15.280014Z", + "iopub.status.busy": "2026-08-11T18:14:15.279914Z", + "iopub.status.idle": "2026-08-11T18:14:15.281502Z", + "shell.execute_reply": "2026-08-11T18:14:15.281272Z" } }, "outputs": [], @@ -328,10 +328,10 @@ "execution_count": 8, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:57.823644Z", - "iopub.status.busy": "2026-08-04T02:01:57.823593Z", - "iopub.status.idle": "2026-08-04T02:01:58.205365Z", - "shell.execute_reply": "2026-08-04T02:01:58.204922Z" + "iopub.execute_input": "2026-08-11T18:14:15.282785Z", + "iopub.status.busy": "2026-08-11T18:14:15.282706Z", + "iopub.status.idle": "2026-08-11T18:14:15.835722Z", + "shell.execute_reply": "2026-08-11T18:14:15.835436Z" } }, "outputs": [ @@ -370,10 +370,10 @@ "execution_count": 9, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:58.207583Z", - "iopub.status.busy": "2026-08-04T02:01:58.207497Z", - "iopub.status.idle": "2026-08-04T02:01:58.606342Z", - "shell.execute_reply": "2026-08-04T02:01:58.605892Z" + "iopub.execute_input": "2026-08-11T18:14:15.838920Z", + "iopub.status.busy": "2026-08-11T18:14:15.838812Z", + "iopub.status.idle": "2026-08-11T18:14:16.431123Z", + "shell.execute_reply": "2026-08-11T18:14:16.430836Z" } }, "outputs": [ @@ -412,10 +412,10 @@ "execution_count": 10, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:58.608545Z", - "iopub.status.busy": "2026-08-04T02:01:58.608465Z", - "iopub.status.idle": "2026-08-04T02:01:59.012843Z", - "shell.execute_reply": "2026-08-04T02:01:59.012464Z" + "iopub.execute_input": "2026-08-11T18:14:16.434992Z", + "iopub.status.busy": "2026-08-11T18:14:16.434863Z", + "iopub.status.idle": "2026-08-11T18:14:17.049175Z", + "shell.execute_reply": "2026-08-11T18:14:17.048897Z" } }, "outputs": [ @@ -456,10 +456,10 @@ "execution_count": 11, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:59.015039Z", - "iopub.status.busy": "2026-08-04T02:01:59.014959Z", - "iopub.status.idle": "2026-08-04T02:01:59.577602Z", - "shell.execute_reply": "2026-08-04T02:01:59.577160Z" + "iopub.execute_input": "2026-08-11T18:14:17.052447Z", + "iopub.status.busy": "2026-08-11T18:14:17.052328Z", + "iopub.status.idle": "2026-08-11T18:14:17.924450Z", + "shell.execute_reply": "2026-08-11T18:14:17.924143Z" } }, "outputs": [ @@ -499,10 +499,10 @@ "execution_count": 12, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:59.579759Z", - "iopub.status.busy": "2026-08-04T02:01:59.579676Z", - "iopub.status.idle": "2026-08-04T02:01:59.972989Z", - "shell.execute_reply": "2026-08-04T02:01:59.972545Z" + "iopub.execute_input": "2026-08-11T18:14:17.928263Z", + "iopub.status.busy": "2026-08-11T18:14:17.928157Z", + "iopub.status.idle": "2026-08-11T18:14:18.534270Z", + "shell.execute_reply": "2026-08-11T18:14:18.533881Z" } }, "outputs": [ @@ -543,10 +543,10 @@ "execution_count": 13, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:01:59.975278Z", - "iopub.status.busy": "2026-08-04T02:01:59.975201Z", - "iopub.status.idle": "2026-08-04T02:02:00.688345Z", - "shell.execute_reply": "2026-08-04T02:02:00.687998Z" + "iopub.execute_input": "2026-08-11T18:14:18.537678Z", + "iopub.status.busy": "2026-08-11T18:14:18.537567Z", + "iopub.status.idle": "2026-08-11T18:14:19.576560Z", + "shell.execute_reply": "2026-08-11T18:14:19.576109Z" } }, "outputs": [ @@ -588,10 +588,10 @@ "execution_count": 14, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:02:00.690842Z", - "iopub.status.busy": "2026-08-04T02:02:00.690767Z", - "iopub.status.idle": "2026-08-04T02:02:01.046170Z", - "shell.execute_reply": "2026-08-04T02:02:01.045845Z" + "iopub.execute_input": "2026-08-11T18:14:19.580042Z", + "iopub.status.busy": "2026-08-11T18:14:19.579935Z", + "iopub.status.idle": "2026-08-11T18:14:20.114869Z", + "shell.execute_reply": "2026-08-11T18:14:20.114549Z" } }, "outputs": [ @@ -623,10 +623,10 @@ "execution_count": 15, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:02:01.048283Z", - "iopub.status.busy": "2026-08-04T02:02:01.048205Z", - "iopub.status.idle": "2026-08-04T02:02:01.418272Z", - "shell.execute_reply": "2026-08-04T02:02:01.417864Z" + "iopub.execute_input": "2026-08-11T18:14:20.118061Z", + "iopub.status.busy": "2026-08-11T18:14:20.117954Z", + "iopub.status.idle": "2026-08-11T18:14:20.677296Z", + "shell.execute_reply": "2026-08-11T18:14:20.676998Z" } }, "outputs": [ @@ -658,10 +658,10 @@ "execution_count": 16, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:02:01.420269Z", - "iopub.status.busy": "2026-08-04T02:02:01.420194Z", - "iopub.status.idle": "2026-08-04T02:02:01.811776Z", - "shell.execute_reply": "2026-08-04T02:02:01.811376Z" + "iopub.execute_input": "2026-08-11T18:14:20.680056Z", + "iopub.status.busy": "2026-08-11T18:14:20.679965Z", + "iopub.status.idle": "2026-08-11T18:14:21.252533Z", + "shell.execute_reply": "2026-08-11T18:14:21.252243Z" } }, "outputs": [ @@ -701,10 +701,10 @@ "execution_count": 17, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:02:01.813687Z", - "iopub.status.busy": "2026-08-04T02:02:01.813606Z", - "iopub.status.idle": "2026-08-04T02:02:02.168679Z", - "shell.execute_reply": "2026-08-04T02:02:02.168282Z" + "iopub.execute_input": "2026-08-11T18:14:21.256016Z", + "iopub.status.busy": "2026-08-11T18:14:21.255916Z", + "iopub.status.idle": "2026-08-11T18:14:21.776661Z", + "shell.execute_reply": "2026-08-11T18:14:21.776121Z" } }, "outputs": [ diff --git a/examples/5_MOC_transports_ECCOv4r4.ipynb b/examples/5_MOC_transports_ECCOv4r4.ipynb index 37f91c3..07cb5c9 100644 --- a/examples/5_MOC_transports_ECCOv4r4.ipynb +++ b/examples/5_MOC_transports_ECCOv4r4.ipynb @@ -33,10 +33,10 @@ "execution_count": 1, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:09:56.567055Z", - "iopub.status.busy": "2026-08-04T02:09:56.566859Z", - "iopub.status.idle": "2026-08-04T02:09:57.455411Z", - "shell.execute_reply": "2026-08-04T02:09:57.455061Z" + "iopub.execute_input": "2026-08-11T18:14:23.119158Z", + "iopub.status.busy": "2026-08-11T18:14:23.119018Z", + "iopub.status.idle": "2026-08-11T18:14:24.429117Z", + "shell.execute_reply": "2026-08-11T18:14:24.428846Z" } }, "outputs": [ @@ -44,7 +44,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Sectionate version: 0.4.0rc2.dev3\n" + "Sectionate version: 0.4.0rc3.dev0\n" ] } ], @@ -77,10 +77,10 @@ "execution_count": 2, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:09:57.469675Z", - "iopub.status.busy": "2026-08-04T02:09:57.469513Z", - "iopub.status.idle": "2026-08-04T02:09:58.262430Z", - "shell.execute_reply": "2026-08-04T02:09:58.262018Z" + "iopub.execute_input": "2026-08-11T18:14:24.430947Z", + "iopub.status.busy": "2026-08-11T18:14:24.430771Z", + "iopub.status.idle": "2026-08-11T18:14:25.946242Z", + "shell.execute_reply": "2026-08-11T18:14:25.945903Z" } }, "outputs": [ @@ -129,10 +129,10 @@ "execution_count": 3, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:09:58.263547Z", - "iopub.status.busy": "2026-08-04T02:09:58.263491Z", - "iopub.status.idle": "2026-08-04T02:09:58.265337Z", - "shell.execute_reply": "2026-08-04T02:09:58.265038Z" + "iopub.execute_input": "2026-08-11T18:14:25.948020Z", + "iopub.status.busy": "2026-08-11T18:14:25.947899Z", + "iopub.status.idle": "2026-08-11T18:14:25.950114Z", + "shell.execute_reply": "2026-08-11T18:14:25.949875Z" } }, "outputs": [], @@ -172,10 +172,10 @@ "execution_count": 4, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:09:58.266314Z", - "iopub.status.busy": "2026-08-04T02:09:58.266260Z", - "iopub.status.idle": "2026-08-04T02:10:02.515464Z", - "shell.execute_reply": "2026-08-04T02:10:02.514970Z" + "iopub.execute_input": "2026-08-11T18:14:25.951526Z", + "iopub.status.busy": "2026-08-11T18:14:25.951441Z", + "iopub.status.idle": "2026-08-11T18:14:31.857574Z", + "shell.execute_reply": "2026-08-11T18:14:31.857288Z" } }, "outputs": [ @@ -245,10 +245,10 @@ "execution_count": 5, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:10:02.518605Z", - "iopub.status.busy": "2026-08-04T02:10:02.518471Z", - "iopub.status.idle": "2026-08-04T02:10:17.559173Z", - "shell.execute_reply": "2026-08-04T02:10:17.558828Z" + "iopub.execute_input": "2026-08-11T18:14:31.865521Z", + "iopub.status.busy": "2026-08-11T18:14:31.865326Z", + "iopub.status.idle": "2026-08-11T18:14:49.067400Z", + "shell.execute_reply": "2026-08-11T18:14:49.067029Z" } }, "outputs": [ @@ -293,10 +293,10 @@ "execution_count": 6, "metadata": { "execution": { - "iopub.execute_input": "2026-08-04T02:10:17.560375Z", - "iopub.status.busy": "2026-08-04T02:10:17.560315Z", - "iopub.status.idle": "2026-08-04T02:10:17.727353Z", - "shell.execute_reply": "2026-08-04T02:10:17.726950Z" + "iopub.execute_input": "2026-08-11T18:14:49.070440Z", + "iopub.status.busy": "2026-08-11T18:14:49.070335Z", + "iopub.status.idle": "2026-08-11T18:14:49.306386Z", + "shell.execute_reply": "2026-08-11T18:14:49.306112Z" } }, "outputs": [ diff --git a/sectionate/tests/test_section_fold.py b/sectionate/tests/test_section_fold.py index a6f681d..b46d9a7 100644 --- a/sectionate/tests/test_section_fold.py +++ b/sectionate/tests/test_section_fold.py @@ -5,6 +5,7 @@ seam connectivity from xgcm's padding with no fold-specific code of its own. """ import os +import warnings import numpy as np import pytest @@ -12,7 +13,8 @@ import xgcm from sectionate.gridutils import get_geo_corners, build_neighbor_maps -from sectionate.section import grid_section +from sectionate.section import grid_section, distance_on_unit_sphere +from sectionate.transports import convergent_transport, uvindices_from_qindices def _fold_grid(nx=8, ny=5, pivot="corner"): @@ -111,3 +113,216 @@ def test_fold_section_crosses_arctic_on_real_grid(): assert np.all(np.abs(np.asarray(lats_c) - 80.0) < 1.0) # It reaches the northern fold row. assert j_c.max() == ds.yq.size - 1 + + +# --------------------------------------------------------------------------- +# Transports across the fold seam. +# +# The seam row identifies each corner with its mirrored column, so the same +# physical corner carries two indices. Crossing the seam, the walk steps from one +# of them to a corner adjacent to the *other*, which is a physical edge but not an +# index-adjacent one. The tests below pin down that such a crossing names the +# velocity face the section actually crossed, with the right sign, exactly once. +# +# They use a streamfunction fixture: psi is a single-valued function of *physical* +# position, so seam twins get identical psi, and transports are built from +# index-space differences of psi. That makes the flow exactly non-divergent and the +# answer for any section known analytically -- transport = psi(end) - psi(start) -- +# while automatically reproducing the duplicated, sign-flipped seam row of an +# ORCA-style fold (NEMO 4.2 manual, Appendix E) without hard-coding the flip. +# --------------------------------------------------------------------------- + + +def _pinched_fold_grid(nxh=16, ny=6): + """ + A single-tile C-grid that genuinely carries the corner-pivot fold identity. + + Picture a cylinder (X-periodic) whose cross-section is pinched shut as `j` rises: + at row `j` the corners sit on an ellipse with semi-axes ``(a_j, b_j)``, and + ``b_{ny-1} = 0``, so the top row collapses onto a segment traversed out and back. + Under ``i <-> nxh - i`` that row is therefore *exactly* mirror-symmetric, which is + the corner-pivot fold identity, with its two fold poles at ``i = 0`` and + ``i = nxh/2``. ``a_j`` grows with `j` so the ellipses nest and every corner below + the seam stays distinct. + + Unlike `_fold_grid`, whose plain lat/lon coordinates do not carry the fold its + metadata declares, this one is geometrically consistent with the fold, so + transports across the seam are well defined. + """ + i_q, j_q = np.arange(nxh + 1), np.arange(ny) + i_h, j_h = np.arange(nxh) + 0.5, np.arange(ny - 1) + 0.5 + + def lonlat(jj, ii): + theta = 2.0 * np.pi * np.asarray(ii) / nxh + t = np.asarray(jj) / (ny - 1) + x = (1.0 + 0.5 * t) * np.cos(theta) + y = (1.0 - t) * np.sin(theta) + return 25.0 * x, 45.0 + 18.0 * y + + lon_c, lat_c = lonlat(*np.meshgrid(j_q, i_q, indexing="ij")) + lon_h, lat_h = lonlat(*np.meshgrid(j_h, i_h, indexing="ij")) + + ds = xr.Dataset(coords={ + "xq": i_q, "yq": j_q, "xh": i_h, "yh": j_h, + "geolon_c": (("yq", "xq"), lon_c), "geolat_c": (("yq", "xq"), lat_c), + "geolon": (("yh", "xh"), lon_h), "geolat": (("yh", "xh"), lat_h), + }) + return xgcm.Grid( + ds, + coords={"X": {"center": "xh", "outer": "xq"}, "Y": {"center": "yh", "outer": "yq"}}, + padding={"X": "periodic", "Y": {"fold": "corner"}}, + autoparse_metadata=False, + ) + + +def _streamfunction(lon, lat): + """A degree-1 spherical harmonic: smooth everywhere (poles included) and + single-valued in physical space, so seam twins receive identical values.""" + lo, la = np.deg2rad(lon), np.deg2rad(lat) + return (0.3 * np.cos(la) * np.cos(lo) + - 0.8 * np.cos(la) * np.sin(lo) + + 0.5 * np.sin(la)) + + +def _add_streamfunction_transports(grid, utr="utest", vtr="vtest"): + """Attach exactly non-divergent transports derived from `_streamfunction`. + + Returns the corner-array streamfunction and a grid carrying the transports. + `umo[j,i] = psi[j,i] - psi[j+1,i]` and `vmo[j,i] = psi[j,i+1] - psi[j,i]` make + the transport across any section telescope to `psi(end) - psi(start)`. + """ + geo = get_geo_corners(grid) + psi = _streamfunction(np.asarray(geo["X"].values), np.asarray(geo["Y"].values)) + ydim, xdim = geo["X"].dims[-2], geo["X"].dims[-1] + ycenter = grid.axes["Y"].coords["center"] + xcenter = grid.axes["X"].coords["center"] + + ds = grid._ds.copy() + ds[utr] = xr.DataArray(psi[:-1, :] - psi[1:, :], dims=(ycenter, xdim)) + ds[vtr] = xr.DataArray(psi[:, 1:] - psi[:, :-1], dims=(ydim, xcenter)) + regridded = xgcm.Grid( + ds, + coords={ax: dict(grid.axes[ax].coords) for ax in ("X", "Y")}, + padding={ax: grid.axes[ax].padding for ax in ("X", "Y")}, + autoparse_metadata=False, + ) + return psi, regridded + + +def _section_transport(grid, i_c, j_c, utr="utest", vtr="vtest"): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # open sections warn about `positive_in` + out = convergent_transport(grid, i_c, j_c, utr=utr, vtr=vtr) + return float(out["conv_mass_transport"].sum().values) + + +def test_pinched_fold_grid_carries_the_fold_symmetry(): + """The fixture really does duplicate its seam row, and the streamfunction + transports really do reproduce the ORCA duplicate-with-sign-flip structure.""" + nxh, ny = 16, 6 + grid = _pinched_fold_grid(nxh, ny) + geo = get_geo_corners(grid) + lon, lat = np.asarray(geo["X"].values), np.asarray(geo["Y"].values) + top = ny - 1 + + # Seam row: corner i and its mirror nxh-i are the same physical point ... + mirrored = distance_on_unit_sphere(lon[top], lat[top], lon[top, ::-1], lat[top, ::-1]) + assert np.nanmax(mirrored) < 1.0e-6 + # ... while the row just below it holds two genuinely distinct halves. + below = distance_on_unit_sphere(lon[top-1], lat[top-1], lon[top-1, ::-1], lat[top-1, ::-1]) + assert below[1:nxh // 2].min() > 1.0e4 + + psi, tgrid = _add_streamfunction_transports(grid) + v = tgrid._ds["vtest"].values + # The seam row's V transports are duplicates carrying a sign flip, and the + # transports are exactly non-divergent -- neither was put in by hand. + assert np.abs(v[top] + v[top, ::-1]).max() < 1.0e-12 * max(np.abs(v[top]).max(), 1.0) + u = tgrid._ds["utest"].values + conv = (u[:, :-1] - u[:, 1:]) + (v[:-1, :] - v[1:, :]) + assert np.abs(conv).max() < 1.0e-12 + + +def test_transport_crossing_the_fold_seam_is_exact(): + """A path that goes up to the seam and back down the mirrored side must transport + exactly `psi(end) - psi(start)`, using each velocity face once. + + Before the seam-twin fix this crossing was attributed to the mirror column's face + with the wrong sign, so the total was wrong by ~2x that face's transport. + """ + nxh, ny = 16, 6 + top = ny - 1 + psi, grid = _add_streamfunction_transports(_pinched_fold_grid(nxh, ny)) + + for i0 in (3, 5, 11): + mirror = nxh - i0 + # up to the seam at column i0, across the fold, back down at the mirror column + j_c = np.array([top - 2, top - 1, top, top - 1, top - 2]) + i_c = np.array([i0, i0, i0, mirror, mirror]) + + got = _section_transport(grid, i_c, j_c) + want = psi[j_c[-1], i_c[-1]] - psi[j_c[0], i_c[0]] + assert got == pytest.approx(want, abs=1.0e-12), f"column {i0}" + + # no velocity face is used twice, and the zero-length twin edge emits none + uv = uvindices_from_qindices(grid, i_c, j_c) + faces = list(zip(uv["var"].tolist(), uv["i"].tolist(), uv["j"].tolist())) + assert len(faces) == len(set(faces)) == 4 + + +def test_transport_along_the_fold_seam_is_exact(): + """Running *along* the duplicated, sign-flipped seam row needs no fold-specific + handling: the traversal direction and the stored velocity share an index frame, + so the flip cancels. This already held before the fix and must keep holding.""" + nxh, ny = 16, 6 + top = ny - 1 + psi, grid = _add_streamfunction_transports(_pinched_fold_grid(nxh, ny)) + + for i0, i1 in ((2, 6), (6, 12), (4, 12)): # last one straddles the fold pivot + i_c = np.concatenate([np.arange(i0, i1 + 1), [i1]]) + j_c = np.concatenate([np.full(i1 - i0 + 1, top), [top - 1]]) + got = _section_transport(grid, i_c, j_c) + want = psi[j_c[-1], i_c[-1]] - psi[j_c[0], i_c[0]] + assert got == pytest.approx(want, abs=1.0e-12), f"columns {i0}..{i1}" + + +def test_fold_crossing_raises_when_coordinates_lack_the_fold_symmetry(): + """`_fold_grid` declares a fold its plain lat/lon coordinates do not carry, so the + seam has no twins and the crossing edge is genuinely undefined. Say so, rather + than silently attributing it to some other face.""" + nxh, ny = 8, 5 + grid = _fold_grid(nxh, ny) + j_c = np.array([ny - 2, ny - 1, ny - 2]) + i_c = np.array([3, 3, nxh - 3]) + with pytest.raises(ValueError, match="fold"): + uvindices_from_qindices(grid, i_c, j_c) + + +@pytest.mark.skipif( + not os.path.exists(_mom6_example_path()), + reason="MOM6 example dataset not present (download via examples/load_example_model_grid.py)", +) +def test_fold_crossing_transport_is_exact_on_real_tripolar_grid(): + """The real-grid counterpart: traced sections that cross the tripolar seam -- + a closed Arctic latitude circle and an open path over the pole -- transport + exactly `psi(end) - psi(start)`, and use no velocity face twice.""" + ds = xr.open_dataset(_mom6_example_path()) + coords = {"X": {"center": "xh", "outer": "xq"}, "Y": {"center": "yh", "outer": "yq"}} + grid = xgcm.Grid(ds, coords=coords, padding={"X": "periodic", "Y": {"fold": "corner"}}, + autoparse_metadata=False) + psi, tgrid = _add_streamfunction_transports(grid) + + sections = { + "latitude circle 80N": (np.arange(0.0, 360.0 + 5.0, 5.0), [80.0] * 73, "latitude circle"), + "meridional over pole": ([100.0, 100.0, 280.0, 280.0], [60.0, 89.0, 89.0, 60.0], "great circle"), + } + for name, (lons, lats, curve) in sections.items(): + i_c, j_c, _, _ = grid_section(grid, lons, lats, curve=curve) + assert j_c.max() == ds.yq.size - 1, f"{name} never reached the seam" + + got = _section_transport(tgrid, i_c, j_c) + want = psi[j_c[-1], i_c[-1]] - psi[j_c[0], i_c[0]] + assert got == pytest.approx(want, abs=1.0e-12), name + + uv = uvindices_from_qindices(grid, i_c, j_c) + faces = list(zip(uv["var"].tolist(), uv["i"].tolist(), uv["j"].tolist())) + assert len(faces) == len(set(faces)), f"{name} counted a velocity face twice" diff --git a/sectionate/transports.py b/sectionate/transports.py index 5e24a91..dff23f3 100644 --- a/sectionate/transports.py +++ b/sectionate/transports.py @@ -115,6 +115,113 @@ def _uv_for_edge(A, B, neighbor_maps, offset, ranges, glon, glat): return "0", 0, 0, int(fB), 0 +def _axis_periodic(grid, axis): + """Whether `axis` wraps, so its first and last index columns are neighbors.""" + ax = grid.axes.get(axis) + return (ax is not None) and (ax.padding == "periodic") + + +def _index_adjacent(ja, ia, jb, ib, ny, nx, y_periodic, x_periodic): + """ + Whether two corners are neighbors in the *raw index* lattice -- the assumption the + single-tile velocity-face arithmetic in `uvindices_from_qindices` is built on. A + wrap across a periodic axis counts, since that arithmetic handles it explicitly. + """ + dj, di = int(jb) - int(ja), int(ib) - int(ia) + if dj == 0 and di == 0: + return True # degenerate; dropped downstream + if dj == 0: + return abs(di) == 1 or (x_periodic and abs(di) == nx - 1) + if di == 0: + return abs(dj) == 1 or (y_periodic and abs(dj) == ny - 1) + return False + + +def _adjacent_candidates(j, i, ny, nx, y_periodic, x_periodic): + """Every corner index adjacent to (j,i) in the raw index lattice, in a fixed order.""" + cand = [(j, i - 1), (j, i + 1), (j - 1, i), (j + 1, i)] + if x_periodic: + cand += [(j, 0), (j, nx - 1)] + if y_periodic: + cand += [(0, i), (ny - 1, i)] + out = [] + for (jj, ii) in cand: + if ((0 <= jj < ny) and (0 <= ii < nx) and ((jj, ii) not in out) + and _index_adjacent(j, i, jj, ii, ny, nx, y_periodic, x_periodic)): + out.append((jj, ii)) + return out + + +def _insert_seam_twins(i_c, j_c, glon, glat, grid): + """ + Splice in seam twins so that every consecutive pair of corners is index-adjacent. + + A seam gives one physical corner two index representations. The bipolar north fold is + the single-tile case: it identifies each seam-row corner with its mirrored column, so + the walk can step from one representation straight to a corner adjacent to the *other*. + That pair is a real physical edge but not an index-adjacent one, and the arithmetic in + `uvindices_from_qindices` reads the velocity column off the source corner -- which is + then the wrong representative. It names the mirror image of the face the section + actually crossed, and (because the mirrored half runs the opposite way in index space) + with the opposite sign. On a closed fold-crossing section that also emits one face + twice, double-counting it. + + Splicing the twin in restores the invariant the arithmetic needs: the crossing becomes + one ordinary edge plus one zero-length twin edge, and zero-length edges are dropped at + the end of `uvindices_from_qindices`, so nothing is counted twice. This is the + single-tile counterpart of the corner canonicalization `_OuterTopology` performs for + multi-tile grids; NEMO reaches the same place from the other direction, carrying the + duplicated points and masking them out of transports with `umaskutil`/`vmaskutil`. + + Runs on every single-tile grid and is a no-op unless a step actually skips the twin, so + grids without seam duplicates (and fold sections that never cross the seam) are untouched. + """ + if len(i_c) < 2: + return np.asarray(i_c), np.asarray(j_c) + ny, nx = glon.shape + y_periodic, x_periodic = _axis_periodic(grid, "Y"), _axis_periodic(grid, "X") + + def coincident(ja, ia, jb, ib): + return distance_on_unit_sphere( + glon[ja, ia], glat[ja, ia], glon[jb, ib], glat[jb, ib] + ) < COINCIDENT_TOLERANCE_M + + out_i, out_j = [int(i_c[0])], [int(j_c[0])] + for k in range(len(i_c) - 1): + ja, ia = int(j_c[k]), int(i_c[k]) + jb, ib = int(j_c[k + 1]), int(i_c[k + 1]) + if not _index_adjacent(ja, ia, jb, ib, ny, nx, y_periodic, x_periodic): + # Either a twin of A adjacent to B, or a twin of B adjacent to A, turns the + # step into two index-adjacent ones. Which of the two exists depends on + # whether the section is entering or leaving the seam. + twin = next( + (t for t in _adjacent_candidates(jb, ib, ny, nx, y_periodic, x_periodic) + if coincident(*t, ja, ia)), + None, + ) + if twin is None: + twin = next( + (t for t in _adjacent_candidates(ja, ia, ny, nx, y_periodic, x_periodic) + if coincident(*t, jb, ib)), + None, + ) + if twin is None: + raise ValueError( + f"Section steps between corners (j={ja}, i={ia}) and (j={jb}, i={ib}), " + "which are neither adjacent in index space nor separated by a seam twin, " + "so the velocity face between them is undefined. On a grid with a bipolar " + "fold this means the corner coordinates do not carry the fold symmetry the " + "grid declares (the seam row's mirrored columns should hold identical " + "coordinates)." + ) + out_j.append(twin[0]) + out_i.append(twin[1]) + out_j.append(jb) + out_i.append(ib) + + return np.asarray(out_i), np.asarray(out_j) + + def uvindices_from_qindices(grid, i_c, j_c, f_c=None): """ Find the `grid` indices of the N-1 velocity points defined by the consecutive indices of @@ -171,6 +278,13 @@ def uvindices_from_qindices(grid, i_c, j_c, f_c=None): keep[1:] = nodes[1:] != nodes[:-1] nat = ot.node_native[nodes[keep]] f_c, j_c, i_c = nat[:, 0].copy(), nat[:, 1].copy(), nat[:, 2].copy() + else: + # Single-tile grids have no face topology to canonicalize corners through, but + # they can still carry seam duplicates: a bipolar north fold identifies every + # seam-row corner with its mirrored column. Splice those twins in so that the + # index arithmetic below -- which assumes consecutive corners are index-adjacent + # -- reads the velocity face the section actually crossed. See `_insert_seam_twins`. + i_c, j_c = _insert_seam_twins(i_c, j_c, glon, glat, grid) nsec = i_c.size uvindices = {