Skip to content

Adding a Case

This guide explains how to add a new GeoCase case to the bundled catalog.

The current workflow is metadata-first:

  1. create a case folder and files,
  2. write the case metadata,
  3. add the case to case-index.yaml,
  4. verify that GeoCase can resolve and load it,
  5. add or update tests that exercise the new scenario.

What a case is

A GeoCase case is a directory of test data plus a metadata file that tells GeoCase:

  • what the data is,
  • why it exists,
  • how it should be loaded,
  • what behavior or risk it is meant to expose.

Each case becomes addressable by a unique ID, which means users can select it from pytest with @pytest.mark.geocase_case("your_case_id").


Step 1 — Pick the right location

Bundled core cases live under src/geocase/data/core/.

Use a category-specific folder such as:

  • src/geocase/data/core/vector/
  • src/geocase/data/core/raster/
  • src/geocase/data/core/netcdf/

Typical structure:

src/geocase/data/core/vector/my_new_case/
├── case.yaml
├── geometry.geojson
└── notes.md

For raster or NetCDF cases, the primary file will usually be a .tif, .tiff, or .nc file instead.


Step 2 — Copy the template

Start from the template:

  • src/geocase/templates/new_case.yaml

Copy it into your new case directory as case.yaml, then fill in the fields.

Example starting point:

id: my_new_case
title: My New Case
description: >
  Short description of what this case tests.
category: vector
format: GeoJSON
test_tier: unit
size_class: tiny
storage_class: bundled
redistributable: true
schema_version: "1.0"
status: draft

Step 3 — Fill out the important metadata

Required identity and classification fields

These fields describe the case at a catalog level:

  • id: unique identifier, lowercase with underscores
  • title: human-friendly name
  • description: what the case contains and why it matters
  • category: vector, raster, netcdf, or satellite
  • format: data format such as GeoJSON, GeoTIFF, or NetCDF
  • test_tier: such as unit, integration, or remote
  • size_class: such as tiny or small
  • storage_class: such as bundled or remote
  • status: lifecycle status such as draft or validated

Discovery fields

These make the case easy to select later:

  • tags
  • risk_types
  • geometry_type for vector cases
  • crs when relevant

Choose tags based on how users are likely to search for the scenario in tests. Good examples: vector, polygon, hole, invalid, nodata.

risk_types is a closed vocabulary. Pick an existing term from src/geocase/catalog/risk_types.py, or add one there in the same change — scripts/validate_catalog.py rejects anything else, and the error names the nearest canonical spelling. The complete term → cases index is on the catalog hub, and geocase.risk_types() returns it as a mapping.

Terms are family/specific where a family has more than one member:

  • extent/antimeridian, geometry/topology_error, attribute/encoding_error

A selector matches either the full term or the bare family prefix, so risk_types_any=["crs"] selects every crs/* case.

Two rules the vocabulary exists to enforce, both learned the hard way:

  • A risk type is a failure mode, not a label. format_comparison covered 37% of the catalog before it was moved to tags; anything that describes how the corpus is built rather than what can go wrong belongs in tags.
  • No risk type is better than a vague one. A case with no failure mode declares risk_types: []. The literal string "none" was used 9 times before this was written down, and it is the absence of a risk type spelled wrong.

Behavioral fields

These explain why the case exists:

  • behavioral_goal
  • expected_capabilities
  • loader_hint

Example:

behavioral_goal: >
  Ensure a polygon with an interior hole loads correctly and preserves
  the hole during downstream geometry operations.

expected_capabilities:
  - load
  - geometry-validation

loader_hint: geopandas
geometry_type: Polygon
crs: EPSG:4326

Where on Earth the case is: extent and region

A CRS is a coordinate convention, not a location -- two cases both declaring EPSG:4326 can be on opposite sides of the planet. Two optional fields carry the geography, and they are maintained differently:

# Generated by scripts/catalog_extent.py -- do not edit by hand.
# west > east means the box crosses the antimeridian.
extent:
  west: 10.0
  south: 50.0
  east: 11.0
  north: 51.0

# Hand-written; scripts/catalog_extent.py does not touch it.
region: "Central Europe (synthetic)"

extent is generated, never hand-typed. Add the case, then run:

python scripts/catalog_extent.py --write

It reads the case's real bytes -- reprojecting to WGS84 when needed, so a UTM raster publishes degrees rather than metres -- and writes the block above. validate_case_content.py then checks the declared box against the data, so a hand-edited extent that drifts from the fixture fails CI. Rerunning --write is a no-op when nothing moved, and it removes the block if the case stops having a resolvable position.

Not every case gets one. A deliberately malformed geometry, a payload with no CRS, coordinates outside the WGS84 domain (out_of_bounds_coordinates has a point at latitude 100), and NetCDF cases all leave extent unset rather than publishing an invented box.

region is yours to write. One short phrase naming the place, and be honest when the placement is synthetic -- most fixtures sit at a shared convenience origin, so "Central Europe (synthetic)" is accurate where "Germany" would imply real survey data. A case with no meaningful location can say so: "Unplaceable -- the geometry is deliberately malformed".

Both feed the Location row on the case's generated page, the schema.org/GeoShape box in its JSON-LD, and the world maps on the compare page.

Ground truth — ship the answer, not just the file

A case that ships only a file tells a consumer what to run against. A case that also ships the right answer tells them whether they got it right, and that is worth considerably more: it turns the case from a smoke test into a graded one.

Five fields on a raster case carry it, and like extent they are generated, never hand-typed:

assertions:
  expect_nodata: true
  # Generated by scripts/catalog_truth.py -- do not edit by hand.
  # expected_bounds is in the case CRS, not 4326 (see extent:).
  expected_mean_masked: 48.0787352901
  expected_mean_naive: -152.8628394157
  nodata_pixel_count: 2
  expected_bounds: [500000.0, 5600000.0, 510000.0, 5610000.0]

Add the case, then run:

python scripts/catalog_truth.py --write

The two means are the point of the pair. expected_mean_masked is the correct answer; expected_mean_naive is what a consumer that forgot to exclude the NoData sentinel actually produces. Declaring both means a grader can tell "this is wrong" from "this is wrong in the specific way this case exists to expose".

expected_bounds is in the case's own CRS, not 4326 — that is what separates it from extent above. On a rotated raster it is the axis-aligned envelope of the rotated footprint, not the four corners.

The fifth field, expected_pixel_world_pairs, is written only for rotated rasters, as [row, col, x, y] quadruples in the case's own CRS:

  expected_pixel_world_pairs:
    - [0.0, 0.0, 300003.6602540378, 5000146.339745962]
    - [7.0, 7.0, 300054.9038105677, 4999955.096189433]

On a north-up grid the round trip is origin + col * pixel, which any reader gets right and which expected_bounds already pins — so declaring it there would be noise. On a rotated one it is the operation that produced the only irreducible finding of the fourth validation round, and expected_bounds is merely the envelope: it says nothing about where an individual pixel went, which is exactly what was wrong. Shipping the pairs means a consumer asserts src.xy(row, col) == (x, y) against a declared answer instead of hand-rolling the inverse affine, which is what that reporter had to do.

validate_case_content.py checks all five against the real pixels, so a stale or hand-edited answer fails CI. The values appear under Known answer on the case's generated page and as variableMeasured in its JSON-LD.

Not every case gets them. The means are written only where the raster declares a NoData value — without one the two means are identical and the pair says nothing — and vector and NetCDF cases are out of scope.

Files section

The files section tells GeoCase which file is the primary artifact.

Example:

files:
  primary: geometry.geojson
  notes: notes.md

The primary path is relative to the directory that contains case.yaml.

Assertions section

Use the assertions block to describe the expected baseline behavior of the case.

Example:

assertions:
  expect_loadable: true
  expect_valid_geometry: true
  expect_crs: true
  expected_epsg: 4326
  expected_geometry_types:
    - Polygon

Vector cases: declare required_drivers when OGR cannot open the file

required_drivers answers one question, for one audience: what must a consumer using pyogrio, fiona, or ogr2ogr have installed before this case will open for them?

It says nothing about GeoCase. VectorCase.load() reads every bundled vector case without touching OGR -- WKB and WKT go through shapely, and Parquet/Feather/Arrow through geopandas' own Arrow readers. The field exists because an external validation run logged 20 cases as failures when the real answer was "you need a driver we never told you about".

Three tiers, so a consumer can filter before reading:

assertions:
  # Omit the field entirely: a stock GDAL build opens this.
  # (GeoJSON, GPKG, Shapefile, KML, GML, SQLite, FlatGeobuf, CSV_WKT)

  # Needs an optional GDAL plugin -- libgdal-arrow-parquet.
  required_drivers:
    - Parquet        # Parquet cases
    - Arrow          # Feather / Arrow / GeoArrow cases

  # No OGR driver exists at any build configuration: the payload is a bare
  # geometry blob with no container. WKB and WKT cases declare this.
  required_drivers:
    - ""

The empty string is the NO_OGR_DRIVER sentinel from geocase.catalog.models. It is deliberately falsy, so the natural filter excludes those cases without the consumer needing to know the sentinel exists:

available = set(pyogrio.list_drivers())
openable = [
    case
    for case in geocase.list_cases(category="vector")
    if all(d in available for d in case.assertions.required_drivers)
]

Note that loader_hint cannot answer this question -- it names the reader GeoCase dispatches to, so every vector case is geopandas regardless of what OGR can do with it.

Failure cases: expect_loadable × expect_valid_geometry

These two booleans read like one axis and are not. Together they name four cells, and each cell needs a different assertion from the consumer:

expect_loadable expect_valid_geometry What the case is What you assert
true true An ordinary well-formed case. assert_valid_geometry(gdf)
true false It loads. The geometry is either OGC-invalid (a bowtie) or OGC-valid but semantically wrong (null island, a lat/lon swap). assert not geom.is_valid, or a domain check -- read the case's risk_types to tell which.
false false It never constructs. The reader raises before validity can be asked. pytest.raises(...)
false true Contradictory -- do not write it.

The third row is the one that catches people out. unclosed_ring_polygon and self_intersecting_polygon both declare expect_valid_geometry: false, but the first raises GEOSException from shapely and the second returns a perfectly ordinary object whose .is_valid is False. A harness that writes assert not geom.is_valid for both fails on the first for the wrong reason.

Note the asymmetry in row two: GeoCase's content gate enforces expect_valid_geometry: true but not false, because false carries those two distinct meanings and asserting invalidity would fail the semantically-wrong-but-OGC-valid cases for a schema limitation rather than a data defect.

Failure cases: declare expected_error_kind

When a case declares expect_loadable: false, also say how it fails:

assertions:
  expect_loadable: false
  expected_error_kind: unparseable_geometry

Without this, a harness can assert only that the case failed -- so "failed for the curated reason", "failed because a driver is missing", and "failed because the consumer has a new bug" are indistinguishable, and the case gives a green light in all three.

The vocabulary is deliberately small, and deliberately not exception class names: the class is the consumer's, and the same unclosed ring surfaces as GEOSException from shapely, DataSourceError from pyogrio and ValueError from pandas.

Kind Means
unparseable_geometry The bytes yield no geometry at all -- an unclosed ring, a truncated WKB, malformed JSON. Nothing was constructed, so no validity question arises.
unsupported_format The container is understood but this variant is not.
missing_driver The reader has no driver for the format. Installing something is the fix -- see required_drivers, which lets a consumer predict this before reading.
invalid_crs The CRS definition itself will not construct.
invalid_topology The geometry constructs, but the operation rejects it rather than repairing it.

The field is gated in both directions. AssertionHints rejects it on a case that is not expect_loadable: false -- a loadable case has no failure mode to declare -- and scripts/validate_case_content.py opens the file, catches what it raises, and reports a finding if the observed failure does not match the declared kind. A declaration nothing evaluates is the defect the content gate exists to close, so the taxonomy is checked against real bytes like every other assertion.

Recording a consumer divergence: known_divergences

known_divergences is a top-level block (a sibling of assertions, not a field inside it). It records disagreements between two ways of reading the same case that someone has already investigated:

known_divergences:
  - consumer: pyogrio
    version_range: "pyogrio >=0.11, GDAL 3.12-3.13 (originates in GDAL)"
    description: >
      Under a spatial filter, the Arrow path returns the NULL-geometry row that
      the numpy path and GDAL's own `ogrinfo -spat` both exclude -- 3 rows
      against 2.
    upstream_url: https://github.com/OSGeo/gdal/issues/12345

consumer and description are required; version_range and upstream_url are optional but strongly worth filling in, since together they answer "is this still open?" without re-running anything. version_range is free text on purpose — the relevant version is often a transitive one (GDAL under pyogrio) that no Python version specifier addresses.

This is a record, not an assertion. Nothing in the content gate verifies it, and nothing can: whether the divergence still reproduces depends on the reader the user has installed, not on geocase's bytes. What the corpus does gate (tests/unit/test_known_divergences.py) is that every record is attributed and readable.

The payoff is in geocase.differential: a divergence matching a record is reported as known rather than diverged, so a repeat run surfaces only what is new. Add a record when you have investigated a divergence and reached a conclusion about it — an unexplained one belongs in the case's notes.md, where it reads as an open question rather than as a settled non-finding.

Raster cases: always declare expected_shape

For a raster case, declare the pixel dimensions as [height, width] (bands are not part of it):

assertions:
  expect_loadable: true
  expect_crs: true
  expected_epsg: 32633
  expected_shape: [10, 10]

This does double duty. validate_case_content.py checks the declared shape against the real pixels, and expected_shape is also the selector that earns the case a rendered pixel preview on its catalog page -- a raster without one falls back to the metadata band-stack schematic. Declare it and regenerate with python scripts/generate_raster_previews.py.

NetCDF cases: declare dimensions in order

NetCDF cases are content-checked like the other two categories. The typed assertions block carries what it can, and the rest goes in params, where check_netcdf_content reads it:

assertions:
  expect_loadable: true
  expect_nodata: true

params:
  expected_dimensions:
    - longitude
    - latitude
    - time
  expected_variables:
    - t2m
  expected_time_units: "hours since 2020-01-01 00:00:00"

expected_dimensions is compared in order, not as a set. That is what makes a dimension-ordering claim checkable at all — a case asserting non-conventional x-before-y ordering is only meaningful if reordering the file's dimensions makes the gate fail.

Two declarations need real bytes behind them:

  • expect_nodata requires a data variable that declares _FillValue or missing_value.
  • expect_crs requires a grid_mapping attribute or a crs/spatial_ref variable. Most simple lat/lon files have neither, and declaring a CRS anyway is how latlon_small shipped an unverifiable claim for a year — the crs: top-level key still records the coordinate system as documentation.

NetCDF cases leave extent unset (see above), so give them a hand-written region or their catalog page loses its Location row.

Finally, the primary file must be generated, not hand-placed. Add a NetCDFSpec to scripts/generate_netcdf_fixtures.py and run it:

python scripts/generate_netcdf_fixtures.py
python scripts/generate_netcdf_fixtures.py --check

--check compares semantics — dimensions, variable dtypes, packing attributes, coordinates, global attributes — rather than bytes, because HDF5 stamps its library version into every file and a byte gate would fail on a dependency bump that changed no data. Set packing (scale_factor, add_offset, dtype) explicitly in the spec's encoding; left to xarray it is re-derived on write, and a packed fixture's whole point becomes an artifact of the library version.

Optional custom parameters

Use params for case-specific values that tests may want to read.

This is especially useful when a function test needs metadata such as:

  • expected output file names,
  • thresholds,
  • known result values,
  • special-case flags.

Example:

params:
  expected_footprint: all_valid_rectangular_footprint_truth.geojson
  min_rect_ratio: 0.99

expected_footprint must name ground truth — geometry derived from the raster's own valid-pixel mask, not a recording of what some tool returned for it. The content gate reads this key and checks the declared geometry against a freshly re-derived mask on part count, area and hole count, so a hull or a simplified polygon fails there. If you also want to pin one consumer's answer as a regression baseline, put it in a separate file and name it for what it is:

params:
  expected_footprint: rotated_two_islands_footprint_truth.geojson
  recorded_gdal_footprint: rotated_two_islands_footprint_gdal_hull.geojson
  min_rect_ratio: 0.35

Derive min_rect_ratio from the truth geometry too. Fitting it to a hull makes it pass trivially — a convex hull is near-rectangular by construction — which is how three cases came to assert thresholds their real shapes did not meet. See docs/plans/32-footprint-truth-and-ambiguous-zero.md.


Step 4 — Add the case to the index

Bundled cases are discovered through:

  • src/geocase/metadata/case-index.yaml

Add a new entry under cases:.

Example:

cases:
  - path: data/core/vector/my_new_case/case.yaml

You can regenerate the index automatically with:

python scripts/build_case_index.py

Recommended follow-up checks:

python scripts/build_case_index.py --check
python scripts/validate_catalog.py

Step 5 — Check a real example

Use an existing case as a model. A good starting point is:

  • src/geocase/data/core/vector/simple_valid_polygon/case.yaml

That case shows a clear, compact metadata file with:

  • good tags,
  • a useful behavioral goal,
  • explicit assertions,
  • a bundled primary data file.

End-to-end example: simple_valid_polygon

This case is a good reference because it is intentionally small and easy to understand.

Folder layout:

src/geocase/data/core/vector/simple_valid_polygon/
├── case.yaml
├── geometry.geojson
└── notes.md

What each file does:

  • case.yaml defines how the case is discovered and interpreted.
  • geometry.geojson is the actual vector data.
  • notes.md explains the testing purpose in plain language.

Key metadata choices in this case:

  • id: simple_valid_polygon gives tests a stable explicit selector.
  • category: vector and format: GeoJSON place it in the vector workflow.
  • tags: [vector, polygon, valid, baseline] make it discoverable.
  • behavioral_goal explains that it is the baseline happy-path polygon.
  • assertions encode the expected baseline checks.

Index entry:

cases:
  - path: data/core/vector/simple_valid_polygon/case.yaml

Minimal pytest check using that case:

import pytest


@pytest.mark.geocase_case("simple_valid_polygon")
def test_simple_valid_polygon_smoke(geocase_case) -> None:
    gdf = geocase_case.load()

    assert geocase_case.id == "simple_valid_polygon"
    assert len(gdf) > 0
    assert gdf.crs is not None

That is the full GeoCase loop:

  1. data file exists,
  2. metadata describes it,
  3. index exposes it to the registry,
  4. a pytest marker selects it,
  5. your test loads and checks it.

When creating a new case, aim for this same level of clarity before adding more complex behavior.

Raster example: geotiff_nodata_small

For raster cases, the structure is very similar, but tests will usually use .read(1) or .open() instead of .load().

Folder layout:

src/geocase/data/core/raster/geotiff_nodata_small/
├── case.yaml
├── nodata_sample.tif
├── nodata_sample.tif.aux.xml
└── notes.md

Why this case is useful:

  • it is tiny and fast to load,
  • it exercises explicit NoData handling,
  • it provides concrete expected metadata in params.

Notable metadata choices:

  • category: raster
  • format: GeoTIFF
  • loader_hint: rasterio
  • risk_types: [nodata/ignored, data/nan_propagation, measurement/incorrect_statistics]
  • params.nodata_value: -9999
  • params.band_count: 1
  • params.dtype: float32

Index entry:

cases:
  - path: data/core/raster/geotiff_nodata_small/case.yaml

Minimal pytest check using that case:

import pytest


@pytest.mark.geocase_case("geotiff_nodata_small")
def test_geotiff_nodata_small_smoke(geocase_case) -> None:
    data, _, nodata = geocase_case.read(1)

    assert geocase_case.id == "geotiff_nodata_small"
    assert nodata == -9999
    assert data.size > 0

If your function needs a raster dataset handle instead of an array, use geocase_case.open() and run rasterio-based assertions there.


Step 6 — Verify the new case

At minimum, verify the following:

  1. the metadata is valid and readable,
  2. the indexed path is correct,
  3. the case loads through GeoCase,
  4. the case can be selected by ID,
  5. the case supports the intended assertions.
  6. related suites include (or intentionally exclude) the new case.

Practical checks today:

import pytest


@pytest.mark.geocase_case("my_new_case")
def test_my_new_case_loads(geocase_case) -> None:
    loaded = geocase_case.load()
    assert loaded is not None

If the case is raster-based, use .read(1) or .open() instead of .load().

Suite and catalog checks:

python scripts/validate_catalog.py

Step 7 — Add targeted tests

Every new case should justify itself by improving test coverage.

Common patterns:

  • add a new unit test that directly selects the case,
  • extend a selector-based test so the new case participates naturally,
  • add case-specific expectations using geocase.id or geocase.metadata.params.

Examples:

@pytest.mark.geocase_case("my_new_case")
def test_specific_behavior(geocase_case) -> None:
    data = geocase_case.load()
    assert data is not None
@pytest.mark.geocase_select(category="vector", tags_any=["hole"])
def test_all_hole_cases(geocase) -> None:
    gdf = geocase.load()
    assert len(gdf) > 0

Writing a good case

A strong case is:

  • small enough to keep the repo lightweight,
  • focused on one clear failure mode,
  • easy to describe in one paragraph,
  • tagged so users can discover it later,
  • accompanied by a test that demonstrates why it exists.

Try not to add a case that is only “interesting data”. Add a case that captures a real testing need.


Before considering a case complete, check all of the following:

  • folder is placed under the correct category path,
  • case.yaml is filled out completely,
  • id is unique and stable,
  • files.primary points to the right artifact,
  • tags and risk types are useful for selection,
  • case-index.yaml contains the new path,
  • suite membership is reviewed (src/geocase/catalog/suites/*.yaml),
  • at least one test exercises the case,
  • notes or provenance are included when helpful,
  • case size is kept as small as practical.

Current limitations

Catalog validation is handled by maintainer scripts:

python scripts/build_case_index.py --check
python scripts/validate_catalog.py

  • docs/getting-started.md
  • docs/contributing/vector-dataset-generation.md
  • docs/testing-your-function-with-geocase.md
  • docs/contributing/workflow.md
  • docs/plans/development-plan.md