Skip to content

Always take the shortest path between section waypoints - #10

Merged
hdrake merged 4 commits into
topology-driven-neighborsfrom
pr47-lat-circle-shortest-path
Aug 11, 2026
Merged

Always take the shortest path between section waypoints#10
hdrake merged 4 commits into
topology-driven-neighborsfrom
pr47-lat-circle-shortest-path

Conversation

@hdrake

@hdrake hdrake commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Addresses r3752599198, r3752605844 and r3752736092 from the review of MOM6-community#47 (MOM6-community#47, review 4899521952). Targets topology-driven-neighbors.

Shortest path, always

Every segment follows the shortest path between its two waypoints. Raw longitudes are never read as an instruction to go the long way round, so a segment written 0 -> 270 along a parallel runs 90° west. The old "each segment must span less than 180 degrees" ValueError is gone.

The walk already did the right thing — the constant-latitude progress metric is sin²(Δlon/2), periodic in 360° and monotonic in |Δlon| up to 180° — only _check_segment_span refused to let such a segment through. This also fixes segments that were wrongly rejected before: 350 -> 10 (20° east across the prime meridian) and -170 -> 170 (20° west across the dateline) now work.

Three curve options

value behaviour
"great circle" unchanged — every segment follows the geodesic
"latitude circle" every segment follows a parallel; a segment whose endpoints do not share a latitude raises
"latitude and great circle" (new) decided per segment — parallels where the endpoints share a latitude, geodesics everywhere else

"latitude circle" now means what it says, meridional segments included. They were previously waved through on the grounds that a meridian is unambiguous, and that was hiding a real failure: the constant-latitude metric is flat along a meridian, so the walk admits no neighbour and falls through to a fallback. On ECCO LLC90, grid_section(grid, [0., 0.], [80., 60.], curve="latitude circle") raised RuntimeError: Should have reached the endpoint by now. in that direction while 60 -> 80 traced fine. That failure is pre-existing on the base branch, not introduced here. It is now either refused with a clear message or, under the combined option, routed to the geodesic and traced identically both ways (49 points each, exact reverses).

There is one ambiguity error, shared by all options: endpoints written exactly half a circle apart, where neither way round is shorter. It names the measure it reports — degrees of longitude along the parallel, or degrees of arc.

Tolerances

Two separately named constants replace the single SEGMENT_ATOL_DEG, because they were never the same quantity:

  • CONSTANT_LATITUDE_ATOL_DEG = 1e-6 (~11 cm) classifies whether a segment follows a parallel. The old 1e-9 (0.11 mm) rejected float(np.float32(26.4)) against 26.4 — 4 cm apart — and ECCO stores geolat_c as float32.
  • HALF_CIRCLE_ATOL_DEG = 1e-9 guards the ambiguity check. The docs now say plainly that it catches endpoints typed exactly half a circle apart and nothing else: 0 -> 179.9999999 runs east and 0 -> 180.0000001 runs west, with no warning either side.

Notes

The earlier claim that a latitude circle and a great circle produce identical paths was checked at latitude 0, where a latitude circle is a great circle — true but vacuous. The suite now discriminates on a 2° global grid: 0 -> 120 at lat 40 holds the parallel in 61 points while the geodesic bows to 60°N. The 7×5 fixture has rows 40° apart and cannot show this at any latitude.

The literal "latitude and great circle" reads oddly beside two options that each name a single curve; "latitude circle where possible" or "mixed" would also do. One-line change to CURVES plus the test literals if you want it renamed before merge.

Tests

85 passed, 0 skips, in docs_env_sectionate_pr47-lat-circle-shortest-path. Covers all three options against zonal, meridional, oblique and exactly-180 segments; the lat-40 discriminator; float32 waypoint classification; and a meridional ECCO LLC90 segment tracing both directions. test_oblique_latitude_circle_segments_reversible was renamed — its legs are zonal and meridional.

Both notebooks that pass curve use only constant-latitude waypoints, so no notebook output changes; they are re-executed on the integrated branch.

🤖 Generated with Claude Code

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`curve` now takes three values instead of two:

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

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

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

Also, from review of the shortest-path change:

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

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

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

hdrake commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Three curve options, and the review findings

curve now takes three values rather than two:

