Skip to content

Domain test matrix: sweep tests across 17 lat/lon topologies - #274

Draft
manishvenu wants to merge 17 commits into
mainfrom
domain-test-matrix
Draft

manishvenu wants to merge 17 commits into
mainfrom
domain-test-matrix

Conversation

@manishvenu

@manishvenu manishvenu commented Aug 19, 2026

Copy link
Copy Markdown
Member

Stacks on #265 (refactor-obc-ic) — merge that first, it brings REFERENCE_OCEAN.

Descrip:

The suite only ever exercises panama1 — a 4°×3° box at 278°E/7°N, the easiest corner of the parameter space (northern, mid-latitude, 0–360, no seam, axis-aligned). Every seam and polar bug we've hit was found by hand on a one-off case, not by the suite. This adds a catalog of 17 domains and sweeps grid + bounding-box tests across all of them. Pure in-memory grids, no network, no CESM — the full sweep is 8 seconds.

Changes:

  1. tests/fixtures/domains.py — the whole framework in one file: DomainSpec dataclass, 17-row catalog, selection function, fixtures. Auto-discovered by the existing fixtures/*.py glob, so adding a domain is adding one row. Covers seams (prime meridian, dateline), convention pairs (the same domain spelled 0–360 and −180–180), polar caps (EPSG:3995/3031), rotated, thin/tiny, and the RectilinearCartesianSupergrid branch nothing else exercises.

  2. tests/domains/test_grids.py — grid invariants across the catalog: builds, coordinates finite and on-sphere, supergrid shape, is_rectangular(), all seven metrics finite and positive, write_supergridfrom_supergrid roundtrip.

  3. tests/domains/test_bounding_boxes.py — sweeps Grid.get_bounding_boxes, the chokepoint between a domain and the data downloaded for it. The span assertion now uses the largest angular gap between successive longitudes, distinguishing a genuinely global range (a polar cap) from a small domain that merely straddles the antimeridian. rotated_on_dateline is xfailed: 5.2° true span, 360° bbox.

  4. Selection flags: --all-domains, --domain-tags=seam,polar, --domains=arctic_cap; default is a 6-domain cheap tier. A bad key or tag raises UsageError at collection, listing the valid ones.

  5. recipe.build_grid dispatches on grid.type (mirroring build_topo/build_vgrid) so from_projection and from_center are reachable from YAML. They weren't before — a polar cap or rotated domain could not be expressed in a config at all, not from the CLI, not from crocodash create, not from MCP.

Minor changes:

  • Removes --runslow; it reimplemented -m "not slow". The ~14 @pytest.mark.slow decorations stay as plain markers, and CI's pytest line gains -m "not slow" to preserve current behaviour.
  • environment.yml declares pyproj, needed by from_projection/from_center and currently arriving only transitively.
  • No end-to-end tier here (removed in 15e155c, recoverable from 6666484). REFERENCE_OCEAN synthesizes data for whatever bbox it's handed, so a wrong bbox still yields clean output — the sweep passed for that reason, not because the boxes were right. Bounding-box correctness is asserted directly instead; the pipeline sweep goes to crocontainer#7.

manishvenu and others added 10 commits August 10, 2026 09:40
MOM6/CESM's FMS-based calendar reader accepts "gregorian" but not the
also-CF-valid "standard" -- CrocoDash-generated cases were crashing at
OBC init / CESM CALENDAR setup with a FATAL calendar-attribute error
(#261).

Calendar gains a third field (mom6) alongside the existing cf/cesm
ones, so a product's CF-correct "standard" time-decode calendar can
stay separate from the literal string regridded output files must
carry. obc.py's OBC regrid step now stamps mom6_calendar
("gregorian"/"noleap") instead of cf_calendar ("standard"/"noleap")
on its output.
CrocoDash#261: extract_forcings/config.json's "conditions" entry
duplicated start_date/end_date under both inputs and outputs (outputs
was a pure passthrough of inputs), and only had a single "step" value
that fed regrid chunking -- the GET/retrieval phase silently ignored
it and always fetched the whole date range in one request regardless
of what "step" said.

Drop the redundant start_date/end_date ConfigOutputParams; driver.py
and recipe.py now read them from conditions["inputs"] instead.
Replace the single "step" output with get_step_days/regrid_step_days
so both process_obc_conditions() chunk params (which already existed
independently) are actually wired to config.json.
CrocoDash#261: users never edit this file directly -- only config.json
is meant to be touched in the LDW -- so prefix it with an underscore
to signal that. FILENAME lives in one place (case_state.py); every
other reference was via case_state.write()/read() and just followed.
Split extract_forcings/obc.py and the old initial_condition.py into
model-agnostic GET/REGRID[/MERGE] engines (obc.py, ic.py) plus a
MOM6-specific callback layer (mom6.py) supplying the regrid step via
regional_mom6's Segment/experiment classes. driver.py's ic/bc dispatch
now goes through mom6.process_mom6_ic/process_mom6_obc.

Bumps the rm6 submodule pointer to pick up the newer experiment/segment
API (Grid/VGrid objects passed directly via hgrid_type/vgrid_type
instead of hgrid_path/vgrid_path strings; rotational_method removed
from regrid_velocity_tracers), which also required updating tides.py
and case.py's expt property to the new construction pattern.

Ported from work already done on the cice branch; this pulls out just
the generic engine split, without the CICE/WW3-specific pieces, so it
can land as its own layer beneath the forcing-configurator refactor.
Splits ForcingProduct's velocity/tracer/SSH metadata out into
VelocityTracerForcingProduct -> MOM6ForcingProduct, so a forcing
product's model-specific regridding contract is explicit rather than
folded into one generic class. Re-parents GLORYS and MOM6_OUTPUT onto
MOM6ForcingProduct.

Adds REFERENCE_OCEAN, a fast deterministic synthetic ocean IC/OBC
product (no network/credentials required) for tests and demos that
exercise the MOM6 IC/OBC pipeline without real data access.

Ported from the cice branch, split down to just the MOM6-relevant
piece -- CICEForcingProduct/WW3ForcingProduct and the REFERENCE_ICE/
REFERENCE_WAVES products land with the cice/ww3 branches instead.
…tor-obc-ic

Kept our stack's rm6 submodule pointer (1c48b714) rather than main's
side's older, diverged one -- ours has commits this stack already
depends on (Segment ocean_side rename, mom6_forge._supergrid rotation,
top-level Grid/Topo/VGrid exposure) that main's pointer doesn't have.
The "ignore degenerate source cells" OBC regridder fix on main's side
needs separate follow-up to confirm whether it's already covered on
our rm6 lineage.

Manually ported the two small non-rm6 fixes main brought to files this
branch already rewrote:
- ic.py: log data_access_function._how_to_use, same as
  initial_condition.py (now deleted, superseded by ic.py/obc.py) used to.
- obc.py: simplified chunk-end date-slicing to plain date-string slicing
  (pandas partial-string indexing already covers the whole calendar day),
  dropping the old +23:59:59 workaround.

raw_data_access/datasets/mom6_output.py deleted (superseded by
cesm_ocean_output.py, which arrived via a clean auto-merge of
raw_data_access/base.py's registry).
REFERENCE_OCEAN previously only had tracer_var_names (temp/salt), so any
MARBL-enabled compset (%MARBL-BIO) hard-failed in write_metadata() with
"This product does not have marbl tracer var names". Add the same
marbl_var_names set CESM_POP_OUTPUT uses, identity-mapped, and generate a
small-constant placeholder variable per tracer in get_reference_ocean_data
so downstream IC/OBC regridding has real data to read.
Until now the whole suite exercised a single grid -- panama1, a 4x3 deg
box at 278E/7N -- which sits in the easiest corner of the parameter
space: northern hemisphere, mid-latitude, 0-360 convention, no seam,
axis-aligned, rectangular. Every seam and high-latitude bug found so far
(GLORYS antimeridian slicing, the mom6_forge dx metric bug, the polar
get_bounding_boxes fixes) was found by hand on a one-off case, not by
the suite.

Add a catalog of 17 domain specs covering the lat/lon topologies
CrocoDash can be handed, plus a pytest layer that parametrizes any test
over it. Writing a regression test for a new seam bug is now a one-line
`def test_x(domain_grid)`.

tests/fixtures/domains.py holds the whole framework -- dataclass,
catalog, selection, fixtures -- in one file, auto-discovered by the
existing fixtures/*.py glob. Adding a domain is adding one row.

Selection, in precedence order:

    pytest tests/domains                        # cheap tier (6 domains)
    pytest tests/domains --all-domains
    pytest tests/domains --domain-tags=seam,polar
    pytest tests/domains --domains=arctic_cap

Three test modules:

  test_grids.py           pure grid invariants; metrics finite and
                          positive; supergrid roundtrip; 0-360 vs
                          -180/180 convention pairs agree
  test_bounding_boxes.py  Grid.get_bounding_boxes, the chokepoint
                          between a domain and the data fetched for it
  test_forcing_pipeline.py  end-to-end against REFERENCE_OCEAN; asserts
                          no output is entirely NaN, the characteristic
                          symptom of a bbox bug that completes silently

Three mom6_forge bugs are pinned as strict xfails so their fixes
announce themselves: the dx/dy sign and antimeridian wrap (NCAR/
mom6_forge#113), the tarea quadrant double-count (NCAR/mom6_forge#126),
and the inflated bounding-box longitude span on a rotated domain
crossing the dateline. cyclic_global is pinned too -- get_bounding_boxes
asserts against cyclic grids outright, a known v1 limitation.

Also replace --runslow with the standard `slow` marker. It reimplemented
what -m "not slow" already does. Slow tests now run by default, so CI
gains -m "not slow" to keep its current behaviour.

CrocoDash_case_factory takes an optional grid_topo_vgrid, defaulting to
the session fixture as before. environment.yml declares pyproj, which
Grid.from_projection and Grid.from_center need and which arrived only
transitively.
@github-actions

Copy link
Copy Markdown

📄 Preview your docs here:
👉 https://CROCODILE-CESM.github.io/CrocoDash/pr-274/index.html

The end-to-end sweep built a real CESM Case per domain and ran
configure_forcings/process_forcings against REFERENCE_OCEAN. That is
~3 minutes for the six-domain cheap tier and ~10 for --all-domains, it
needs a real CESM root so it cannot run on a laptop, and CI's
-m "not slow" does not deselect `workflow`, so it would have landed in
CI by default.

The cost would be worth it if the tier caught what the cheap tests
cannot, but it does not: REFERENCE_OCEAN synthesizes data for whatever
bounding box it is handed rather than slicing a fixed global dataset, so
a wrong bbox still produces clean non-NaN output. Every seam and polar
pipeline test passed for that reason, not because the boxes were right.
What it actually proved was "the pipeline does not crash on this
topology" -- worth having, but that is what crocontainer is for.

Bounding-box correctness is asserted directly in test_bounding_boxes.py,
which is where the value was all along.

Removes test_forcing_pipeline.py and the scaffolding that existed only
for it: the domain_case and domain_grid_topo_vgrid fixtures,
configure_domain_forcings() and its cvars-snapshot dance, and the
grid_topo_vgrid argument added to CrocoDash_case_factory. All of it is
recoverable from 6666484 when the crocontainer follow-up needs it.
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
see 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

recipe.build_grid handled two of the four ways to make a Grid:
Grid(**cfg), and Grid.from_supergrid when the config carried
supergrid_path. from_projection and from_center were unreachable, so a
polar cap or a rotated domain could not be expressed in a YAML config at
all -- not from the CLI, not from crocodash create, not from the MCP
server. Four of the five most interesting rows in the domain catalog
were unreachable from YAML for that reason.

Dispatch on grid.type, mirroring what build_topo and build_vgrid already
do with topo.source.type and vgrid.type.

grid.type carries two families of value. from_file, from_projection and
from_center name the alternative constructors and are stripped before
the remaining keys are forwarded. uniform_spherical and
rectilinear_cartesian are the supergrid flavours Grid.__init__ has
always taken, and are passed through to it untouched -- Grid(**cfg)
already forwarded `type`, so rectilinear_cartesian may already appear in
configs in the wild and must not be swallowed by the new dispatch. A
config with no type at all behaves exactly as before.

DomainSpec.builder is now spelled with the same values as grid.type, and
DomainSpec.to_grid_config() emits the config block, so
test_domain_is_expressible_as_a_recipe_config can build every catalog
domain both ways and require the coordinates to match. That test is what
caught the type-key collision above: the cartesian_rectilinear row
carries its own type=, which an earlier version of this dispatch
clobbered.

case_to_yaml is unchanged -- it always writes supergrid_path, so a grid
round-trips through its written supergrid file however it was built.
@manishvenu
manishvenu marked this pull request as draft August 20, 2026 16:15
regional_mom6.py imports ruamel.yaml at module scope, so nothing in the
suite can even be collected without it -- tests/conftest.py auto-globs
fixtures/, which pulls in the whole CrocoDash -> rm6 chain. It was
arriving only transitively, which held right up until an environment
rebuild resolved without it.

Same class as the pyproj declaration above.
Three of these tests documented open mom6_forge bugs as strict xfails.
That works only against the checkout they were written for: point
CrocoDash at a mom6_forge carrying NCAR/mom6_forge#113 and #126 and the
suite reports 22 failures, 20 of them XPASS(strict) -- the fixes landing
read as breakage.

Ask the installed mom6_forge what it does instead. Two probes build a
throwaway 4x4 grid (and one 4x4 polar cap) and check the behaviour
directly, so tarea_matches_supergrid_quadrants and
grid_metrics_positive_and_finite xfail only where the bug is actually
present. lru_cache keeps it to one probe per session.

The bounding-box test needed a different answer, because #113 does not
fix that bug -- it widens any >180-degree raw span to the full range
rather than narrowing it, to stop lon_max landing exactly on 180.0 and
being collapsed to -180.0 downstream. Narrowing would need the box to
carry a wrapped range (lon_min > lon_max) that every consumer would have
to understand.

So the assertion is now the sharper one it should have been: a domain
whose true angular span is modest must not get a near-global bounding
box. Domains that genuinely span most of the globe are exempt, since no
box can be too wide for them and there is nothing useful left to assert.
That separates the polar caps -- 358.6 degrees, legitimately -- from
rotated_on_dateline, which is 5.2 degrees wide and reports 360. The old
assertion happened to pass on the caps by comparing against their own
raw span, which was accidental rather than meaningful.

Verified both ways on the full 17-domain sweep:
  pinned mom6_forge       209 passed, 6 skipped, 27 xfailed
  #113 + #126             229 passed, 6 skipped,  7 xfailed
No failures either way, so bumping the mom6_forge pointer is now a
no-op for this suite rather than a 22-failure event.
manishvenu added a commit that referenced this pull request Aug 20, 2026
Picks up the capability probes, so this branch's domain tests pass whether
or not the CrocoDash checkout's nested mom6_forge carries #113/#126.
process_forcings hangs indefinitely in _regrid_obc_chunk's staging
to_netcdf, at a rate around one case in four on a CPU-constrained machine.
Three consecutive crocontainer domain sweeps wedged on 1, then 3, then 4 of
16 domains, with disjoint sets each time -- every one of the 16 domains
passes on some run and fails on another, so the grid topology is not the
trigger. The faulthandler stack is identical in every case: the main thread
parked in dask.local.queue_get waiting on a worker inside HDF5 that never
returns.

Wrapping the write in dask's synchronous scheduler removes the thread pool
the deadlock needs, and leaves the chunk-by-chunk memory profile unchanged
(unlike .load(), which would also fix it but pull each chunk fully into
memory). This is the same guard, for the same reason, as
_download_initial_condition in ic.py.

_merge_boundary in obc.py gets it too. That write has not been observed to
wedge, but open_mfdataset makes it dask-backed and it is the identical
construction two functions from a known deadlock.

Left alone: mom6.py's write_regridded_ic_file, whose open_dataset is eager
and never builds a dask graph, and bgc.py's writes, which this sweep does
not exercise.

Expect these writes to be somewhat slower now that they are serialised.
Negligible on the sweep's ~12s domains; worth measuring on a large
production case.
The catalog already covers each singularity alone -- equator_straddle sits at
lon -10, prime_meridian_seam at lat 45 -- but nothing covered the origin
corner, where latitude and longitude change sign in the same domain. That is
the case where a bug in one axis can be masked or amplified by the other, and
it is cheap: a 6x6 degree box centred on 0N/0E.

Tagged cheap so it runs in the default sweep, and both equator and seam so it
is picked up by either tag slice.
Base automatically changed from refactor-obc-ic to main September 15, 2026 04:29
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