Changelog¶
All notable changes to GeoCase are documented here.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
Corpus changes: name what changed¶
Any change to a case's geometry, CRS, dtype, nodata value, id, or risk types is listed by case id and by what changed — never as a summary.
This convention exists because of a measured failure. The rc1 → rc3 fix to
polygon_*_baseline (four different geometries shipping under one family name) was
correct, and it silently broke a downstream test: the consumer had a canary pinning
the old behaviour, it went red on upgrade, and there was no signal as to why. An
entry reading "fixed baseline consistency" costs the reader an afternoon; one naming
the case and the geometry costs them five seconds.
A user hitting a break searches for the case id or the error text, so both belong in the entry verbatim.
[Unreleased]¶
Added — benchmark edge batteries, raster and predicate tasks (Plan 46 Phase 3.3, #31)¶
Benchmark only; no catalog case changed and no prompt.md changed. Append-only.
sample_at(1 → 4 edges):bottom_up_sentinel,non_square_sentinel,nan_nodata_keeps_minus_9999.zonal_mean(1 → 5):bottom_up_transform,non_square_pixels,all_nodata_window,nan_beside_sentinel.tag_points(1 → 4):point_on_outer_boundary,point_on_corner_vertex,point_on_hole_boundary.label_point(2 → 4):multipolygon_gap,thin_l_band.fix_geometry(1 → 4):bowtie_southern_offset,bowtie_tiny_coordinates,bowtie_unequal_lobes.
Added — benchmark edge batteries, antimeridian tasks (Plan 46 Phase 3, #31)¶
Benchmark only; no catalog case changed and no prompt.md changed. Checks are
append-only: existing check names keep their meaning.
area_m2,buffer_m,position_at,split_antimeridianeach go from one edge check to five. New edge checks:area_m2antimeridian_westward,pole_adjacent_crossing,equator_crossing,southern_hemisphere;buffer_mdateline_westward,pole_adjacent,equator_crossing_line,southern_hemisphere;position_atdateline_westward,pole_adjacent,equator_crossing,southern_hemisphere;split_antimeridianwestward_ring,pole_adjacent,equator_crossing,southern_hemisphere. The newarea_m2andsplit_antimeridianinputs differ (only the original box is shared).tests/benchmark/test_results_pin.pyregrades a committed module over the check names recorded in itsgraded.jsononly; checks added later are not drift.
Added — vector differential round (Plan 42 Phases 3–4, #27)¶
geocase.differential.default_comparenow comparesgeometry-dtype frame columns withcompare_geometries, so a frame divergence reportsgeometry state differs: NULL vs EMPTY(orNaN-coordinate vs present, orPOINT EMPTY vs POLYGON EMPTY) instead of two reprs. No case changed.examples/test_differential_vector.py: pyogrio vs. raw OGR + GEOS over the vector corpus. Result: 104 agree, 0 diverged, 0 errored — no finding.
Added — a reusable differential-round instrument (Plan 51, #46)¶
geocase.differential gains run_round and render_report, so a new
differential round (PROJ, GEOS, or your own) no longer hand-rolls the same
write-up the pyogrio and GDAL rounds each wrote from scratch:
run_round(...)iscompare_casesplussummarize, bundled into a newRoundReport.render_report(report)renders it as Markdown: an environment table, the outcome counts, and a section per non-agreeoutcome with each result's detail and, for aknownresult, itsupstream_url.- Both are exported from
geocase.differential.__all__, alongside the existingcompare_cases/summarize. Not part of theimport geocasev1.0 surface, same footing as the rest of the submodule.
Added — one-click release pipeline (Plan 50)¶
Tooling only; nothing in the package changes.
prepare-release.yml: one input (the version) opens the release PR, using the newscripts/prepare_release.py.release.yml: a merged release PR tags the version, uploads to TestPyPI without approval, smoke-tests the TestPyPI package in clean runners (scripts/smoke_release.py, core and[array]), waits for approval before PyPI, then smoke-tests PyPI, creates the GitHub release fromscripts/changelog_section.py, and closes the release issue.
[1.1.0] — 2026-09-27¶
Added — the array extra for geocase.raster (#44)¶
geocase.raster imports numpy, but numpy is not a core dependency, so on a plain
pip install geocase without numpy, import geocase.raster failed with
ModuleNotFoundError: No module named 'numpy'. It now raises
ImportError: geocase.raster needs numpy ... pip install "geocase[array]".
- New extra
array = ["numpy>=1.24,<3"];allincludes it, andwritenow lists numpy explicitly. - Core stays
pydantic,pyyaml,geofacts: plaingeocasestill never installs or changes numpy. - The docs no longer call
geocase.raster"dependency-free".
Changed — benchmark prompts (Plan 46 Phase 0)¶
Two task prompts were edited, so their prompt_sha256 moved. Each task now
carries prompt_version: 2 in its task.yaml, the superseded text is archived
as prompt.v1.md beside prompt.md, and every committed run's recorded hash
still reproduces from the version that run was sent. A run made under v1 is
not comparable with one made under v2 for these two tasks; the prompt-hash
test reports the split rather than failing on it. Listed by task and by what
changed:
project_line— the stated tolerance moved from 1 km to 25 km. The grader had always enforced 25 km (LIMIT_M), so a submission 20 km off violated the prompt's contract and scoredPASS. The prompt moved rather than the oracle: the trap is densify-before-reproject, whose failure is >500 km, and a 1 km bar would additionally have measured how many waypoints a model chose. The oracle's behaviour is unchanged; no committed grading moves.utm_epsg_for— the contract now names the zone-assignment standard: the grid zone "as assigned by the Military Grid Reference System, whose zone numbering includes the published grid exceptions". The old prompt asked for the CRS "appropriate for that location", under whichpyproj.query_utm_crs_info's answers (32632 for 10.5E 78N, 32631 for 4.5E 60N — EPSG's areas of use do not encode the 33X/32V exceptions) were a defensible reading scoredSILENT. The grader's expected codes are unchanged and now cited (DMA TM 8358.1, ch. 3) in aSOURCESdict, which every hand-typed grader constant must carry.
Added — benchmark¶
python -m geocase.benchmark report— task x model matrix, per-trap-category trapped rates with Wilson intervals, reproducible-silent tasks at k>=3, and--coverage. Unpublishable runs are excluded from every rate and named.classify_trialingeocase.benchmark.taxonomy:trapped(controls pass, edge silently wrong) vsbroken(a control did not pass), derived at report time from the stored checks. No record schema changes.TRAP_TO_RISK: thetrap_category-> catalogrisk_typesmapping; the coverage test pins thattransform,dtypeandprecisionhave no geo task.
Plan 44. Eight cases added, no case removed, no existing geometry / CRS / dtype / nodata value / id changed. Every existing case keeps every risk type it had — the changes below are additions only, so nothing that selected before stops selecting.
Added — corpus¶
The numeric-boundary family, under
src/geocase/data/core/vector/special/precision/. Seven single-point GeoJSON
cases, single-variable, isolating where a text formatter stops representing the
double it was given. precision_loss_geojson_roundtrip found this class by
accident — one of its three points happens to sit at 1e-14 — and the family
exists so finding it again is not an accident:
numeric_boundary_1e14— the hit. GDALd6fd56f52dwrites this coordinate as0through both the WKT and the GeoJSON writers, silently, at relative error 1.0. Not a precision floor:1e-14is fourteen decimal places, inside the writer's default of fifteen.numeric_boundary_1e13— the upper bracket. A brute force over 300 000 coordinates puts the zeroing class at exactly|v|in[1e-14, 1e-13), so this value must survive. A consumer that loses it has a wider bug.numeric_boundary_1e15— the lower bracket, separating the writer bug from "the value fell off the end of 15 decimal places". Losing1e-15is documented behaviour; losing1e-14is not.numeric_boundary_trailing_zeros—0.000000010000001, which loses its trailing1throughintelliround(ogr/ogrutils.cpp:161-169): the routine testss[len-3]throughs[len-9]for zeros, then drops the last 8 characters including the untesteds[len-2]. Same mechanism as the1e-14finding, at a magnitude nobody would call small.numeric_boundary_trailing_nines— the other patternintelliroundspecial-cases, where rounding carries across every digit. Itsycarries across an integer boundary, so a consumer rounding to 15 places returns51.0for a point that was never at 51.numeric_boundary_15_significant_digits— control. Inside the writer's 15-place default, so it must survive unchanged.numeric_boundary_17_significant_digits— control. 17 digits is what IEEE 754 doubles need in the worst case, two more than the writer emits, so this value is expected to change on a text round-trip. It exists so a consumer can tell that documented limit apart from the1e-14class, which is a defect.
And one raster:
optical_dateline_west_small— an RGB GeoTIFF straddling the antimeridian at-180, mirroringoptical_dateline_smallabout 180. Identical size, dtype, band count, CRS and pixel size, so a behavioural difference between the pair is attributable to the direction of the crossing and to nothing else.
Added — risk types¶
Five terms, all additive. Existing spellings are untouched and no alias was needed.
precision/formatter_boundary,precision/significant_digits,precision/denormal_magnitude— the numeric axis the corpus had no way to name.failure_mode/consumer,failure_mode/reference_implementation— a new family recording whose failure mode a case is. Round 6 pointed the corpus at GDAL itself and the georeferencing-convention cases all passed; that is the correct result, because those cases are failure modes for consumers of GDAL, not for GDAL. The corpus previously could not express the difference.
risk_types is a pinned selector surface, so shipping this family commits the
two terms and the axis they cut. That was decided deliberately (Plan 44, U26):
the family is additive — no existing term changes meaning and no existing
query changes its result set — and if a later round shows the split wants a
third value, adding one is additive again.
Changed — corpus¶
risk_types gained one or more failure_mode/* terms on the cases below.
Additions only — no term was removed, renamed or retired, so every existing
selector returns what it did before. Listed by id because a new term changes what
list_cases(risk_types_any=...) returns for the added term:
failure_mode/consumer—dem_nan_nodata_small,rotated_two_islands,geotiff_int8_small,landcover_ambiguous_zero_small,water_mask_small,bottom_up_dem_small,pixel_is_area_dem_small,pixel_is_point_dem_small.failure_mode/reference_implementation—optical_dateline_small,precision_loss_geojson_roundtrip. The cases added in this release carry the term from the start and are listed here too, so that the set this selector returns can be read off one list:optical_dateline_west_small,numeric_boundary_1e13,numeric_boundary_1e14,numeric_boundary_1e15,numeric_boundary_15_significant_digits,numeric_boundary_17_significant_digits,numeric_boundary_trailing_nines,numeric_boundary_trailing_zeros.
Complete as shipped: failure_mode/consumer selects 8 cases and
failure_mode/reference_implementation selects 10.
optical_dateline_small and precision_loss_geojson_roundtrip additionally
gained a known_divergences record apiece, so a repeat differential run against
GDAL reports known rather than diverged. This changes differential output
only; it does not affect selection.
Added — CI¶
- A
floorjob installs GeoCase on Python 3.11 with every core dependency at its declared minimum (pydantic==2.0,pyyaml==6.0,geofacts==0.1.2,pytest==7.0, pinned inci/floor-constraints.txt) and no extras, then runstests/withouttests/benchmark. A unit test keeps the pins equal to the>=bounds inpyproject.toml. Tests that need an extra now carry@pytest.mark.requires(...)and skip without it. No dependency bound changed.
[1.0.0] — 2026-09-05¶
The first stable release. Everything below landed after the 2026-08-02 feature freeze
(recorded as 1.0.0-freeze at the bottom of this file) and
shipped through the release candidates 1.0.0rc1, 1.0.0rc2 and 1.0.0rc3, which are
on PyPI. If you are upgrading from an rc, the entries below are cumulative across all
three: read Changed — corpus first, since that is the section that can change what a
selector returns.
Changed — corpus¶
risk_typesconsolidated to a canonical vocabulary (124 terms → 104) — plan 40 phase 3, executing plan 27 §1.2–1.3. Terms are nowfamily/specificwhere a family has more than one member, and every term is gated:scripts/validate_catalog.pyrejects a spelling outsidesrc/geocase/catalog/risk_types.py. Before this, only four terms were checked against anything, so the rest were indistinguishable from typos — which is how the corpus reached 124 terms over 163 cases with 78 singletons, and howdocs/adding-a-case.mdcame to teach two worked-example terms (topology_breakage,attribute_encoding) that existed in no case.
Nothing silently stops selecting. Every merged spelling is recorded in
RISK_TYPE_ALIASES and resolved at selection time, so
list_cases(risk_types_any=["coordinate_order"]) returns what it always did. A bare
family prefix now also selects — risk_types_any=["crs"] matches every crs/* term.
Two terms were retired rather than renamed, and these are the ones that change what a selector returns:
none(9 cases) — the absence of a risk type, spelled wrong. Those nine cases now declarerisk_types: []. Anything selecting on"none"returns nothing. Affected:simple_valid_point,simple_valid_linestring,simple_valid_multipoint,simple_valid_multilinestring,simple_valid_polygon,simple_valid_multipolygon,dense_ring_polygon_4k,dense_ring_polygon_4k_gpkg,fractal_coastline_polygon.format_comparison(60 cases) — a corpus-construction label rather than a failure mode, covering 37% of the catalog. Moved totags, so select it withtags_any=["format_comparison"]instead ofrisk_types_any=[...]. No case lost the label.
Two merges the brief proposed were not made, because the corpus's own tests
proved the distinction load-bearing: lat_lon_swap stays separate from
crs/axis_order (the GML baselines declare an authority axis order the bytes
genuinely honour; out_of_bounds_coordinates is a swap caught only because latitude
100 is out of range), and crs_mishandled stays separate from crs/mismatch (a
mismatch is a property of a pair, which is why crs_mismatch_overlay_pair exists).
bottom_up_dem_small,rotated_bottom_up_smallgainedtransform/bottom_up;pixel_is_area_dem_small,pixel_is_point_dem_smallgainedtransform/pixel_anchor. All four previously declared onlynodata_ignored, so the two conventions that produced the most severe findings of validation rounds 1 and 2 were unsearchable by the index built to find them. No bytes changed.
Added — corpus¶
-
Three single-variable controls (163 → 166) — plan 40 phase 4, under
raster/single_variable/:rotated_only_square(a 30° rotated affine, no nodata),nodata_only_dem_small(two interior-9999sentinels, north-up), andbottom_up_only_square(a positive-eaffine alone).rotated_two_islandsbundles rotation with sparse islands with footprint generation, so a failure there needs an argument about which caused it. These remove the argument: a defect reproducing on the control and its bundled counterpart is localised with no further work. -
expected_pixel_world_pairs— plan 41 phase 3.3.[row, col, x, y]quadruples in the case's own CRS, on all five rotated rasters androtated_only_square. Round 4's only irreducible finding required hand-rolling the inverse affine to see where a pixel landed; the fixture now ships that answer.expected_boundsis only the axis-aligned envelope on a rotated raster and says nothing about individual pixels, which is exactly what was wrong. -
A
known_divergencesrecord onoptical_dateline_smallnaming the consequence: the footprint reaches longitude 180.22, so floor-based tile indexing requests a tile at 180 and raises. The geometry was always in the file and was not enough — round 4 found the crash only after hand-rolling the tile arithmetic.
Added¶
geocase.risk_types()— the reverse index,{term: [case ids]}. The forward direction has always existed vialist_cases(risk_types_any=...); a reporter built this half by hand with an ad-hocCounterover all 163 cases. Additive.list_cases(risk_types_all=...)— the missing half of the pair.tagshas had both_anyand_allsince v1.0 whilerisk_typeshad only_any. Additive.CaseMetadata.case_id— a read-only alias for.id, plus a directedAttributeErrorfor near-misses. The package spells one concept two ways (KnownDivergence.case_idagainstCaseMetadata.id), and a reporter's first call wasc.case_id, which raised a bare pydantic error naming nothing useful. The underlying inconsistency is a v1.1 naming item, not a v1.0 change.
Changed — docs site¶
No library behaviour changes here. Three surfaces were unpublished from the docs
site ahead of the v1.0 release. All of them stay in the repository and remain
readable on GitHub — exclude_docs removes them from the build, so they are no
longer reachable by URL either.
docs/benchmark/— the LLM-benchmark subsystem. It is explicitly not part of the v1.0 compatibility promise, and it is not mature enough to present as a product surface beside a 1.0 release. Removed from the nav and from the build: nav removal alone would have left the page live at its URL. TheREADME.mdpointer stays and is now marked experimental and unpublished.docs/design/— four documents specifying a case-recommendation service, an API spec, and a database schema for a backend that does not exist. The pages do say "proposed", but published beside a v1.0 release they read as shipping features. The three published pages that linked into them (contributing/workflow.md,contributing/structure-and-planning.md,index.md) now link by GitHub URL, per the rule that an unpublished doc is never linked relatively.docs/design/presentation-brief.md— a brief handed to a design tool describing how to pitch GeoCase. It was already off-nav but was still being built and served, and it links to avalidationbranch that will break when the branch is deleted.
Also scrubbed: absolute local paths of the form /Users/<name>/projects/… in plans 37,
38, and 39, which named a developer's home directory and pointed at private
repositories no reader can open. Now written as ~/projects/….
Added¶
crs_mismatch_overlay_pair(153 → 154) — plan 36 phase 2, executing plan 27 §1.1.crs_mismatchsat in the README's opening prose and in the catalog's pre-committed search vocabulary while no case declared it: the nearest candidates (rasterize_match_wgs84_polygon,web_mercator_baseline) are single-layer, and a CRS mismatch is a relationship between two inputs that one file cannot express. The new case is the catalog's first two-layer case — one footprint in southeastern Norway written twice, as a WGS84 primary and a sidecar holding UTM 33N metres while declaring EPSG:4326. Both files are individually well-formed and parse without warning; the defect exists only in the pair. Reprojecting the sidecar by its true EPSG lands it on the reference to within 0.0004 m, while trusting its declaration puts it 3359 km away, silently — an overlay or spatial join returns empty, which reads as "no features intersect" rather than as an error.
Ships as one case with a sidecar rather than two cross-referencing cases: a
relationship split across two independently-selectable ids can be selected apart,
and a selector returning half a relationship is a footgun. files.sidecars
already supported this, so no model change was needed. _check_crs_mismatch() in
catalog/content.py backs the risk type against the bytes — the only content
check that reads a sidecar as well as a primary — and fails if the sidecar is ever
rewritten with honest degrees.
- Three large curated vector cases (150 → 153) — plan 28 phase 3. Every vector case
in the catalog held at most 4 features, and 74 held exactly one, which made probes for
skip_features,max_features, Arrow batch chunking and paged reads execute without being able to fail: with one feature every batch boundary is the same boundary and every partial read is the full read. Each new case holds ~10,000 features with one defect placed past the boundary, undervector/special/scale/:invalid_geometry_at_scale_gpkg(a self-intersecting bowtie at index 9,999, invisible to any prefix read),null_after_batch_boundary_gpkg(the first NULL after 10,000 non-NULL integers — pyogrio returnsint64from a partial read andfloat64from the full read of the same column), andmixed_timezone_after_batch_gpkg(a UTC offset that changes only at the last row —datetime64[ms, UTC+01:00]partial againstdatetime64[ms, UTC]full). Generated by_large_specs()inscripts/generate_vector_fixtures.pyand covered by its--checkgate, with a fingerprint that compares the defect rather than 10,000 WKB blobs.
mixed_timezone_after_batch_gpkg is deliberately GeoPackage-non-conformant:
requirement 15 wants DATETIME in UTC with a literal Z, and GDAL warns on read. That
is the case's subject rather than an oversight — a conformant all-Z file was measured
and shows no dtype instability at all — and it is declared via
params.gpkg_datetime_conformant: false and the spec_nonconformance risk type.
Size impact: the bundled payload goes 2.1 MB → 5.1 MB and the wheel 456 KB →
1.25 MB, against verify_dist.py's unchanged 2 MB ceiling. Roughly one more trio of
this size fits; the one after that belongs in a remote manifest.
-
Seven cases closing gaps found by an external review (143 → 150). Three raster transform-convention cases:
bottom_up_dem_small(a positive-e, south-up affine — every other raster in the catalog is built withfrom_origin, which always emits a negativee, so code assuming north-up passed the whole corpus), pluspixel_is_area_dem_small/pixel_is_point_dem_small, a differential pair sharing a transform and an array and differing only inAREA_OR_POINT. Two NetCDF cases:ndvi_packed_netcdf(int16 packed byscale_factor, cross-linked with the GeoTIFFndvi_scaled_int16_smallas a same-failure-mode pair across containers) andcf_time_ordering_netcdf(CF time units on non-conventional(longitude, latitude, time)dimensions). Two vector cases:polygon_z_wkb/polygon_z_gpkg, the first fixtures in the catalog with Z coordinates, the GPKG half also carrying anidof 9007199254740993 (2^53 + 1) for readers that route integers through a double. -
scripts/generate_netcdf_fixtures.py. NetCDF fixtures are now regenerable and gated like the raster and vector families.--checkcompares semantics rather than bytes, because HDF5 stamps its library version into every file. -
NetCDF content checking.
check_case_contentno longer returns[]for the category;check_netcdf_contentverifies declared dimensions (in order), variables, fill values, packing and time units against the real file. -
Two
AssertionHintsfields, both additive andNone-defaulted:expected_transform_signsandexpected_pixel_anchor, with a newPixelAnchorliteral. Public assertionsassert_transform_signs,assert_pixel_anchorandassert_scale_factor. -
AssertionHints.required_drivers(plan 28 phase 2.1) — additive, defaults to[]. Declares the OGR driver an external consumer (pyogrio, fiona,ogr2ogr) needs before a case will open for them. It says nothing about GeoCase, which reads every vector case without OGR. Populated on the 20 vector cases a stock GDAL build cannot open: the 7 Parquet/Feather/Arrow/GeoArrow cases nameParquet/Arrow(the optionallibgdal-arrow-parquetplugin), and the 13 WKB/WKT bare-geometry cases declare the newNO_OGR_DRIVERsentinel — the empty string, deliberately falsy — because no driver exists for them at any build configuration. An external validation run had logged all 20 as spurious failures.loader_hintcannot express this: it marks all 113 vector casesgeopandas. -
loader_hintfilter (plan 28 phase 2.2) onlist_cases(),select_cases(),matches_selection()andSuiteSelection. Additive and keyword-only. -
AssertionHints.expected_error_kind(plan 28 phase 2.4) — additive, defaults toNone, with a newExpectedErrorKindliteral ingeocase.catalog.models:unparseable_geometry|unsupported_format|missing_driver|invalid_crs|invalid_topology. A harness could previously assert only that a case failed, never how, so "failed for the curated reason", "failed because a driver is missing" and "failed because the consumer has a new bug" were indistinguishable — an external validation run had to separate all 20 of its failures by hand. A vocabulary rather than exception classes, since the class belongs to the consumer (the same unclosed ring isGEOSExceptionfrom shapely,DataSourceErrorfrom pyogrio,ValueErrorfrom pandas). Gated in both directions:AssertionHintsrejects the field on a case that is notexpect_loadable: false, and the content gate opens the file and reports a finding when the observed failure does not match the declared kind, via the newgeocase.catalog.content.classify_error. Declared onunclosed_ring_polygon, the corpus's oneexpect_loadable: falsecase. -
CaseMetadata.known_divergences(plan 28 phase 2.5) — additive, defaults to[], holdingKnownDivergence(consumer, version_range, description, upstream_url)records. A divergence investigated once stays investigated: without it the next person running a differential harness re-investigates from scratch and — the expensive part — cannot tell a newly introduced consumer bug on a case from the one already understood. A record, not an assertion: nothing in the content gate can verify it, because whether it still reproduces depends on the reader the user has installed. Seeded onempty_geometry_gpkgwith the pyogrio Arrow / GPKG spatial-filter finding traced into GDAL'sGetArrowStream. Rendered as its own "Known consumer divergences" section on the case's catalog page. -
geocase.differential(plan 28 phase 2.6) — read every case two ways, compare, report the disagreements.compare_case,compare_cases,summarize,default_compareand theDifferentialResultdataclass. This is the mode with external evidence behind it: both defects an independent validation run found (a pyogrioread_dataframecrash, since patched upstream, and the GDALGetArrowStreamdivergence) came from comparing a consumer against itself, not against any assertion GeoCase declares — so neither path needs to be an oracle.
Four outcomes, kept distinguishable on purpose: agree (including both paths
failing identically, so a curated-failure case is agreement rather than a finding),
diverged, known (matched against known_divergences for the consumer named in
consumer=), and errored (exactly one path raised — a crash and a wrong answer
need different triage). Selection keywords are forwarded verbatim to list_cases.
default_compare understands GeoDataFrames row-count-first and treats None,
NaN, NaT and NA as the same missing value; it stops there, and compare=
takes your own. Not in geocase.__all__ — a submodule import, the precedent
geocase.raster and geocase.assertions set. Documented at
docs/differential-testing.md with a runnable
examples/test_differential_pyogrio.py.
- The
expect_loadable×expect_valid_geometrymatrix is documented in docs/adding-a-case.md, with the assertion each cell implies — the documentation answer to a report finding that the two fields read as one axis and are not.unclosed_ring_polygonraises before a geometry exists;self_intersecting_polygonreturns an object whose.is_validisFalse. Both declareexpect_valid_geometry: false, and a harness that writesassert not geom.is_validfor both fails on the first for the wrong reason.
Fixed¶
pip install "geocase[all]"could break a working geospatial environment (plan 40 phase 1). Installed into a venv created with--system-site-packagesover GDAL 3.6.2 / geopandas 0.12.2 / scipy 1.10.1, it resolved numpy 2.4.6 — incompatible with that scipy — shadowed the system geopandas and pandas, and left pandas unimportable:
Every optional dependency is now bounded at the next major
(geopandas>=0.14,<2, shapely>=2.0,<3, pyarrow>=14.0,<23,
rasterio>=1.3,<2, xarray>=2023.1,<2027, netCDF4>=1.6,<2, and likewise
for the bench, dev and docs groups), so a future major cannot be pulled
in silently.
Bounds alone do not prevent this, and the fix that matters is knowing
which install to run. There are two supported shapes, now documented in
README.md and docs/getting-started.md: pip install "geocase[all]" for a
greenfield environment, and plain pip install geocase when you
already have a geo stack. The plain install is enough to enumerate, select and
resolve every case — verified against a clean interpreter with numpy,
rasterio, geopandas and xarray all absent — because case.primary_path is an
ordinary filesystem path and gdal.Open reads it directly. The extras are the
convenience loaders, not how a case is read.
Changed¶
-
list_cases(format="vector")now raises a redirectingValueErrorinstead of a raw pydanticValidationErrorreciting all 17FormatTypeliterals (plan 28 phase 2.3). Passing any of the fourCategoryvalues —"vector","raster","netcdf","satellite"— toformatgets a message pointing atcategory=instead. Theformatparameter keeps its name; renaming it would break the v1.0 keyword surface to fix a message. Both before and after are exceptions, so the only code affected is code catchingValidationErrorspecifically. -
(data)
latlon_sample.ncwas replaced. It was the only fixture in the repository that could not be regenerated: its temperature values were unseeded random floats committed in a single "Analyse structure" commit, and 400 seed and distribution combinations failed to recover them. It is now emitted from a deterministic ramp.
Shape (5, 8), float64, _FillValue = -9999.0 and both fill positions are
preserved exactly, so every assertion the case declares still holds. Only the
temperature values differ. If you pinned an earlier release and asserted against
specific temperatures, those assertions will fail.
The case also dropped three declarations it could not demonstrate: the
coordinate_order and dimension_mismatch risk types (the data is conventional
(latitude, longitude) rectilinear, which exercises neither) and
expect_crs/expected_epsg (the file has no grid_mapping and no crs variable).
The dimension-ordering risk now lives on cf_time_ordering_netcdf, which demonstrates it.
-
expected_scale_factoris now enforced. It had been declared in the model, the schema and three rastercase.yamls since the raster action plan, and read by nothing. Those three cases are checked against their real band scales for the first time. -
The six
*_gml_baselinecases declareaxis_order. Their bytes were always latitude-first —urn:ogc:def:crs:EPSG::4326forces the authority's declared axis order — and nocase.yamlsaid so. Bytes are unchanged; a new content check verifies the claim against them. Theirnotes.mdfiles also had their geometry corrected: all six still quoted pre-relocation coordinates. -
BREAKING (data): the
<geometry>_<format>_baselinefixtures now hold the geometry they always claimed to. 53 of the 60 shipped different coordinates from the GeoJSON canonical named in their ownparams.canonical_source_case_id. Nothing insrc/ortests/ever dereferenced that link, so the divergence was structurally invisible — and anyone who trusted the naming and diffed, say, KML against Shapefile got a "cross-format difference" that was purely a fixture accident. Two independent evaluations hit exactly that.
Every baseline payload changed. If you assert baseline coordinates downstream, use the table below to update them. Old values are shown normalized, so a row may differ from your file's literal ring order or vertex start.
| Family | Formats | Old geometry | New (canonical) geometry |
|---|---|---|---|
| point | CSV_WKT, GPKG, SQLite, Shapefile, WKB, WKT | POINT (10 52) |
POINT (12.5 55.7) |
| point | GML | POINT (10.5 50.5) |
POINT (12.5 55.7) |
| point | Arrow, Feather, FlatGeobuf, KML | (already correct) | POINT (12.5 55.7) |
| linestring | GML, GPKG, KML, SQLite, Shapefile, WKB, WKT | LINESTRING (0 0, 1 1, 2 0) |
LINESTRING (10 50, 10.5 50.3, 11 50.1) |
| linestring | FlatGeobuf, GeoArrow | LINESTRING (12 55, 12.5 55.4, 13 55.8) |
LINESTRING (10 50, 10.5 50.3, 11 50.1) |
| linestring | CSV_WKT | (already correct) | LINESTRING (10 50, 10.5 50.3, 11 50.1) |
| polygon | FlatGeobuf, GML, KML, Parquet, WKB, WKT | POLYGON ((12 55, 12 56, 13 56, 13 55, 12 55)) |
POLYGON ((10 50, 11 50, 11 51, 10 51, 10 50)) |
| polygon | CSV_WKT, GPKG | POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0)) |
POLYGON ((10 50, 11 50, 11 51, 10 51, 10 50)) |
| polygon | SQLite, Shapefile | (already correct) | POLYGON ((10 50, 11 50, 11 51, 10 51, 10 50)) |
| multipoint | all but Feather | MULTIPOINT ((0 0), (1 1), (2 2)) |
MULTIPOINT ((10 50), (10.2 50.1), (10.4 50.2)) |
| multipoint | Feather | MULTIPOINT ((12 55), (12.2 55.1), (12.4 55.2)) |
MULTIPOINT ((10 50), (10.2 50.1), (10.4 50.2)) |
| multilinestring | all but Parquet | MULTILINESTRING ((0 0, 1 1), (2 2, 3 3)) |
MULTILINESTRING ((10 50, 10.5 50.2, 11 50.1), (10.2 49.8, 10.8 49.9, 11.1 50)) |
| multilinestring | Parquet | MULTILINESTRING ((12 55, 12.4 55.2, 12.8 55.4), (12.1 54.8, 12.6 55, 13 55.3)) |
(as above) |
| multipolygon | all | MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)), ((2 2, 3 2, 3 3, 2 3, 2 2))) |
MULTIPOLYGON (((10 50, 10.5 50, 10.5 50.5, 10 50.5, 10 50)), ((11 50, 11.5 50, 11.5 50.5, 11 50.5, 11 50))) |
Note the multilinestring row in particular: the old fixtures had two-vertex parts where the canonical has three, so no coordinate tolerance would ever have hidden the difference.
The six simple_valid_* GeoJSON canonicals are unchanged, including
simple_valid_polygon.params.expected_bounds.
- BREAKING (data): every baseline now carries exactly
id(int64, always1) andname(str, always the case id). Previously the schemas varied case by case —polygon_geopackage_baselinehadid, name, area_sqkmwhilepolygon_shapefile_baselinehad onlyname— so a consumer diffing two members could not tell which column differences were the format and which were fixture accident. Columns removed:value,area_sqkm,length_km,poly_count,segments, andsegment_co(itself a silent, undocumented DBF 10-character truncation ofsegment_count). Format-idiomatic schemas remain covered, deliberately and better, by thespecial/encoding/*cases.
Three formats cannot honour the schema and are documented exceptions: KML reads name
back as Name and synthesizes ~10 columns of its own, GML injects gml_id, and
WKT/WKB have no attribute slot at all (VectorCase.load() synthesizes name).
-
point_gml_baselinenow declaresparams.canonical_source_case_id. It carried thecross_format_canonicaltag while declaring an unrelatedcanonical_location: {lon, lat}literal that nothing read — the entire 59-declared vs 60-tagged gap. The literal is removed. -
Regenerating the KML baselines drops the hand-added
<Style>block thatpolygon_kml_baselinecarried. This is intentional, not an oversight: KML styling isformat_limited_kml_case's job, and a style element inside a family whose purpose is to hold everything but the format constant is one more uncontrolled variable. -
CI: the
catalogjob installs.[raster,vector]rather than.[raster]. The fixture generator now needs shapely, geopandas and pyarrow. -
(data) The
footprint_edge_casesfootprint sidecars are renamed, and three of them now hold different geometry. Each<case>_footprint.geojsonrecorded one of two incompatible things under one name: forhole_center_nodataandall_valid_rectangularit was ground truth derived from the raster's NoData mask, and for the other three it was a recording of the GDAL footprint utility's own simplified/hull output — inflated 1.98× / 1.88× / 2.80× over the real valid-pixel mask, withrotated_two_islandsandnonsquare_diagonal_sparsecollapsing genuinely disjoint regions into a single polygon.
The two meanings are now separate files, named for what they are:
| Old | New | Meaning |
|---|---|---|
<case>_footprint.geojson |
<case>_footprint_truth.geojson |
Mask-exact ground truth, regenerated from the same array as the raster. What params.expected_footprint points at. |
| — | <case>_footprint_gdal_hull.geojson |
The previously committed GDAL output, kept as a recorded-behaviour baseline. Pointed at by the new params.recorded_gdal_footprint. |
all_valid_rectangular has no _gdal_hull file: every pixel is valid, so the hull
is the mask. No case id, files.primary, fixture, marker or __all__ entry changed;
if you read these sidecars by filename, update the path and expect real geometry.
-
(data)
params.min_rect_ratiore-derived from the truth geometry for all five footprint edge cases. The old values (0.74 / 0.93 / 0.76 / 0.98) had been fitted to the near-rectangular hulls and so asserted almost nothing; the real shapes score 0.375 / 0.500 / 0.275 / 0.889. -
The content gate checks footprint shape, not just hole count.
geocase.catalog.content._check_footprintnow also compares part count and area against a freshly re-derived mask. A declared footprint that merges disjoint regions, or that is a hull rather than a mask, failsscripts/validate_case_content.py. This is stricter than before and may turn cases in external manifests red. -
landcover_small'sbehavioral_goalnow nameslandcover_ambiguous_zero_smallinstead of referring to a case that did not exist.
Added¶
-
landcover_ambiguous_zero_small— a new raster case where 0 is simultaneously a valid land-cover class and the declared NoData sentinel. A consumer maskingdata == nodatasilently deletes a legitimate class covering 25% of the scene; one ignoring NoData treats sentinel pixels as data. Nothing in the file distinguishes the two, and that indistinguishability is the case. It is the sibling oflandcover_small— the same scene with the ambiguity removed — so the pair isolates the collision from every other property. Declaresrisk_types: [ambiguous_zero, nodata_ignored, category_misread]. The bundled catalog is now 136 cases. -
shapefile_ring_orientation— a newspecial/encoding/case preserving the pre-convergencepolygon_shapefile_baselinebytes: the same square assimple_valid_polygonbut with a clockwise exterior ring. The Shapefile specification mandates CW exteriors where RFC 7946 mandates CCW, and OGR rewrites orientation on write, so a GeoJSON → Shapefile round trip silently reverses it. Code that readsis_ccwto tell an exterior from a hole breaks here.
It exists as its own case because the cross-format comparison must be winding-insensitive — the Shapefile members of any family can never match a CCW canonical — which makes this artifact unassertable inside a baseline family.
- Three gates, so this class of defect cannot return silently.
scripts/validate_catalog.pynow checks that thecross_format_canonicaltag andparams.canonical_source_case_idare biconditional, that the id resolves, that the target is GeoJSON, that geometry types match, and that a canonical is not itself tagged. No geospatial dependencies, so it runs in the GDAL-onlycatalogjob.tests/unit/test_cross_format_canonical.pyloads every tagged case throughVectorCase.load()and asserts geometry (viashapely.normalize, tolerance1e-9), geometry type, CRS (viapyproj.CRS, since the columnar formats return PROJJSON rather than the string"EPSG:4326"), thenamevalue, and the column schema. Cases are auto-discovered, so future baselines are gated automatically.scripts/generate_vector_fixtures.pynow generates all 60 baselines rather than only the five SpatiaLite ones, deriving each geometry from its declared canonical, and--checkverifies every one.
Fixed¶
-
geometry.xsdis now declared infiles.sidecarsfor the six GML cases. It was hashed bygenerate_checksums.pybut undeclared, andvalidate_catalog.pyonly checks declared files — so a missing GML schema would have shipped unnoticed. -
docs/dataset-catalog.mdgeography was wrong. Thirty-six baselines sat at or beside(0, 0)— colliding with thenull_island_pointsentinel's entire reason for existing — while the page claimed they were in Central Europe. Convergence makes the claim true and leaves only seven deliberate cases near the origin. The bundled-payload figure is also corrected from 4.2 MB to its actual 2.1 MB. The two numbers came from different measurements:du -sh src/geocase/datareports 4.2 MB because 572 mostly-tiny files each round up to a 4 KB block, while the payload's real byte sum is 2.1 MB (2.4 MB includingcase.yaml,notes.md, andchecksums.sha256). The wheel carries 2.3 MB uncompressed. 2.1 MB is the figure quoted everywhere;duoutput is not. -
The bundled case count is 135, not 134. The 1.0.0 entry's "134 bundled cases" was correct at that release; the catalog has grown by one since.
README.md,docs/index.mdandrecipe/meta.yaml's build-time assertion said 134 and now say 135, andscripts/validate_catalog.pygates all three againstlen(get_registry())so the number cannot drift again.
[1.0.0-freeze] — 2026-08-02¶
Not a published version. This section records the v1.0 feature freeze: the feature
set was finalised on 2026-08-02, and that date is what the heading records. Nothing was
uploaded under this heading. What actually shipped as 1.0.0 is the section above,
which folds this freeze together with everything the release candidates added; this
entry is kept because it is where the compatibility promise and the removals were first
written down.
Correction (2026-09-05): this heading previously read
## [1.0.0] — dated 2026-08-02, **not yet released**and stated that no GeoCase version had ever been uploaded to PyPI or TestPyPI. That was true when written and is no longer:1.0.0rc1,1.0.0rc2and1.0.0rc3were published to PyPI during August 2026, and1.0.0followed on 2026-09-05. Renamed to1.0.0-freezeso that exactly one section in this file describes the released1.0.0. Version0.1.0was never uploaded.An earlier correction (2026-08-23) fixed this entry's original claim that 1.0.0 was "the first release published to PyPI"; see Plan 25 for the release process that had not yet been run at that point.
The compatibility promise¶
v1.0 makes a stability commitment on two surfaces only:
- The pytest workflow — the
geocase_case/geocasefixtures and thegeocase_case,geocase_suite, andgeocase_selectmarkers. - The public API — the names exported from
import geocase, pinned against a literal intests/unit/test_public_api.py. This surface was 27 names at the freeze and is 29 as released, after Plan 31 addedSpatialExtentandCategory. That is an additive change: no name was removed or renamed, so the promise holds.
Everything else — module layout, internal helpers, the shape of geocase.catalog — is
internal and may change in a minor release. The promise is deliberately narrow because
those two surfaces are the genuinely mature ones; a wider claim would make the 1.0 label
dishonest.
Breaking changes¶
- The command-line interface and its
[project.scripts]entry point have been removed. The declaredgeocaseconsole script was broken in every install — it pointed at a module that raisedImportError— so no working usage can depend on it. There is no CLI in v1.0. Use the Python API or the pytest plugin.
Added¶
- Public API (
import geocase): a pinned surface — 27 names at this freeze, 29 as released — covering case discovery, loading, and inspection —list_cases,get_case,load_case,show_case,list_suites,get_suite,__version__, the case classes, the metadata models and enums, andRemoteCaseUnavailableError. docs/dataset-catalog.md: what the catalog contains, why each format was chosen, the geodetic rationale for every coordinate cluster, and an honest list of coverage gaps.- Generated catalog pages under
docs/_generated/catalog/: an index plus one page per case, per risk type, and per format, carryingschema.org/DatasetJSON-LD. - Manifest support:
extended-manifests/*.yamlparse and resolve, remote case ids are discoverable through the registry andshow_case, andGEOCASE_MANIFESTSselects which manifests are loaded. - CI quality gates:
ruff checkandruff format --check,mypy, a Python 3.11/3.14 test matrix, and non-blocking coverage reporting. - Catalog integrity gates: orphaned case metadata, manifest validity (shadowed ids,
cross-manifest duplicates, malformed digests, dangling
bundled_analogreferences),size_classversus real on-disk payload, generated-page drift, and case ids named in hand-written docs.
Changed¶
- Bundled data shrank from 36 MB to 2.1 MB. Five SpatiaLite fixtures declared
size_class: tinywere 6.7 MB each;size_classnow has enforced byte thresholds. cases/raster.pyloads throughloaders/rasterio_loader.py, making it the single raster load path.requires-pythonis>=3.11, with classifiers for 3.11 through 3.14 — the versions actually tested rather than the ones plausibly supported.- Development status classifier:
3 - Alpha→5 - Production/Stable. project.urlsnow point at the canonical repository host. (This entry originally said "GitLab"; the project has always lived on GitHub at https://github.com/farzinashouri/geocase, and the URLs point there.)- The case schema's
formatenum went from 7 values to 17, and itsassertionsblock from 6 documented fields to all 16. Both are now pinned to the models by a test.
Fixed¶
- The raster coverage matrix reported 25 of 30 cases: the generator's glob missed the five
footprint_edge_casesfiles. Because the artifact was gated bygit diff --exit-code, CI was actively enforcing the wrong number. - Case pages linked risk hubs at
../../risk/, one level too high, producing 187 broken links.mkdocs build --strictnow passes. materialize_caseraised an internalNo case root foundfor manifest cases instead of an actionable error; both error paths are now asserted in tests.- The registry singleton ignored
GEOCASE_MANIFESTSchanges after first use; resolved manifest paths are part of the cache key.
Removed¶
- The
cli/package and its entry point (see Breaking changes). catalog/validators.py— an empty stub nothing imported.raster/affine_transform_quirk/— an empty case directory that shipped in the wheel while appearing in no index. The rotated/skewed-transform coverage it implied is a genuine gap, now tracked on the v1.1 list rather than implied by a placeholder.
Deferred to v1.1¶
Stated as decisions, not omissions:
- Remote dataset transport. Declared remote cases are discoverable and raise clear
errors, but nothing downloads, caches, or unpacks. Both manifests are entirely
placeholder — every
sha256isreplace_me, everybase_uriisexample.org. The gate for reopening this is concrete: at least one real published archive with a real sha256. - Rotated/skewed affine transforms and non-square pixels, and southern-hemisphere UTM coverage — see the coverage gaps in the dataset catalog.
Known numbers¶
134 bundled cases (103 vector, 30 raster, 1 NetCDF) across 16 formats, 2.1 MB of bundled data, 780 passing tests, 54% line coverage.