Adding a Case¶
This guide explains how to add a new GeoCase case to the bundled catalog.
The current workflow is metadata-first:
- create a case folder and files,
- write the case metadata,
- add the case to
case-index.yaml, - verify that GeoCase can resolve and load it,
- 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:
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 underscorestitle: human-friendly namedescription: what the case contains and why it matterscategory:vector,raster,netcdf, orsatelliteformat: data format such asGeoJSON,GeoTIFF, orNetCDFtest_tier: such asunit,integration, orremotesize_class: such astinyorsmallstorage_class: such asbundledorremotestatus: lifecycle status such asdraftorvalidated
Discovery fields¶
These make the case easy to select later:
tagsrisk_typesgeometry_typefor vector casescrswhen 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_comparisoncovered 37% of the catalog before it was moved totags; anything that describes how the corpus is built rather than what can go wrong belongs intags. - 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_goalexpected_capabilitiesloader_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:
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:
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:
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:
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):
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_nodatarequires a data variable that declares_FillValueormissing_value.expect_crsrequires agrid_mappingattribute or acrs/spatial_refvariable. Most simple lat/lon files have neither, and declaring a CRS anyway is howlatlon_smallshipped an unverifiable claim for a year — thecrs: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:
--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:
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:
You can regenerate the index automatically with:
Recommended follow-up checks:
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:
What each file does:
case.yamldefines how the case is discovered and interpreted.geometry.geojsonis the actual vector data.notes.mdexplains the testing purpose in plain language.
Key metadata choices in this case:
id: simple_valid_polygongives tests a stable explicit selector.category: vectorandformat: GeoJSONplace it in the vector workflow.tags: [vector, polygon, valid, baseline]make it discoverable.behavioral_goalexplains that it is the baseline happy-path polygon.assertionsencode the expected baseline checks.
Index entry:
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:
- data file exists,
- metadata describes it,
- index exposes it to the registry,
- a
pytestmarker selects it, - 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: rasterformat: GeoTIFFloader_hint: rasteriorisk_types: [nodata/ignored, data/nan_propagation, measurement/incorrect_statistics]params.nodata_value: -9999params.band_count: 1params.dtype: float32
Index entry:
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:
- the metadata is valid and readable,
- the indexed path is correct,
- the case loads through GeoCase,
- the case can be selected by ID,
- the case supports the intended assertions.
- 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:
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.idorgeocase.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.
Recommended author checklist¶
Before considering a case complete, check all of the following:
- folder is placed under the correct category path,
case.yamlis filled out completely,idis unique and stable,files.primarypoints to the right artifact,- tags and risk types are useful for selection,
case-index.yamlcontains 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:
Related docs¶
docs/getting-started.mddocs/contributing/vector-dataset-generation.mddocs/testing-your-function-with-geocase.mddocs/contributing/workflow.mddocs/plans/development-plan.md