value behaviour
"great circle" unchanged — every segment follows the geodesic
"latitude circle" every segment follows a parallel; a segment whose endpoints do not share a latitude raises
"latitude and great circle" (new) decided per segment — parallels where the endpoints share a latitude, geodesics everywhere else

This replaces the oblique-only rejection the first version shipped. "latitude circle" now means
what it says, including for meridional segments, which were previously waved through on the
grounds that a meridian is unambiguous.

That accepting-meridians behaviour was hiding a real failure. The constant-latitude progress
metric measures progress purely in longitude, so along a meridian it is flat: the walk admits no
neighbour and falls through to a fallback. On ECCO LLC90,
grid_section(grid, [0., 0.], [80., 60.], curve="latitude circle") raised
RuntimeError: Should have reached the endpoint by now. in that direction while 60 -> 80 traced
fine — a direction-dependent failure. This is pre-existing on the base branch, not introduced
here.
Under "latitude circle" the segment is now refused with a clear message; under
"latitude and great circle" it routes to the geodesic and traces identically in both directions
(49 points each way, exact reverses).

Review findings fixed alongside:

  • The classification tolerance was 1e-9 degrees — 0.11 mm — and it was a rejection threshold.
    float(np.float32(26.4)) differs from 26.4 by 3.8e-07 degrees (about 4 cm), and ECCO stores
    geolat_c as float32, so a user reading one latitude off the grid and typing the other got a
    hard error. It is now CONSTANT_LATITUDE_ATOL_DEG = 1e-6 (~11 cm, five orders of magnitude
    below any grid cell) and it classifies rather than rejects. The half-circle comparison keeps its
    own tight 1e-9 as a separately named constant — the two are not the same physical quantity and
    should never have shared one.
  • The merged ambiguity message was wrong for latitude circles. At 89°N, 0 -> 180 reported
    "half a circle apart (separation ~180.0000 degrees)" for two points 222 km apart, because the
    separation is degrees of longitude, not arc. It still is one message for all options, but it now
    names the measure it reports.
  • The near-180 guard was oversold. 0 -> 179.999999999 raises while 0 -> 179.9999999 runs
    east and 0 -> 180.0000001 runs west — a 2e-07 degree move flips the section to the other side
    of the planet with no error. The docs now say plainly that it catches endpoints typed exactly
    half a circle apart and that anything short of it is settled by round-off.
  • The contract was stated in full in five places; it is now stated once, in grid_section.
    test_oblique_latitude_circle_segments_reversible was renamed — its legs are zonal and
    meridional, so under these rules it is not oblique at all.

On the discriminating test: the previous check that a latitude circle and a great circle agree
was run at latitude 0, where a latitude circle is a great circle — true but vacuous. The suite
now uses a 2° global grid where 0 -> 120 at lat 40 holds the parallel in 61 points while the
geodesic bows to 60°N in 81. The old 7×5 fixture has rows 40° apart and cannot tell the metrics
apart at any latitude.

Full suite: 85 passed, 0 skips.

The literal "latitude and great circle" reads a little oddly beside two options that each name a
single curve; "latitude circle where possible" or "mixed" would also work. It is a one-line
change to CURVES plus the test literals if you would rather rename before merge.

Written by Claude Code.

@hdrake hdrake left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Looks great

@hdrake
hdrake merged commit eabe1f5 into topology-driven-neighbors Aug 11, 2026
hdrake added a commit that referenced this pull request Aug 11, 2026
Two conflicts, both additive documentation:

- `create_section_composite`'s parameter list: #10 documented `curve`, this
  branch documented `grid`. Both kept.
- `CLAUDE.md`'s `section.py` bullet: the base now states the three-curve
  contract from #10; this branch states the repeated-corner invariant. The
  base's wording is authoritative on curves and carries hdrake's own edits, so
  the invariant sentences are spliced into it.

Two tests on this branch encoded #10-era path lengths that the corner dropping
changes: `test_latitude_circle_takes_shortest_path_west` (the seam vertex is one
corner under two indices, so the path starts at index 6 rather than carrying
both) and `test_parallel_is_held_where_the_geodesic_bows` (the geodesic crosses
the periodic seam diagonally, 81 points rather than 83). Expectations updated
and the reasons recorded in each docstring.

Suite: 96 passed, 0 skips. Transports re-verified against the unmodified base:
still exactly one recorded difference, `mom6_fold.zonal_periodic.n_corners:
308 -> 307`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant