Skip to content

Port simple_paleobathymetry (Steps 1-4) and its distance/sediment engine into gplately - #449

Draft
michaelchin wants to merge 27 commits into
masterfrom
444-simple-paleobathymetry
Draft

michaelchin wants to merge 27 commits into
masterfrom
444-simple-paleobathymetry

Conversation

@michaelchin

@michaelchin michaelchin commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #444 and #445 together (they turned out to be tightly coupled - see the discussion on #444).

gplately.paleobathymetry (#444) - Steps 1, 3, 4 of EarthByte's simple_paleobathymetry workflow, plus an orchestrator:

  • age_to_basement_depth() - Step 1: seafloor age -> basement depth (gdh1/rhcw18/parsons_sclater/crosby09). Ships the RHCW18 lookup table.
  • dutkiewicz_2017_sediment_thickness() - Step 3: Dutkiewicz et al. (2017) sediment-thickness polynomial fit.
  • sediment_isostatic_correction() / paleobathymetry() - Step 4: Sykes (1996) isostatic correction, combined into final paleobathymetry.
  • simple_paleobathymetry() - runs Steps 1-4 end to end, calling into gplately.sediment_thickness for Step 2.

gplately.sediment_thickness (#445) - a port of predicting-sediment-thickness's ocean_basin_proximity.py engine:

  • generate_distance_grids() - Step 2: reconstructs each ocean point backward through time and computes its lifetime-mean distance to the nearest proximity feature (e.g. passive-margin COB line segments), using pygplates.TopologicalModel and gplately's own ptt.utils.proximity_query.
  • generate_sediment_thickness_grids() - Step 3's grid-level driver, composing the above with dutkiewicz_2017_sediment_thickness().
  • Exposed on the CLI: gplately generate-distance-grids / generate-sediment-grids (aliases gdg/gsg).

Validation

  • Steps 1, 3, 4 numerically cross-checked against the original workflow's own reference code (diffs at floating-point noise, ~1e-12).
  • Step 2 + the full simple_paleobathymetry() pipeline run end-to-end against a real plate model (Müller et al. 2025, via the Plate Model Manager) with a downloaded age grid: produces physically sensible distance-to-margin (0-3000 km) and paleobathymetry (-2 to -5.5 km) values.
  • CLI (generate-distance-grids -> generate-sediment-grids) smoke-tested end-to-end against the same real data.
  • Unit tests added for all of the above (test_9_paleobathymetry.py, test_10_sediment_thickness.py), using the existing Muller2019 test fixtures.

Deliberately out of scope

  • Continent-obstacle routing in Step 2 (shortest path around continents, via the original's shortest_path.py) - distances are currently always great-circle. This is a real difference from simple_paleobathymetry's default config (use_continent_obstacles: true).
  • Topological proximity features - only static/non-topological proximity features (e.g. COB line segments) are supported.
  • Step 5 (pyBacktrack merge) - tracked separately in Integrate pyBacktrack: merge present-day and subducted paleobathymetry grids into complete grids #447.

All flagged in the relevant module docstrings.

Test plan

  • python -m pytest -vv tests-dir/pytestcases/test_9_paleobathymetry.py tests-dir/pytestcases/test_10_sediment_thickness.py tests-dir/pytestcases/test_0_imports.py - all passing.
  • Manual cross-check of Steps 1/3/4 against reference implementation.
  • Manual end-to-end run (Python API and CLI) against a real plate model, sanity-checked output ranges.
  • black --check clean on all changed/added files.
  • Full pytest -vv tests-dir/pytestcases (not run here - some fixtures need network access / take a while).

🤖 Generated with Claude Code

michaelchin and others added 2 commits September 14, 2026 14:50
Adds gplately.paleobathymetry with age_to_basement_depth() (Step 1: four
published thermal-subsidence models), dutkiewicz_2017_sediment_thickness()
(Step 3: the Dutkiewicz et al. 2017 age/distance-to-margin polynomial fit),
and sediment_isostatic_correction()/paleobathymetry() (Step 4: Sykes 1996
isostatic sediment-load correction), ported from and numerically verified
against EarthByte's simple_paleobathymetry workflow. Ships the RHCW18
age-depth lookup table used by the 'rhcw18' model.

Step 2 (lifetime-mean distance to the nearest passive continental margin)
and Step 5 (pyBacktrack merge) are deliberately not included here: Step 2
needs the proximity/obstacle-routing engine tracked in gplately#445, and
Step 5 is tracked in gplately#447. See the module docstring and the #444
comment thread for the reasoning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…; wire into #444

Adds gplately.sediment_thickness, a port of EarthByte's
predicting-sediment-thickness ocean_basin_proximity.py engine:

- generate_distance_grids(): for each ocean point in an age grid, reconstruct
  it backward through time (topological reconstruction via
  pygplates.TopologicalModel, distance queries via gplately's own
  ptt.utils.proximity_query) and accumulate its lifetime-mean distance to the
  nearest proximity feature (e.g. passive-margin COB line segments).
- generate_sediment_thickness_grids(): combines an age grid with the distance
  grid above via gplately.paleobathymetry.dutkiewicz_2017_sediment_thickness.

Exposed on the CLI as 'gplately generate-distance-grids'/'generate-sediment-grids'
(aliases gdg/gsg), per #445's stated scope.

Not ported: continent-obstacle shortest-path routing (ocean_basin_proximity.py's
shortest_path.py) - distances are currently always great-circle; and
topological (as opposed to static) proximity features. Both are documented
gaps in the module docstring.

Validated end-to-end against a real plate model (rotations/topologies/COBs)
with a downloaded age grid: produces physically sensible distance
(0-3000 km) and paleobathymetry (-2 to -5.5 km) values. Added unit tests
using the existing Muller2019 test fixtures plus a synthetic age grid.

Also adds gplately.paleobathymetry.simple_paleobathymetry(), the Steps 1-4
orchestrator for #444, which now calls generate_distance_grids() for Step 2
instead of requiring the caller to supply a distance grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@michaelchin michaelchin changed the title Port simple_paleobathymetry Steps 1, 3 and 4 into gplately Port simple_paleobathymetry (Steps 1-4) and its distance/sediment engine into gplately Sep 14, 2026
michaelchin and others added 2 commits September 14, 2026 15:26
Groups them with the other gridding code (oceans.py, topology/isochron
seafloor grids) per gplately's module layout. Public API is unaffected -
everything stays re-exported from gplately/__init__.py (gplately.paleobathymetry,
gplately.simple_paleobathymetry, gplately.generate_distance_grids, etc.).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wraps gplately.grids.paleobathymetry.simple_paleobathymetry() as a new CLI
subcommand (alias 'pb'), completing the CLI surface alongside the existing
'generate-distance-grids'/'generate-sediment-grids' (#445): previously there
was no CLI path to Steps 1/4, or to running the full pipeline in one command,
even though that one-command workflow is the actual UX simple_paleobathymetry
(#444) is modelled on.

Refactored gplately/commands/sediment_thickness.py to extract
_add_distance_arguments()/_resolve_rotation_topology_proximity_files(), now
shared between 'generate-distance-grids' and the new 'paleobathymetry'
command instead of being duplicated.

Verified end-to-end against a real plate model (muller2025) via the CLI;
also added the same real invocation to tests-dir/test-cli.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@michaelchin

Copy link
Copy Markdown
Contributor Author

Added a `gplately paleobathymetry` CLI command (alias `pb`), wrapping `simple_paleobathymetry()` to run Steps 1-4 end to end in one command - previously there was CLI coverage for Steps 2/3 only (`generate-distance-grids`/`generate-sediment-grids`), with no CLI path to the full pipeline even though that's the actual one-command UX the source `simple_paleobathymetry` workflow offers.

Refactored the shared argparse/file-resolution logic out of `commands/sediment_thickness.py` so it's reused by the new command rather than duplicated. Verified end-to-end against a real plate model via the CLI, and added the same invocation to `tests-dir/test-cli.sh`.

michaelchin and others added 2 commits September 14, 2026 15:43
…r in test-cli.sh

The combined 'paleobathymetry' command already exercised generate_distance_grids()/
generate_sediment_thickness_grids() at the Python level, but not the CLI-level
handoff between running 'gdg' and 'gsg' as two separate invocations (writing to
--distance-grids-dir, then a second process reading those grids back off disk) -
a distinct code path in commands/sediment_thickness.py that wasn't covered by
either the unit tests or test-cli.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds gplately.grids.pybacktrack_paleobathymetry.merge_pybacktrack_paleobathymetry(),
a thin wrapper around pybacktrack.reconstruct_paleo_bathymetry_grids()'s merge
support: computes pyBacktrack's present-day paleobathymetry and merges in
Steps 1-4's grids to also cover submerged continental crust and crust that
has since subducted. pybacktrack is an optional dependency (new
gplately[paleobathymetry] extra), imported lazily so `import gplately` never
requires it.

Wired into gplately.grids.paleobathymetry.simple_paleobathymetry() as an
opt-in final step (pybacktrack=True, requires output_directory,
static_polygon_filename, present_day_age_grid_filename - Step 5 merges the
Step 4 grids by reading them back off disk), and into the 'gplately
paleobathymetry' CLI command as --pybacktrack (--static-polygons and
--present-day-age-grid are auto-resolved from -m/--model when not given
explicitly).

Validated with the actual pybacktrack package (now installed locally):
merging pyBacktrack's output with a real Steps-1-4 run (muller2025) via both
the Python API and the CLI increases finite-cell coverage from 330 to 441
out of 703 grid points, as expected (pyBacktrack fills in the
submerged-continental-crust/subducted-crust gaps Steps 1-4 leave as NaN).
Unit tests added (skipped when pybacktrack isn't installed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@michaelchin

Copy link
Copy Markdown
Contributor Author

Added Step 5 (pyBacktrack merge, #447) too, so this PR now covers #444, #445 and #447.

  • `gplately.grids.pybacktrack_paleobathymetry.merge_pybacktrack_paleobathymetry()` - thin wrapper around `pybacktrack.reconstruct_paleo_bathymetry_grids()`'s merge support. `pybacktrack` is an optional dependency (new `gplately[paleobathymetry]` extra), imported lazily so `import gplately` never requires it.
  • Wired into `simple_paleobathymetry()` as an opt-in step (`pybacktrack=True`) and into the CLI as `gplately paleobathymetry --pybacktrack` (`--static-polygons`/`--present-day-age-grid` auto-resolve from `-m/--model` when omitted).
  • Actually validated with the real `pybacktrack` package (I had it install cleanly in the test env): ran the full pipeline end-to-end (Python API and CLI) against `muller2025`, and the merged output goes from 330 to 441 finite cells (out of 703, at a coarse 10° test grid) - i.e. pyBacktrack is correctly filling in the submerged-continental-crust / already-subducted-crust gaps that Steps 1-4 leave as NaN, which is the whole point of Step 5.
  • Unit tests added, skipped automatically via `pytest.importorskip` when `pybacktrack` isn't installed (kept it out of the core test env since it's a large optional dependency).

Only thing still open for the whole paleobathymetry effort now is #446 (continent-contouring passive-margins use case) and the continent-obstacle routing gap noted in #445's module docstring.

Adds gplately.grids.continent_contouring, a port of EarthByte's
continent-contouring create_passive_margins.py (the one use case that
repository contains), built entirely on gplately's own already-vendored
continent-contouring engine (gplately.ptt.continent_contours.ContinentContouring):

- passive_margin_polylines(): split one continent-contour polyline into its
  passive-margin segments, by removing the parts close to a subduction zone
  (the core algorithm).
- generate_passive_margins(): the full driver - contour continents through
  time and split each contour, returning/writing the aggregated contour and
  passive-margin feature collections plus per-time continental-crust masks.

Exposed on the CLI as 'gplately generate-passive-margins' (alias gpm).

generate_passive_margins()'s output composes directly with #445's
generate_distance_grids() as an alternative to a static COB line-segment
file (its passive_margin_features is already the
pygplates.FeaturesFunctionArgument-compatible type that proximity_features
expects) - validated end-to-end against a real plate model (muller2025),
no glue code needed. Noted in sediment_thickness.py's module docstring.

Validated with real data throughout: contouring, splitting, CLI, and the
composition with generate_distance_grids all run correctly against
muller2025. Unit tests added, including hand-checked geometric cases for
the segment-splitting algorithm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@michaelchin

Copy link
Copy Markdown
Contributor Author

Added #446 too (continent-contouring passive margins), so this PR now covers #444, #445, #446 and #447 - the whole paleobathymetry tracking effort (#448) except the continent-obstacle routing gap noted in #445's docstring.

  • `gplately.grids.continent_contouring` - port of `continent-contouring`'s `create_passive_margins.py`, built on gplately's own already-vendored `ptt.continent_contours.ContinentContouring` engine. `passive_margin_polylines()` (the core segment-splitting algorithm) + `generate_passive_margins()` (the full time-stepping driver).
  • CLI: `gplately generate-passive-margins` (alias `gpm`).
  • Validated the actual point of this: `generate_passive_margins()`'s `passive_margin_features` output composes directly with Move Predicting Sediment Thickness workflow into GPlately, expose via API/CLI #445's `generate_distance_grids()` as a dynamically-contoured alternative to a static COB file - zero glue code needed (it's already the right `pygplates.FeaturesFunctionArgument`-compatible type). Ran this composition end-to-end against `muller2025` and confirmed it produces sensible distance grids.
  • Unit tests added, including hand-verified geometric cases for the segment-splitting logic (e.g. a contour with an active-margin gap in the middle correctly splits into two separate passive-margin polylines).

Adds gplately.lib.shortest_path, a faithful port of
predicting-sediment-thickness's shortest_path.py: a spherical grid
(Grid/ObstacleGrid/DistanceGrid) that computes shortest-path distances
*around* obstacle geometries via Dijkstra's algorithm, instead of a
great-circle straight line that can cut through land.

Wires this into generate_distance_grids() as new optional arguments
(continent_obstacle_features, plate_boundary_obstacle_feature_types,
shortest_path_grid_subdivision_depth) - when continent_obstacle_features is
given, each time step builds an obstacle grid from the reconstructed
obstacles (plus, by default, mid-ocean ridge and subduction zone plate
boundary sections) and routes distances around it instead of using plain
great-circle distance. Propagated through to
gplately.grids.paleobathymetry.simple_paleobathymetry() and the CLI
(--route-around-continents / --continent-obstacles /
--shortest-path-grid-depth, on both 'generate-distance-grids' and
'paleobathymetry').

Validated: a standalone synthetic-obstacle test confirms routed distance is
strictly longer than great-circle when a straight line would cut through
the obstacle, and matches great-circle (within grid-discretisation noise)
on a clear path. Against a real plate model (muller2025) with real
coastlines, obstacle-routed distances average ~70-80km longer than
great-circle across a coarse global test grid, with a small number of
individual-point exceptions consistent with the algorithm's known
grid-interpolation smoothing (shrinks with finer grid resolution, as
expected). Also verified end-to-end via the CLI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@michaelchin

Copy link
Copy Markdown
Contributor Author

Added continent-obstacle routing too, closing the one remaining documented gap from #445.

  • `gplately.lib.shortest_path` - a faithful port of `predicting-sediment-thickness`'s `shortest_path.py`: a spherical grid + Dijkstra's algorithm that computes shortest-path distances around obstacle geometries instead of a great-circle line that can cut through land.
  • Wired into `generate_distance_grids()` as new optional args (`continent_obstacle_features`, `plate_boundary_obstacle_feature_types`, `shortest_path_grid_subdivision_depth`), and propagated through to `simple_paleobathymetry()` and the CLI (`--route-around-continents`/`--continent-obstacles`/`--shortest-path-grid-depth`, on both `generate-distance-grids` and `paleobathymetry`).
  • Validated: a clean synthetic-obstacle unit test confirms routed distance > great-circle when blocked, ≈ great-circle when clear. Against real data (`muller2025` + real coastlines), obstacle-routed distances average ~70-80km longer than great-circle across a test grid, with a small number of individual-point exceptions that shrink as grid resolution increases - consistent with the algorithm's own known grid-interpolation smoothing behaviour, not a port defect.

With this, the only thing left from the original scope (per #448) is #446... which is also already in this PR. So as far as I can tell, everything from the original email thread is now implemented, at least at a first-pass level of completeness.

michaelchin and others added 2 commits September 14, 2026 16:50
Combines test_9_paleobathymetry.py, test_10_sediment_thickness.py,
test_11_pybacktrack_paleobathymetry.py, test_12_continent_contouring.py and
test_13_shortest_path.py into a single test_9_paleobathymetry.py, organised
into labelled sections by submodule. Renamed the three colliding
test_public_api_exports() functions (one per merged file) so all three
survive rather than the later definitions silently shadowing earlier ones.
Replaced the module-level pytest.importorskip("pybacktrack") with a
try/except + skipif marker scoped to just the two pybacktrack tests, so a
missing optional pybacktrack dependency no longer skips the whole file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- gplately/grids/paleobathymetry.py:
  - simple_paleobathymetry() no longer crashes with "got multiple values for
    keyword argument 'max_distance_km'" when sediment_thickness_kwargs
    explicitly overrides it (a documented, valid override) - the caller's
    value now correctly wins instead of colliding with the internally
    injected clamp_distance_km.
  - age_to_basement_depth(model="rhcw18") no longer maps tiny negative ages
    (floating-point/grid-interpolation noise right at a ridge) to 0 m (sea
    level); they're now clamped to 0 before interpolation, giving the
    table's actual ridge-crest depth (~-2500 m), consistent with how gdh1/
    parsons_sclater already special-case age < 0.
  - simple_paleobathymetry(pybacktrack=True) now raises a clear TypeError if
    rotation_model is an already-constructed pygplates.RotationModel (its
    own docstring says that's an accepted rotation_model input in general,
    but pyBacktrack specifically needs raw filenames) instead of failing
    obscurely inside pybacktrack.

- gplately/grids/continent_contouring.py: passive_margin_polylines() no
  longer splits a passive-margin stretch that happens to straddle a closed
  contour ring's arbitrary start/end point into two separate output
  polylines - the ring is now rotated to start right after an active edge
  (if any) before splitting, so the array boundary never falls inside a
  passive stretch.

- gplately/commands/sediment_thickness.py, .../continent_contouring.py:
  fractional --time-step/--min-time/--max-time (all declared type=float on
  the CLI) were silently truncated via int() before building the time
  range - e.g. --time-step 0.5 crashed with "range() arg 3 must not be
  zero", and --time-step 2.5 silently became 2. Replaced with a proper
  float-aware _time_range() helper (shared between the two command
  modules). Also added the missing return_none_if_not_exist=True on
  Topologies layer lookups (a model without that layer now hits the
  intended "No rotation/topology files found" message instead of an opaque
  plate-model-manager exception), and continent_contouring.py now merges in
  a model's Cratons layer when present, matching seafloor_grids.py's
  existing continent-file resolution.

- gplately/commands/paleobathymetry.py: --pybacktrack no longer silently
  drops all but the first file when a plate model's StaticPolygons layer
  resolves to multiple files (pyBacktrack's static_polygon_filename takes
  exactly one file) - they're now merged into one temporary file instead.

- tests-dir/test-cli.sh: fixed a reference to unittest/test_seafloor_gridding.sh
  (underscores); the actual script is test-seafloor-gridding.sh (hyphens),
  so the CLI smoke test would have aborted there.

Added regression tests for the three algorithm/API-level fixes (RHCW18
ridge depth, closed-ring seam merge, max_distance_km override). Full test
suite (33 tests) still passes; CLI commands manually re-verified end to end,
including the previously-crashing fractional --time-step case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@michaelchin

Copy link
Copy Markdown
Contributor Author

Ran a thorough multi-angle code review over the whole diff before asking for review. Fixed everything that was a genuine bug:

  • `simple_paleobathymetry()` crashed with `TypeError: got multiple values for keyword argument 'max_distance_km'` if `sediment_thickness_kwargs` used its own documented override of that name.
  • `age_to_basement_depth(model="rhcw18")` mapped tiny negative ages (common floating-point noise right at a ridge) to 0 m (sea level) instead of ridge-crest depth (~-2500 m) - the other three models already special-cased this, rhcw18 didn't.
  • `simple_paleobathymetry(pybacktrack=True)` now fails with a clear error instead of an obscure one if `rotation_model` is passed as an already-built `pygplates.RotationModel` (pyBacktrack needs raw filenames).
  • `passive_margin_polylines()` could incorrectly split a single passive-margin stretch into two output polylines when it straddled a closed contour ring's arbitrary start/end point.
  • `--time-step`/`--min-time`/`--max-time` (declared `type=float`) were silently truncated via `int()` before building the time range - `--time-step 0.5` crashed outright, `--time-step 2.5` silently became 2. Fixed with a shared float-aware helper.
  • A plate model missing a `Topologies` layer raised an opaque plate-model-manager exception instead of the intended friendly error message.
  • `generate-passive-margins` didn't merge in a model's `Cratons` layer the way `seafloor_grids.py`'s equivalent resolution does.
  • `--pybacktrack` silently dropped all but the first file when a model's `StaticPolygons` layer spans multiple files - now merges them instead.
  • `tests-dir/test-cli.sh` referenced `test_seafloor_gridding.sh` (underscores); the real file is `test-seafloor-gridding.sh` (hyphens) - would have aborted the CLI smoke test.

Added regression tests for the algorithm/API-level fixes; full suite (33 tests) passes, and I re-ran the CLI commands manually including the previously-crashing fractional `--time-step` case.

Deliberately not fixed, flagged for awareness rather than action in this PR:

  • A real performance issue in `generate_distance_grids()`: when continent-obstacle routing is on and multiple age grids are processed together, each age grid's backward-reconstruction trajectory independently rebuilds the obstacle grid and reruns Dijkstra at every overlapping time step, so total work scales roughly O(N²) in the number of age grids instead of O(N) (this is exactly what the original `ocean_basin_proximity.py` was structured to avoid, by design, and is a legitimate restructuring, not a quick fix - didn't want to rush it in alongside everything else here).
  • A batch of "duplicated code" / "could reuse a shared helper" / "could use scipy instead of hand-rolled Dijkstra" style findings across several files - real observations, but refactors rather than bugs, and `gplately/lib/shortest_path.py` in particular is a deliberately faithful port where I'd rather not deviate from the validated original.
  • Two findings (the `math.pi` fallback being counted into the distance mean, and `DistanceGrid.shortest_distance()`'s point-vs-other-geometry inconsistency) turned out to be faithful copies of the original algorithm's own behaviour, not defects introduced here - confirmed against the source I ported from.

I think this is ready for human review now.

@michaelchin
michaelchin marked this pull request as ready for review September 14, 2026 07:17
michaelchin and others added 2 commits September 14, 2026 17:29
Adds paleobathymetry (pb), generate-distance-grids (gdg),
generate-sediment-grids (gsg) and generate-passive-margins (gpm) to the CLI
docs, following the existing per-subcommand section format (description +
usage examples). Verified with a clean sphinx-build (no new warnings/errors,
sections render correctly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@michaelchin

Copy link
Copy Markdown
Contributor Author

@jcannon-gplates

Most of the work in the PR was done by Claude Code.

@jcannon-gplates

Copy link
Copy Markdown
Contributor

Wow, this is quite some work - nice!

I'll take a look - it might be a while though.

@michaelchin

Copy link
Copy Markdown
Contributor Author

Wow, this is quite some work - nice!

I'll take a look - it might be a while though.

Dietmar wanted it be included in the next GPlately release. In fact, I had completely forgotten the paleobathymetry task until he reminded me yesterday. It is the reason why I suddenly asked Claude Code to do this after I've tagged v2.1.0rc0. Take your time. Either in or not in the next release is fine with me. I am also hesitate to rush it in.

@michaelchin

Copy link
Copy Markdown
Contributor Author

Wow, this is quite some work - nice!

I'll take a look - it might be a while though.

By the way, Claude wrote almost all the long comments here. You may want your Claude give you a brief summary of those long comments. I found sometimes it took some time to read the reports from Claude.

@michaelchin

Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts in this pull request

…hymetry

# Conflicts:
#	sphinx-doc/source/command_line_interface.rst

Co-authored-by: michaelchin <2688316+michaelchin@users.noreply.github.com>

Copilot AI commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved by merging master into this branch and fixing the conflict in sphinx-doc/source/command_line_interface.rst in commit 2a52a58.

@jcannon-gplates jcannon-gplates left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @michaelchin — this is a lot of ground covered, and the parts that are straight ports are genuinely well done.

I reviewed it by diffing against the original source rather than reading the diff, since the question I care about is fidelity to the workflows rather than whether the new code is internally sensible. Worth flagging up front, because it's why most of what follows wasn't visible in your own review pass — several of these need the ancestor to see at all.

How I'd suggest we split this up. I'm happy to take nearly all of it. I wrote most of the original code, so the fixes and the decisions will be quicker for me than for you — and it saves you the tokens, which I know get tight. I'd rather not hand you a long list of things to interpret either:

  • The blocking items below — I'll fix them on this branch, unless you'd rather do them yourself. They're all small; the largest is a one-line change repeated in three places.

  • Everything under "Decisions rather than fixes" — treat as decided, they're calls about my own original code. I've written each one up so it's on the record, and I'll check with you at the time if I'm unsure about anything.

  • The follow-up issues — mine, except the test plumbing. I've drafted them all under tracking issue #457 so you don't have to write any of this up:

    Issue Owner
    #458 Blocking fixes for this PR (checklist; closed by #449) me
    #459 Restore the shared time loop in generate_distance_grids() — the 5–8× win me
    #460 Restore the internal/output grid-spacing split and upscaling filter me
    #461 Restore parallelism across Steps 1–3 and Step 5 me
    #462 Contouring fidelity: time-dependent thresholds, buffer ramp, subduction-zone output me
    #463 Test plumbing: test-cli.sh into CI, pybacktrack in test-env.yml, GPLATELY_TEST_LEVEL gating you, if you're happy to take it
    #464 Tests for the changed surface (the porting delta) me
    #465 --max_distance_threshold: help text vs. behaviour me
    #466 Module placement and naming (physics functions in grids/, the continent_contouring / continent_contours clash) me
    #467 Example notebook for the workflow me

    Issue #463 is CI and packaging rather than workflow science, which is much more your area than mine — that's the only reason I've put your name on it. Issue #466 touches GPlately's own layout rather than the ported code, and on issue #467 I can get the notebook started but you may well want to tidy it up to match the style of the others — so say the word if you'd rather own either of those.

What's solid

  • lib/shortest_path.py is a genuinely faithful port. A token-level diff against the original shows no behavioural deviations at all — constants, the 16-neighbour construction with dateline wrap and pole clamping, the quad-tree hemisphere bounding, the lazy-deletion Dijkstra heap, the inverse-distance blend in shortest_distance, and both memo caches are intact. Dropping the __main__ demo block was the right call.
  • Steps 1/3/4: every constant and branch boundary matches, including the Dutkiewicz polynomial with its standardisation constants, and the Sykes correction.
  • RHCW18_age_depth.dat is byte-identical to both my copy and pyBacktrack's bundled one, so Step 1 and Step 5 are genuinely consistent for rhcw18. Nice property to have. (gdh1 too — I checked the port's analytic form against pyBacktrack's and they agree to 0.00 m.)
  • Step 2's core maths — seeding, backward stepping, retirement, the unweighted mean, the math.pi fallback, mean-only clamping — matches statement for statement.
  • pyBacktrack Step 5 passes every argument with the same name and value as the original, and the lazy import is right.
  • Good reuse of ptt/continent_contours.py rather than re-implementing it.

Blocking

1. Age grids with descending latitude are sampled upside-down. Three sites: grids/sediment_thickness.py:409-420 and :515-526, grids/paleobathymetry.py:511-526. Each builds extent=(min(lon), max(lon), min(lat), max(lat)), which throws away the sign sample_grid needs. read_netcdf_grid only flips latitude when max(lon) > 180 (_grids.py:371-376), so a −180..180 north-up grid keeps descending rows, and sample_grid then reads row 0 as −90° when it is +90° (_grids.py:858-860).

Reproduced with the PR's exact sampling call — one synthetic field where age increases with latitude, written twice with the two equally valid on-disk row orders:

query latitude      : [-75. -45. -15.  15.  45.  75.]
expected age (Myr)  : [ 15.  45.  75. 105. 135. 165.]
PR code, lat ASC    : [ 15.  45.  75. 105. 135. 165.]
PR code, lat DESC   : [165. 135. 105.  75.  45.  15.]   <-- exactly mirrored
signed extent, DESC : [ 15.  45.  75. 105. 135. 165.]   <-- _grids.py:390-395 form

max error on the descending grid: 150.0 Myr (field spans 15-165 Myr)

It's silent — no warning, and the output keeps entirely plausible ranges. gplately's own internal callers already use the correct signed form at _grids.py:390-395, so the fix is to use (grid_lon[0], grid_lon[-1], grid_lat[0], grid_lat[-1]), or to pass realign=True. The original workflow was immune because grdtrack and xarray.interp both work off labelled coordinates.

On how likely this is to bite in practice, see the first answer under "Questions I'd flagged" below — short version, GMT- and gplately-written grids are always safe, GDAL-written ones aren't.

2. {:.0f} output filenames collide and silently overwrite. grids/paleobathymetry.py:538, grids/sediment_thickness.py:535, grids/continent_contouring.py:292 — while the distance grids correctly use {:.1f} (sediment_thickness.py:459). The format strings are inherited; what changed is the precondition. The original forced integer times, so {:.0f} was injective, whereas _time_range exists specifically to allow fractional steps. Feeding it the times from --time-step 0.5 over 0→2 Ma:

   0.0 Ma  ->  paleobathymetry_0Ma.nc
   0.5 Ma  ->  paleobathymetry_0Ma.nc     <-- overwrites 0.0
   1.0 Ma  ->  paleobathymetry_1Ma.nc
   1.5 Ma  ->  paleobathymetry_2Ma.nc     <-- banker's rounding
   2.0 Ma  ->  paleobathymetry_2Ma.nc     <-- overwrites 1.5

grids computed : 5     files written : 3     silently lost : 2

So paleobathymetry_0Ma.nc ends up holding the 0.5 Ma grid. The returned dict is correct, so this is disk-only and easy to miss. The distance-grid writer survives the same times unscathed because of its {:.1f} — worth making the other three match it.

3. --time-step is bound to time_increment. commands/sediment_thickness.py:146 and commands/paleobathymetry.py:92. These are two different quantities in the original: time.step chooses the output times, while time_increment=1 is hardwired for the lifetime sampling loop. --time-step 10 samples each point's distance-to-margin every 10 Myr instead of 1 Myr, and degrades the reconstruction trajectory as well. Plausible output, wrong numbers. Needs a separate --time-increment defaulting to 1.

4. The same fault again in Step 5. grids/paleobathymetry.py:584 forwards the reconstruction increment as pyBacktrack's output increment. With times [0, 10, 20] and the default time_increment=1, Step 5 asks to merge paleobathymetry_1Ma.nc through _19Ma.nc, none of which exist — after the full backtrack has already run.

5. COBs are auto-sourced from the plate model, which the original explicitly refuses to do. commands/sediment_thickness.py:82-85 falls back to plate_model.get_layer("COBs"). The original refuses this in three places — the load_plate_model docstring, a capitalised warning in config.yml ("COB POLYGONS MUST NOT BE USED — a polygon outline wraps the whole continent, including active margins, creating false passive-margin proximities"), and the _check_cob_line_segments() advisory. That polygon guard was dropped at the same time, so there is now nothing to catch it. The error message at :91-95 half-knows this already.

6. An all-NaN age grid still writes an output file. sediment_thickness.py:423-427 falls through to the write at :453-461. The original warns and continues. A bad age grid silently yields a valid-looking all-NaN .nc that Step 3 then consumes.

7. No validation of time_increment in the library (the original raises). 0 gives ZeroDivisionError at :148; negative gives an infinite loop. Only the CLI path is guarded.

8. Valid-time epsilon dropped. create_passive_margins.py writes time + 0.5*interval - 1e-4 in three places, with the comment "epsilon to avoid overlap at interval boundaries". continent_contouring.py:275-287 drops it, so adjacent time slices now overlap at every boundary when loaded into GPlates.

9. crosby09 shouldn't be accepted for Step 5. pybacktrack_paleobathymetry.py:48 maps it to pyBacktrack's AGE_TO_DEPTH_MODEL_CROSBY_2007, but those are two different models — the numbers are under "Questions I'd flagged" below. gdh1 and rhcw18 genuinely are consistent across the merge; this one isn't, and it should raise rather than silently produce a grid with a model discontinuity along the vanished-crust boundary.

10. The public API should be locked down before release, not after. Neither generate_distance_grids nor simple_paleobathymetry has a * marker, so all 11 / 16 optional parameters can be passed positionally — including shortest_path_grid_subdivision_depth, distance_threshold_radians and plate_boundary_obstacle_feature_types. That depth was a deliberate speed/quality compromise on my side and is fine to expose, but as an advanced keyword. A bare *, after age_grid_filenames_and_times is one character per signature, and it's the kind of thing that can't be added once people are calling it. Same argument for the 14 new public names being missing from __all__ (so from gplately import * omits the whole new API — the hasattr-based export tests pass and mask this), and for only 4 of the 14 reaching Sphinx.

11. Minor. commands/paleobathymetry.py:69 leaks a NamedTemporaryFile(delete=False). commands/paleobathymetry.py:119 says obstacle routing isn't included, but it is. RHCW18_age_depth.dat is vendored without a licence statement.

Decisions rather than fixes

These are all calls about code I wrote, so I've taken them rather than asking — flagging them here so they're on the record, and happy to be argued out of any of them.

  • Restore continent-obstacle routing as the default. config.yml ships use_continent_obstacles: true; the port makes it opt-in. This also means the batching regression under "Performance" is on the default path rather than an edge case.

  • Provenance of grids/continent_contouring.py. The docstring says it ports create_passive_margins.py, but it actually descends from simple_paleobathymetry's generate_continent_contours.py after my fix in EarthByte/simple_paleobathymetry#2 — it reproduces wording that exists only in that fix. That's good news: it inherited both corrections (the swapped area/exclusion thresholds, and .inf separation → engine default), so it never had the merge-every-continent-into-one-blob bug. Two consequences worth recording. The docstring should cite the actual ancestor and note that the defaults follow the paleobathymetry-workflow parameter set rather than continent-contouring at main. And it missed the buffer_and_gap_mode: ramp from that same fix — and can't express it, since buffer_and_gap_distance_kms is a float where the engine accepts callables. Deep-time (pre-Pangea) contouring isn't reachable as a result; that part is issue #462 rather than this PR.

  • separation_distance_threshold_radians=None is safe, but only by accident of style. It works because :209-213 omits the kwarg so the constructor default applies. The engine treats an explicit None as zero. Worth a comment there so nobody "simplifies" it into a direct pass-through later.

  • Keep the closed-ring seam rotation (:97-111), but document it as a deliberate deviation. It emits one polyline where every ancestor emits two. I think it's an improvement, but it changes feature counts and per-feature lengths against the reference passive_margin_features.gpmlz, so anyone comparing will otherwise think something is broken. It also evaluates _near_subduction twice per arc; the memo is in issue #462.

Testing

I agree with the principle that verbatim ports carry the original's track record — shortest_path.py and the exact-constant maths don't need new tests for port fidelity. But that makes the case for concentrating effort on what actually changed, and at the moment the suite doesn't.

Classifying the ~2,200 new code lines: excluding shortest_path.py, only about a quarter is verbatim, grids/*.py is roughly 57% changed, and the numerically risky part is about 90 lines across six grid-I/O call sites — which is exactly where bug 1 lives, three times over. All the confirmed bugs are in changed or new code; none are in verbatim code.

Of the 33 tests, 3 make an exact numerical claim, 19 are sign/shape/NaN properties, and 11 are hasattr/raises/isfile. None use golden values from the original workflow, though reference outputs are committed in the source repos. Concretely: the coefficients, the spacing, and the valid-time epsilon could all change and the suite would stay green. Every distance-grid test passes a single age grid, so the batching behaviour is untestable by construction; and the fixture is written with write_netcdf_grid, which always emits ascending latitude, so bug 1 is invisible to it. test-cli.sh isn't referenced by any workflow and pybacktrack isn't in test-env.yml, so the CLI has no CI coverage and both Step 5 tests always skip.

One nuance on "verbatim needs no tests": it does where the port widened the envelope. generate_input_points_grid's floor(360/spacing) is verbatim, but only ever ran at 0.5 and 0.1 — --grid-spacing 0.3 now yields an actual spacing of 0.30025° while the filename still says 0.3d. Similarly Grid(6) was hardwired and is now a user-settable int, and proximity_geometries can now be empty or very large.

Tests covering the blocking fixes will land with those fixes here. The rest of the changed surface is written up as issue #464, and the CI/packaging side as issue #463.

Performance

Confirmed — and it's a regression introduced by the port rather than something pre-existing. ocean_basin_proximity.py is deliberately structured to batch, and says so in the API docstring, in the task-chunking comment that motivates min_num_age_grids_per_task = 10, and in the CLI help. One outer time loop services all age grids via ocean_basin_reconstructions, with the sharing at :1145-1152. grids/sediment_thickness.py:405 inverts that into a per-age-grid time loop.

Two corrections to the framing. "O(N²)" overstates it: total work is O(M·T) either way; what goes from O(T) to O(M·T) is the shared term, which my own profiling puts at 86–88% of per-time-step cost — so realistically 5× to 7.7×, not unbounded. And with obstacle routing restored as the default, this is the default path rather than an edge case.

Separately, TopologicalModel is built inside the per-time-step loop at :156-158 despite having no time dependence. That one's free.

None of the performance work blocks the merge, on the basis that the port reproduces the original workflow and just runs slower — that's the bar I'm applying. That includes the dropped internal/output grid-spacing split: that split and its upscaling filter were a speed optimisation on my side, not part of the method, so computing directly at output resolution is correct. Two things worth documenting rather than fixing: output computed directly at 0.1° won't be bit-identical to the published product, since the smoothing filter is absent; and the default grid_spacing=0.5 is coarser than the workflow's 0.1° output.

Questions I'd flagged — and the answers

Three things I couldn't settle from the code alone. I've since checked all three, so nothing here needs an answer from you.

Do any age grids in this chain actually store latitude descending? Effectively no, but not never. I swept every grid on my machine — 486 directories' worth of age grids, distance grids, published paleobathymetry and GPlates sample data — and 483 are ascending. Two of the three exceptions are cartopy's bundled sample data. The third is a real EarthByte age grid, and its history attribute says GDAL CreateCopy: GDAL writes north-up, i.e. descending, and its longitudes were outside −180..180 as well, so read_netcdf_grid's realign gate wouldn't have caught it either.

So anything written by GMT or by gplately is always safe, and anything that has been through GDAL, a GeoTIFF or a GIS is not. That downgrades bug 1 from "corrupts the shipped workflow" to "silently corrupts a plausible user-supplied input" — still worth fixing in a public API, and it means the test has to build its descending grid with netCDF4 directly, since write_netcdf_grid can't produce one.

Should crosby09 be rejected for Step 5? Yes. Steps 1–4's crosby09 is the Crosby & McKenzie (2009) empirical piecewise fit; pyBacktrack's CROSBY_2007 is the plate-cooling model from Crosby's 2007 thesis, with explicit mantle density, diffusivity and plate thickness. Different models, and the numbers say so:

 age   crosby09 (Steps 1-4)   CROSBY_2007 (Step 5)      diff
   0            2652.0 m              2601.4 m       +50.6 m
  20            4101.0                4150.7         -49.7
  75            5457.9                5517.8         -59.8
 100            5369.0                5461.8         -92.9
 140            5557.5                5656.8         -99.3
 160            5793.7                5703.5         +90.3

max |difference| over 0-200 Ma : 99.3 m
same check for gdh1            :  0.00 m   (port vs pyBacktrack, exact)

So the merged grid would carry a model discontinuity of up to ~100 m wherever present-day crust meets vanished crust. gdh1 is exact and rhcw18 shares the identical table, so it really is just this one model. That's blocking item 9 — better to raise now than to add a rejection after release.

--max_distance_threshold: help text vs. behaviour. Confirmed, and it's my bug carried over faithfully. The help says over-threshold distances are ignored and "will not contribute to mean / standard deviation"; both code paths set min_distance = math.pi and include it. The reason is that the lookup returns None for "unreachable through obstacles" and for "beyond threshold", and the unreachable case genuinely needs a value, so the two got conflated on the same line. The behaviour I actually want is π·R for unreachable and a real exclusion for over-threshold — but that changes published numbers, so for this PR I'd just make the docstring describe what the code does, and settle the semantics in issue #465 (upstream as well as here).

michaelchin and others added 2 commits September 21, 2026 14:26
Three independent gaps flagged in review of #449 (four new CLI parsers,
~40 flags, and Step 5/pyBacktrack support), none of which had CI coverage:

- Add tests-dir/pytestcases/test_10_paleobathymetry_cli.py: argparse-level
  tests (add_parser -> parse_args -> assert on the namespace) for all four
  new subcommands (paleobathymetry/pb, generate-distance-grids/gdg,
  generate-sediment-grids/gsg, generate-passive-margins/gpm), covering
  every flag, both aliases, and the two argparse-enforced validations
  (--age-depth-model's choices, --distance-grids-dir's required=True).
  These never call the actual command function, so they're fast (all 15
  pass in well under a second) and need no network or plate model,
  unlike tests-dir/test-cli.sh's end-to-end coverage of the same surface.
- Add pybacktrack to tests-dir/test-env.yml so CI actually exercises the
  two Step 5 tests instead of skipping them via HAS_PYBACKTRACK on every
  run. It's a noarch conda-forge package, so this doesn't add any
  per-platform build risk to the 15-job test matrix.
- Gate the 7 tests in test_9_paleobathymetry.py that pull a full
  Muller2019 plate model behind GPLATELY_TEST_LEVEL, matching the
  pattern already used for the comparable heavy tests in
  test_4_rasters.py, so they no longer download on every one of the 15
  matrix jobs and no longer error (rather than skip) when the network
  is unavailable in the default run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…try.py

Folds test_10_paleobathymetry_cli.py back into test_9_paleobathymetry.py
to keep one file per feature area, per request. Left a comment where the
section was merged in noting the tradeoff this gives up: these are fast,
offline, fixture-free argparse-wiring checks, mixed in with the rest of
the file's numeric/behavioural library tests (which often need the
Muller2019 fixtures and are sometimes network-gated) -- worth revisiting
if the file's dual nature becomes annoying in practice.

Module-name collision note: gplately.commands.paleobathymetry is
imported as paleobathymetry_cmd (etc.) to avoid shadowing the Step 4
paleobathymetry() function already imported from gplately.grids.paleobathymetry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@michaelchin
michaelchin marked this pull request as draft September 21, 2026 04:37
@michaelchin

Copy link
Copy Markdown
Contributor Author

convert back to "Draft" so that the tests don't run for every push.

jcannon-gplates and others added 11 commits September 21, 2026 22:29
sample_grid() learns the row order of a grid from the sign of its extent:
dy = (extent[3] - extent[2]) / (nrows - 1), and point_i = (lat - extent[2]) / dy.
Three age-grid sampling sites built that extent with np.min/np.max, discarding
the sign, so any grid stored with descending latitude was read upside-down --
southern ages applied to northern points, silently, with entirely plausible
output. Descending latitude is what GDAL writes, and therefore what any GeoTIFF
or GIS round-trip produces.

Use the grid's first/last coordinates instead, which is the idiom
read_netcdf_grid() already uses for its own internal resampling.

Adds an offline regression test asserting that the predicted sediment thickness
is unchanged when the same age field is stored the other way up, a guard test
pinning the assumption that a descending grid survives the read descending, and
a plate-model-gated end-to-end equivalent covering the other two sampling sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Output filenames carried the reconstruction time to a fixed number of decimal
places -- "{:.0f}" for the paleobathymetry, sediment-thickness and continent-mask
grids, "{:.1f}" for the mean-distance grids. That was safe in the workflows this
was ported from, because they forced whole-number times. The port added a time
range that permits fractional steps without widening the names, so
--time-step 0.5 over 0-2 Ma computed five grids and wrote three, the later times
overwriting the earlier ones with no warning.

The same two literals also reached pyBacktrack, whose Step 5 hand-off has its own
output_file_decimal_places_in_time and merge_paleo_bathymetry_file_... parameters,
both hardwired to 0 here. pyBacktrack builds its format as "{time:.Nf}", so with a
fractional step it went looking for grids nobody had written.

Add a keyword-only decimal_places_in_time to the five public entry points and to
all four CLI subcommands, threaded through to both pyBacktrack parameters so the
two halves cannot drift. Left unset, each output keeps the number of decimal
places its original workflow used -- one for the mean-distance grids, none for the
rest -- so existing filenames are unchanged. simple_paleobathymetry() forwards the
value unresolved for that reason: resolving it centrally would rename the distance
grids out from under the generate-sediment-grids subcommand that reads them back.

Where times would still collide, raise rather than overwrite, naming the two times,
the file they share and the value that would separate them. The check runs before
any reconstruction, including in the CLI, which would otherwise read every distance
grid off disk first.

Also folds the duplicated mean_distance_<spacing>d_<time>.nc format string, which
the library wrote and the CLI independently reconstructed, into one helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two different quantities had been bound to one CLI flag. --time-step chooses
which times get output; time_increment is how finely each ocean point's lifetime
is sampled while walking it backward, which the original workflow fixed at 1 Myr
and never tied to the output step. Binding them meant --time-step 10 also sampled
every 10 Myr instead of every 1 Myr: plausible output, wrong numbers.

Give the reconstruction increment its own --time-increment flag, defaulting to 1
as in the original, on the two subcommands that actually reconstruct.

The same conflation reached Step 5. pyBacktrack's time_increment is documented as
"the time increment that output is generated", i.e. the output step -- it was
being handed Step 2's reconstruction increment, so times [0, 10, 20] with the
default increment of 1 made pyBacktrack generate 0, 1, 2 ... 20 Ma and then look
for paleobathymetry_1Ma.nc, which Step 4 never wrote. It now takes the increment
from the spacing of the requested times, and refuses times that are not evenly
spaced, since it has no way to express those.

Each age grid's walk starts at its own time snapped up onto a shared grid of
multiples of the increment -- the structure that lets the original share work
between age grids. Times that do not land on that grid were therefore
reconstructed from the wrong starting time, so they are now rejected. That check
and the snap share one rule: the times a fractional step produces are not exact
(0 + 3 * 0.1 is 0.30000000000000004), and a plain ceil() sent that to 0.4, so the
snap rounds first where the quotient is a whole number to within a tolerance.

All of this is validated before a file is opened or a point reconstructed,
including the Step 5 prerequisites, which were checked only after Steps 1-4 had
already run.

Note for anyone using a fractional output step: --time-step 0.5 alone now raises
rather than silently sampling at 0.5 Myr, and wants --time-increment 0.5 (or
finer) passed explicitly. Defaulting the increment to the output step is what
this commit removes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three cases where the port accepted something the original workflow refused, or
produced output where the original produced none. All three share a failure mode:
a complete, plausible-looking grid that means the wrong thing.

Proximity features. The CLI fell back to the plate model's COBs layer when
--proximity-features was not given; the original refuses that in three separate
places. A COBs layer traces the entire continent-ocean boundary, active margins
included, where this workflow wants passive margins only -- so ocean points beside
a subduction zone come out close to a "margin". What the layer contains also
varies by model: muller2019's is line segments, while merdith2021's and the COB
Terranes sets are largely polygons. The fallback is gone, and the library now
refuses polygons outright, along with an empty feature list -- reachable through a
proximity_feature_types filter that matches nothing, and worth catching because
every point then reports half the Earth's circumference.

An age grid with no input points inside it wrote a grid of NaN, where the original
warns and moves on. A file full of NaN is indistinguishable from a real result
until something reads it, so it is skipped instead. Both callers were taught to
expect the gap: the library pipeline drops those times from the later steps, and
the CLI treats one missing distance grid as that skip while still failing when
none of them are there.

Contour and passive-margin features were given valid times of exactly
time +/- 0.5 * step, so adjacent slices both claimed the instant between them and
GPlates drew two sets of margins on top of each other at every interval boundary.
The original subtracts an epsilon from the begin time in three places; restored
here for the two features this writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 5 merges pyBacktrack's paleobathymetry into the grids Steps 1-4 produced, so
both sides have to convert age to depth with the same model. "crosby09" was mapped
onto pyBacktrack's AGE_TO_DEPTH_MODEL_CROSBY_2007, which is a different model
despite the name: gplately's is the empirical piecewise fit of Crosby & McKenzie
(2009), pyBacktrack's is the plate-cooling model of Crosby's 2007 thesis.
Measured against pybacktrack 1.5.0 over 0-200 Ma they differ by up to 218 m, at 86
Ma, and by 51 m at the ridge crest, which is a step change wherever the two
sources meet. For comparison the models that are kept agree to 0.41 m (gdh1) and
0.00 m (rhcw18).

Drop the mapping, and record why alongside it, since a pyBacktrack constant with a
matching name exists and re-adding the entry looks obviously right.

A substituted RHCW18 lookup table is refused for the same reason: it changes the
age-depth relationship for Steps 1-4 only, and pyBacktrack has no way to be handed
the same table.

Both are checked before Steps 1-4 run rather than when Step 5 starts, and model
aliases are resolved first, so a valid run spelled "richards" is not refused for
looking unfamiliar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Supersedes the approach in 9381cb9, which refused the models pyBacktrack has no
matching built-in for. pyBacktrack's ocean_age_to_depth_model accepts a callable
as well as one of its enumerated models -- "a callable function accepting a single
non-negative age parameter and returning depth (in metres)" -- so Step 5 can be
handed gplately's own conversion instead of the nearest-looking built-in. Both
sides then run one implementation rather than two that ought to agree.

Measured against pybacktrack 1.5.0 over 0-200 Ma, depths from Step 5 now match
Steps 1-4 exactly (0.00 m) for every model, where picking a built-in by name gave
0.41 m for gdh1, 218 m for crosby09 (whose CROSBY_2007 is a different model
despite the name), and nothing at all for parsons_sclater. A substituted RHCW18
lookup table now reaches Step 5 as well, instead of being refused because
pyBacktrack could not be given it. So the refusals are gone and parsons_sclater
gains Step 5 support it never had.

The callable is a functools.partial of a module-level function, not a closure:
pyBacktrack pickles the model when use_all_cpus is set, and a closure does not
survive that. It is memoised on the exact age, with no rounding, because
pyBacktrack calls it once per ocean point per decompaction step -- 43% of calls
are repeats in a 10-degree run, and the conversion otherwise runs a scalar through
array machinery. In a real Step 5 run it accounts for well under 1% of the time.

An unusable age-depth model, or an unreadable lookup table, is now reported before
Step 2 runs rather than from Step 4 at the end of it, and whether or not Step 5 was
asked for.

The crosby09-vs-CROSBY_2007 measurement is kept as a test: it is now the reason
for not using a built-in, rather than the reason for refusing a model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
None of this can be changed afterwards without a breaking change, and none of it
is on master yet, so this is the only free moment to get it right.

Make the optional parameters of the five workflow entry points keyword-only. The
signatures run to twenty parameters, and several are interchangeable by type but
not by meaning -- passing topological features where proximity features go is
accepted in silence and yields a plausible grid of wrong numbers. So the marker
goes after the first parameter on the four whose remaining required arguments are
confusable with each other, and after both required arguments on
generate_sediment_thickness_grids, whose list and dict are not. Every caller in
the repository already passed these by name, so nothing changed but the guarantee.

Add the thirteen names the port introduced to __all__. They were imported into the
package namespace but never exported, so `from gplately import *` omitted the
entire new API, and add the eight of them that Sphinx was missing, grouped by
workflow step rather than listed flat.

The export tests could not have caught either: they asserted hasattr, which is
true regardless of __all__ and of the docs. They now check all three, and skip
visibly rather than silently when run against an installed package with no
sphinx-doc/ beside it. A further test asserts the keyword-only property rather
than a list of parameter names, so one added later is covered too.

AGE_DEPTH_MODELS and DUTKIEWICZ_2017_SEDIMENT_THICKNESS carry `#:` doc-comments
now, so the generated pages describe them rather than showing the builtin tuple
and dict docstrings.

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

Four calls about code the original workflow made deliberately, three of which are
only recorded in the ancestors and would be undone by anyone tidying up.

Continent-obstacle routing is on by default again. The original ships
use_continent_obstacles: true (config.yml:134); the port made it opt-in, so the
documented workflow was not what the command ran. On by default has to tolerate a
plate model with no Coastlines layer, so an implicit run falls back to
straight-line distances with a warning where an explicit --route-around-continents
still fails; and because every -m/--model run now reaches for that layer, a
failure fetching it no longer takes down a run that never asked to route. Passing
--continent-obstacles together with --no-route-around-continents is refused rather
than silently ignoring the files. This makes the default path slower -- #459, #460
and #461 are where that is addressed.

The module's provenance was wrong. It descends from simple_paleobathymetry's
generate_continent_contours.py, not continent-contouring's
create_passive_margins.py: it splits a contour by walking get_points() as the
former does (generate_continent_contours.py:385) rather than get_segments() as the
latter does (create_passive_margins.py:376), and it reproduces text that exists
only in the former ("e.g. lakes", config.yml:113). Its defaults follow the
paleobathymetry-workflow parameter set, which is what they should be judged
against.

separation_distance_threshold_radians is omitted rather than passed as None, and
now says why: the engine reads an explicit None as zero separation, which merges
nothing and differs from its own default. Folding the condition away would change
the output while looking like a simplification.

The closed-ring seam rotation is marked as a deliberate deviation. No ancestor
rotates the ring, so where a passive stretch straddles the seam they emit two
polylines for one margin and this emits one. The union of arcs is unchanged, so
comparisons against a reference .gpmlz should be made on that rather than on
feature counts.

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

The merged static-polygons file that --pybacktrack writes when given several
StaticPolygons layers was a NamedTemporaryFile(delete=False) that nothing ever
deleted -- one abandoned .gpmlz in the system temp directory per run. It now lives
in a TemporaryDirectory that cleans itself up, with ignore_cleanup_errors so that
a file still held open cannot turn a finished run into a traceback after every
grid has already been written.

The 'paleobathymetry' description told users continent-obstacle routing was "not
yet included". It has been included all along, and is now the default.

distance_threshold_radians said it would "reject/ignore" proximities beyond the
threshold. It does not: a point with nothing in range is recorded at math.pi
radians -- half the Earth's circumference -- and averaged in like any other
sample, so a threshold pulls the affected means towards that maximum rather than
leaving them out. The same substitution covers a point that cannot be reached
around continent obstacles, because the lookup reports both cases identically. The
docstring now says so, and points at #465, where whether that is the right
behaviour is decided.

RHCW18_age_depth.dat gains the provenance note it was vendored without. It cannot
gain a licence statement: the upstream repository
(github.com/freddrichards/RHCW18_Plate_Model) states no licence at all, only a
request to cite two papers, so there is nothing to restate. The note records the
source, both citations, that this copy is byte-identical to
simple_paleobathymetry's, and that redistribution terms are unresolved -- with the
three ways to resolve them, since that needs a decision rather than a paragraph.
pyproject's data/*.* pattern ships it beside the table, and a test keeps the two
together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Findings from a review of the accumulated delta rather than of any one commit.
No numerical defect: this is stale text, one missing check, and one caveat.

Report a missing pybacktrack package before Steps 1-4 run. Every other Step 5
prerequisite is checked up front, but the package's own importability was not --
it surfaced from the import at the Step 5 call, hours later, on a machine that was
never going to be able to finish. Importing it in the prerequisite block is both
the check and the import.

--proximity-features' help still said "required unless -m/--model's plate model
provides a COBs layer". That fallback was removed two commits ago, so --help was
describing a run that now raises.

The comment above the Step 5 increment described an import that two reworks ago
sat beneath it and no longer exists. The continent_obstacle_features docstring
made the same point twice, once per commit that wrote it.

The valid-time epsilon is credited properly: it comes from continent-contouring's
create_passive_margins.py, while this module otherwise descends from
simple_paleobathymetry's generate_continent_contours.py, which has no equivalent.
Adopted from the sibling, not restored from the ancestor, and the module docstring
no longer claims to owe the other file nothing.

Finally, a caveat on Step 5's time round trip. pyBacktrack regenerates the output
times from the increment it is given, so they must survive formatting at
decimal_places_in_time. They do unless the precision sits exactly at the step's
rounding boundary -- [0, 0.15, 0.3, 0.45] at one decimal place, where Step 4
writes _0.5Ma.nc and pyBacktrack asks for _0.4Ma.nc. Reachable only by passing
such times directly; the CLI derives them the same way pyBacktrack does. The
failure is loud, and the docstring now says how to avoid it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream (github.com/freddrichards/RHCW18_Plate_Model) states no licence at all,
only a request to cite two papers, so there was never a licence for GPlately to
restate. Treating that citation request as the intended terms, and redistributing
on that basis with the attribution already recorded beside the data.

Written down so it is not re-litigated, along with what would change the answer:
explicit terms appearing upstream, or a need for a licence rather than an
inference -- in which case the options are to ask the authors, or to fetch the
table at run time instead of vendoring it.

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

Copy link
Copy Markdown
Contributor

Hi @michaelchin, the blocking items in #458 are complete, so you can mark it ready-for-review if you like.

I did one Ubuntu/3.12 dispatch after the last commit. Marking it will then unlock macOS, Windows and Python 3.10/3.11/3.13.

After this is merged I will work on the remaining PRs listed in #457.

@jcannon-gplates jcannon-gplates linked an issue Sep 21, 2026 that may be closed by this pull request
20 tasks done

This branch has not been deployed

No deployments
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.

Blocking fixes for #449

3 participants